Skip to main content

Overview

In order to verify that a wallet is eligible to participate in a sale, the Sonar backend issues signed purchase permits that tie the wallet to an entity on Sonar and proof that they passed the necessary checks configured for the sale. These purchase permits are intended to be passed to your sale contract with each purchase, and validated there using common ECDSA libraries. Next to information about the purchasing Sonar entity, the purchase permit can also contain extra information such as allocation limits, reserved amounts, etc.

Purchase Permit

Sonar generates permits with this structure (depends on your sale configuration, default is an Allocation Permit):
struct PurchasePermitWithAllocation {
    PurchasePermit permit;
    uint256 reservedAmount;    // Reserved allocation for this entity
    uint256 minAmount;         // Minimum purchase amount
    uint256 maxAmount;         // Maximum total amount this entity can purchase
}

Validation

To ensure the validity of a purchase permit, you need to verify the following aspects (see also the example implementation below). Please make sure to implement all of these checks to ensure correct enforcement of the requirements of your sale.
  1. The ECDSA signature was issued by the sonar signer
  2. The permit’s sale UUID matches yours
  3. The permit is not expired
  4. The sender matches the wallet address in the permit

Example Validation

These snippets are based on the example sale contract further below.

1. Signature Verification

You must verify that permits are signed by Sonar’s authorized signer:
// Grant this role to the signer address provided by Sonar
bytes32 public constant PURCHASE_PERMIT_SIGNER_ROLE = keccak256("PURCHASE_PERMIT_SIGNER_ROLE");

// Verify signature in your purchase function
address recoveredSigner = PurchasePermitWithAllocationLib.recoverSigner(permit, signature);
if (!hasRole(PURCHASE_PERMIT_SIGNER_ROLE, recoveredSigner)) {
    revert PurchasePermitUnauthorizedSigner(recoveredSigner);
}

2. Sale UUID Validation

Prevent permit reuse across different sales:
if (permit.permit.saleUUID != saleUUID) {
    revert PurchasePermitSaleUUIDMismatch(permit.permit.saleUUID, saleUUID);
}

3. Expiration Checks

Permits have short expiration times:
if (permit.permit.expiresAt <= block.timestamp) {
    revert PurchasePermitExpired();
}

4. Wallet Authorization

Ensure the permit is used by the authorized wallet:
if (permit.permit.wallet != msg.sender) {
    revert PurchasePermitSenderMismatch(msg.sender, permit.permit.wallet);
}

Integration Patterns

Wallet-Level Tracking (Basic)

The example contract tracks purchases by wallet address:
// Track amount purchased by each wallet
mapping(address => uint256) public amountByAddress;

function purchase(uint256 amount, /* permit params */) external {
    _validatePurchasePermit(purchasePermit, purchasePermitSignature);
    
    uint256 newTotalAmount = amountByAddress[msg.sender] + amount;
    
    // Validate allocation limits
    if (newTotalAmount < purchasePermit.minAmount) {
        revert AmountBelowMinimum(newTotalAmount, purchasePermit.minAmount);
    }
    if (newTotalAmount > purchasePermit.maxAmount) {
        revert AmountExceedsMaximum(newTotalAmount, purchasePermit.maxAmount);
    }
    
    amountByAddress[msg.sender] = newTotalAmount;
}

Entity-Level Tracking

// Track amount purchased by each entity
mapping(bytes16 => uint256) public amountByEntity;

function purchase(uint256 amount, /* permit params */) external {
    _validatePurchasePermit(purchasePermit, purchasePermitSignature);
    
    bytes16 entityID = purchasePermit.permit.entityID;
    uint256 newTotalAmount = amountByEntity[entityID] + amount;
    
    if (newTotalAmount > purchasePermit.maxAmount) {
        revert AmountExceedsMaximum(newTotalAmount, purchasePermit.maxAmount);
    }
    
    amountByEntity[entityID] = newTotalAmount;
}

Getting the Signer Address

The signer address for permits is provided when your sale is approved. You can find it by contacting support@echo.xyz.

Example Sale Contract

Here’s a complete example based on our reference contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;

import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol";
import {
    PurchasePermitWithAllocation,
    PurchasePermitWithAllocationLib
} from "./permits/PurchasePermitWithAllocation.sol";

contract ExampleSale is AccessControlEnumerable {
    /// @notice The role allowed to sign purchase permits.
    /// @dev This is intended to be granted to a wallet operated by the Sonar backend.
    bytes32 public constant PURCHASE_PERMIT_SIGNER_ROLE = keccak256("PURCHASE_PERMIT_SIGNER_ROLE");

    error PurchasePermitSaleUUIDMismatch(bytes16 got, bytes16 want);
    error PurchasePermitExpired();
    error PurchasePermitSenderMismatch(address got, address want);
    error PurchasePermitUnauthorizedSigner(address signer);
    error AmountBelowMinimum(uint256 amount, uint256 minAmount);
    error AmountExceedsMaximum(uint256 amount, uint256 maxAmount);
    error ZeroAddress();
    error ZeroEntityID();
    error AddressTiedToAnotherEntity(address addr, bytes16 got, bytes16 existing);

    event Purchased(address indexed wallet, bytes16 indexed entityID, uint256 amount, uint256 totalAmount);

    /// @notice The Sonar UUID of the sale.
    bytes16 public immutable saleUUID;

    /// @notice The amount purchased by wallet address
    mapping(address => uint256) public amountByAddress;

    /// @notice The ID of Sonar entities (individuals or organisations) associated to purchasing wallets.
    mapping(address => bytes16) public entityIDByAddress;

    constructor(bytes16 _saleUUID, address purchasePermitSigner) {
        saleUUID = _saleUUID;
        _grantRole(PURCHASE_PERMIT_SIGNER_ROLE, purchasePermitSigner);
    }

    /// @notice Allows a users to purchase an amount of something.
    /// @dev In this example, we just increment an amount storage against the purchasing wallet and don't transfer any actual tokens.
    function purchase(
        uint256 amount,
        PurchasePermitWithAllocation calldata purchasePermit,
        bytes calldata purchasePermitSignature
    ) external {
        // ensure the validity of the purchase permit issued by Sonar
        _validatePurchasePermit(purchasePermit, purchasePermitSignature);

        uint256 newTotalAmount = amountByAddress[msg.sender] + amount;

        // Validate against minimum amount (check if new total meets minimum)
        if (newTotalAmount < purchasePermit.minAmount) {
            revert AmountBelowMinimum(newTotalAmount, purchasePermit.minAmount);
        }

        // Validate against maximum amount
        if (newTotalAmount > purchasePermit.maxAmount) {
            revert AmountExceedsMaximum(newTotalAmount, purchasePermit.maxAmount);
        }

        _trackEntity(purchasePermit.permit.entityID, msg.sender);

        // Update the wallet's total amount purchased.
        // Note: This example tracks amounts only by the investing wallet.
        // One might also want to track/limit totals by entity ID.
        amountByAddress[msg.sender] = newTotalAmount;

        // Note: If the purchaser was transferring tokens as part of the purchase, you would do that here.

        emit Purchased(msg.sender, purchasePermit.permit.entityID, amount, newTotalAmount);
    }

    /// @notice Validates a purchase permit.
    /// @dev This ensures that the permit was issued for the right sale (preventing the use of permits issued for other sales),
    /// is not expired, and is signed by the purchase permit signer.
    function _validatePurchasePermit(PurchasePermitWithAllocation memory permit, bytes calldata signature)
        internal
        view
    {
        if (permit.permit.saleUUID != saleUUID) {
            revert PurchasePermitSaleUUIDMismatch(permit.permit.saleUUID, saleUUID);
        }

        if (permit.permit.expiresAt <= block.timestamp) {
            revert PurchasePermitExpired();
        }

        if (permit.permit.wallet != msg.sender) {
            revert PurchasePermitSenderMismatch(msg.sender, permit.permit.wallet);
        }

        address recoveredSigner = PurchasePermitWithAllocationLib.recoverSigner(permit, signature);
        if (!hasRole(PURCHASE_PERMIT_SIGNER_ROLE, recoveredSigner)) {
            revert PurchasePermitUnauthorizedSigner(recoveredSigner);
        }
    }

    /// @notice Tracks entities that have purchased.
    /// @dev Ensures that any purchasing wallet can only be associated to a single Sonar entity.
    function _trackEntity(bytes16 entityID, address addr) internal {
        if (entityID == bytes16(0)) {
            revert ZeroEntityID();
        }

        if (addr == address(0)) {
            revert ZeroAddress();
        }

        bytes16 existingEntityID = entityIDByAddress[addr];

        // If the wallet already has an associated sonar entity, we need to check if it's the same,
        // since wallets can only be used by a single entity.
        // While this is also enforced by the Sonar backend, it's still good to also check it on the contract.
        if (existingEntityID != bytes16(0)) {
            // Wallets can only be used by a single entity
            if (existingEntityID != entityID) {
                revert AddressTiedToAnotherEntity(addr, entityID, existingEntityID);
            }

            // entity is already tracked
            return;
        }

        // new entity so we track them
        entityIDByAddress[addr] = entityID;
    }
}

Next Steps

I