Skip to main content

Overview

For most sales, Sonar deploys and manages the SettlementSale contract for you. However, if you have specific requirements not covered by the standard contract, you can build a custom contract that integrates with Sonar’s compliance infrastructure.

When to Build a Custom Contract

  • You need custom onchain logic beyond what Sonar provides
  • You want to integrate token sale logic with other contract functionality
  • You have specific requirements for how commitments are structured
  • You want to extend the standard contract with additional features

Deployment Models

Sonar supports different levels of customization:
ModelContractSettlement Computation
StandardSonar deploys SettlementSaleSonar computes allocations
Custom with Sonar SettlementYou deploy custom contractSonar computes allocations
Fully CustomYou deploy custom contractYou compute and provide allocations
For the hybrid model (custom contract with Sonar-managed settlement), your contract must implement:
  • ICommitmentDataReader - so Sonar can read commitment data, which enables computing prices (for auctions) and allocations
  • IOffchainSettlement - so Sonar can write the computed allocations to your contract
See the sonar contract interfaces for implementation details.

The Core Integration

Your custom contract must validate purchase permits—signed authorizations from Sonar that prove a wallet is eligible to participate. Sonar handles all compliance (KYC/KYB, accreditation, jurisdiction checks) and issues permits for eligible participants. The permit contains:
  • Entity identity and authorization proof
  • Purchase limits (min/max amounts)
  • Price limits (for auctions)
  • Expiration timestamp

Purchase Permit

Sonar generates permits with this structure (see also the sonar contract repo):
struct PurchasePermitV3 {
    bytes16 saleSpecificEntityID;  // Sale-specific entity identifier (same entity has different IDs per sale)
    bytes16 saleUUID;              // Your Sale UUID
    address wallet;                // Authorized wallet address
    uint64 expiresAt;              // Unix timestamp expiration
    uint256 minAmount;             // Minimum commitment amount
    uint256 maxAmount;             // Maximum commitment amount
    uint64 minPrice;               // Minimum bid price (for auctions)
    uint64 maxPrice;               // Maximum bid price (for auctions)
    uint64 opensAt;                // Unix timestamp when permit becomes valid (inclusive)
    uint64 closesAt;               // Unix timestamp when permit stops being valid (exclusive)
    bytes payload;                 // Additional data (e.g., forcedLockup)
}

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 current time is within the permit’s time window (opensAt <= now < closesAt)
  5. The sender matches the wallet address in the permit
  6. The commitment amount is within the minimum and maximum amount limits
  7. The bid price is within the minimum and maximum price limits (for auctions)

Example Validation

These snippets are based on the example sale contract.

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 = PurchasePermitV3Lib.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.saleUUID != saleUUID) {
    revert PurchasePermitSaleUUIDMismatch(permit.saleUUID, saleUUID);
}

3. Expiration Checks

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

4. Time Window Validation

Permits include a time window during which they are valid for commitments:
if (block.timestamp < permit.opensAt) {
    revert PurchasePermitNotYetValid();
}
if (block.timestamp >= permit.closesAt) {
    revert PurchasePermitWindowClosed();
}

5. Wallet Authorization

Ensure the permit is used by the authorized wallet:
if (permit.wallet != msg.sender) {
    revert PurchasePermitSenderMismatch(msg.sender, 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 purchase 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

One might want to additionally enforce limits by entity, across all wallets they can use:
// Track amount purchased by each entity
mapping(bytes16 => uint256) public amountByEntity;

function purchase(uint256 amount, /* permit params */) external {
    _validatePurchasePermit(purchasePermit, purchasePermitSignature);

    bytes16 entityID = purchasePermit.saleSpecificEntityID;
    uint256 newTotalAmount = amountByEntity[entityID] + amount;

    if (newTotalAmount > purchasePermit.maxAmount) {
        revert AmountExceedsMaximum(newTotalAmount, purchasePermit.maxAmount);
    }

    amountByEntity[entityID] = newTotalAmount;
}

Auction bid price validation

If you are running a sale with a dynamic pricing model where the user can commit with a requested price (e.g. auctions), you should validate the bid price is within the minimum and maximum price limits.
struct Bid {
    uint64 price;
    uint256 amount;
}

mapping(address => Bid) internal bidByAddress;

function processBid(uint64 price, uint256 amount, /* permit params */) external {
    _validatePurchasePermit(purchasePermit, purchasePermitSignature);

    if (price > purchasePermit.maxPrice) {
        revert BidPriceExceedsMaxPrice(price, purchasePermit.maxPrice);
    }

    if (price < purchasePermit.minPrice) {
        revert BidPriceBelowMinPrice(price, purchasePermit.minPrice);
    }

    if (amount < purchasePermit.minAmount) {
        revert BidBelowMinAmount(amount, purchasePermit.minAmount);
    }

    if (amount > purchasePermit.maxAmount) {
        revert BidExceedsMaxAmount(amount, purchasePermit.maxAmount);
    }
    
    // Replace the existing bid
    bidByAddress[msg.sender] = Bid(price, amount);
}

Getting the Signer Address

Sonar signs all purchase permits using a global infrastructure key. When deploying your contract, grant the PURCHASE_PERMIT_SIGNER_ROLE to this address. For EVM chains: 0x9a1964e04d10ccfe1a9ce300d943f71c6e3ae24c For other chains, contact [email protected] to get the correct signer address.
The permit signer role is always held by Sonar’s infrastructure. This ensures all purchase authorizations flow through Sonar’s compliance verification, maintaining the integrity of the permit system.

See Also