Assetera Docs
Smart contracts

Settlement router

AsseteraPrimarySales in detail: the settlement intent, four signatures over three nonce namespaces, and a constrained executor judged on measured balance deltas.

AsseteraPrimarySales is a constrained executor. It takes the buyer's settlement currency, calls a venue with calldata it did not author, and then judges the whole settlement on balance deltas it measured itself. It never trusts what the venue reports, and it holds no minting right on any path.

This page is the low-level reference. For what primary issuance is and what you have to build, start at Primary issuance.

This contract has been reviewed independently

Nethermind Security reviewed this router and reported no issues at any severity. The report is public: see Security reviews.

The settlement intent

One EIP-712 struct authorises one settlement. It has fourteen fields, all static types, and it is the pre-image of everything else on the path.

struct SettlementIntent {
  address buyer;             // must equal the ERC-2771 _msgSender()
  address assetToken;        // what the buyer must end up holding
  uint256 minAssetOut;       // the delivery floor, asserted against the MEASURED delta
  address settlementToken;   // the currency; must equal the fee attestation's feeToken
  uint256 venueQuoteIn;      // approved to the venue; the venue may consume less
  uint256 buyerFee;          // Assetera's fee, same token, ON TOP of venueQuoteIn
  uint256 maxSettlementIn;   // hard cap on the TOTAL buyer debit
  address feeCollector;      // must be on this router's own collector allowlist
  address venue;             // signed, and checked against no on-chain list
  bytes4  selector;          // asserted against bytes4(venueCalldata)
  bytes32 calldataHash;      // keccak256 of the calldata passed as its own argument
  bytes32 supplierReference; // the venue's own quote or order id, carried onto the event
  uint256 nonce;             // single-use, in this router's own namespace
  uint256 deadline;          // hard TTL cap, at most MAX_INTENT_TTL (15 minutes)
}

Its EIP-712 struct hash is also the paramsHash that both attestations must carry, so one hash pins four signatures to one settlement. An attestation minted for a different asset, venue or amount cannot be replayed onto this one.

The amount model, stated once

venueQuoteIn is the venue's firm quote and the most it can pull. buyerFee is charged on top of it, never carved out of it. maxSettlementIn is the buyer's own cap and the only number a buyer needs to read; the contract asserts maxSettlementIn >= venueQuoteIn + buyerFee. Whatever the venue does not take is refunded in the same transaction.

Four signatures, four signers, three nonce namespaces

SignatureSignerAccepted ifNonce namespace
Settlement intentSETTLEMENT_OPERATOR_ROLErecovers to a role holderintent nonces, per buyer
Buyer consentthe buyer, over the same digestvalidates for intent.buyer, EOA or ERC-1271(none of its own)
Compliance attestationKYC_OPERATOR_ROLErecovers to a role holder, paramsHash matchesKYC nonces, per account
Fee attestationFEE_OPERATOR_ROLErecovers to a role holder, paramsHash matchesfee nonces, per account

The three namespaces are independent: burning an intent nonce says nothing about a KYC or fee nonce of the same number. There is no fourth namespace because the buyer signs the intent, so the intent's own single-use nonce is the buyer's replay protection too.

All four are verified before any nonce is burned

Verification and consumption are separate passes. An invalid signature therefore cannot spend the other three, which is why a failed settlement leaves every nonce reusable.

SETTLEMENT_OPERATOR_ROLE is a role of its own, with its own key, because it is the only one of the three service roles whose holder can cause a transfer.

Why the buyer signs too

Every field of the intent belongs to the settlement operator, the delivery floor included. Without the buyer's own signature, a compromised settlement key could set minAssetOut to one wei and have the buyer's own transaction pay for it.

Wallet simulation would normally catch that, and here it cannot: under ERC-2771 the buyer signs a ForwardRequest whose data is opaque bytes no wallet can render as balance changes. An EIP-712 payload with named fields is what gives the protection back. The buyer signs the same intent digest rather than a smaller mirror struct, precisely so the two cannot drift apart.

The check uses SignatureChecker.isValidSignatureNow, not ECDSA.recover, so contract wallets work: Safe and embedded smart accounts validate through ERC-1271 on the same terms as an EOA. ERC-1271 signatures are revocable, so validity is asserted at settlement time and nowhere else, and the intent's short TTL keeps that window small.

The EIP-712 domain

EIP712Domain(name: "AsseteraPrimarySales", version: "1", chainId, verifyingContract)

Deliberately different from the exchange's domain name, which is "AsseteraExchange". Cross-contract replay is therefore impossible by construction rather than by check: an attestation minted for the exchange recovers to a different address here and is rejected as a bad signer.

The constrained executor

The venue's calldata is opaque bytes the router never interprets. It is constrained in exactly two ways, and the settlement is then judged on something else entirely.

BindingCheck
By hashkeccak256(venueCalldata) == intent.calldataHash, or CalldataHashMismatch
By selectorbytes4(venueCalldata) == intent.selector, or SelectorMismatch

There is no on-chain allowlist of venues, selectors, assets or currencies. That is a decision, not an omission: an allowlist does not bound a compromised settlement signer, because the attacker names the genuine venue and the genuine selector and simply sets the delivery floor to nothing.

Three things bound the loss instead: the per-currency cap below, the exact per-settlement allowance the buyer grants, and the fact that Assetera's own issuance settles against a contract that mints only what it has been paid for.

One structural guard does apply: intent.venue may not be either of the two tokens this settlement moves. Otherwise the opaque bytes would be a token call made by the router, with the router's own allowances.

The settlement is judged on measured balance deltas

0  guards: the venue is not either settled token; the attested bps and the signed fee agree
1  the per-currency cap is charged on venueQuoteIn + buyerFee, before any external call
2  snapshot: the buyer's asset balance, and the router's OWN balance of both tokens
3  pull venueQuoteIn + buyerFee from the buyer, and MEASURE what arrived
4  approve EXACTLY venueQuoteIn to the venue, then call it with the bound calldata
5  set the approval back to zero, and MEASURE what the venue consumed
6  refund the unconsumed remainder to the buyer
7  forward any asset that landed on the router (the increase only), then assert the
   buyer's MEASURED asset delta is at least minAssetOut
8  transfer buyerFee to the collector
9  assert the router's balance of BOTH tokens returned to its pre-call value

The four numbers on PrimarySettled come out of steps 5, 6 and 7. Nothing is relayed from whatever the venue chose to emit, which is why one log decoder works for every supplier.

Consequences worth knowing before you integrate:

  • A fee-on-transfer or deflationary settlement currency cannot settle at all. Step 3 refuses anything other than the exact amount, in either direction, with SettlementPullMismatch. That is a listing decision taken at the line that moves money rather than discovered in a reconciliation.
  • A venue consuming less than approved is normal, not an error. It is refunded.
  • Over-delivery is fine. minAssetOut is a floor, not a ceiling. Asset that a venue misdirects to the router is forwarded to the buyer (the increase over the snapshot, never the whole balance) before the floor is measured.
  • The router holds nothing. Both invariants are asserted against the pre-call balances, so a third party's stray donation is never swept into somebody's settlement, and never accumulates.
  • A rebase of the asset token during the venue call counts as delivery, because nothing on chain can tell it from an honest transfer. It requires the buyer to already hold a position in that asset, which a primary purchase usually does not. Both the venue and the asset are named in the intent the buyer signed.

Native currency is not accepted on the settlement path

There is no receive() and no sweep. The only payable function on the contract is an admin-only pass-through used for a venue's funding-wallet onboarding handshake, and it forwards the entire value in the same call or reverts.

The fee

QuestionAnswer
Which side paysthe buyer, only. A non-zero makerFeeBps reverts with MakerFeeNotSupported
In which currencythe settlement currency. The fee attestation's feeToken must equal intent.settlementToken, or FeeTokenNotALeg
How muchbuyerFee == floor(venueQuoteIn * takerFeeBps / 10_000), or BuyerFeeMismatch
Where it is takenafter the refund and after the delivery assertion, from the router's own holding
To whomintent.feeCollector, which must be on this router's allowlist and be the same collector the fee attestation names
Can the venue reach itno. buyerFee is never inside the approval given to the venue

The rounding direction is load-bearing: it floors, matching every other fee calculation on the platform. Two implementations that disagree by one wei would revert every settlement whose fee does not divide exactly.

The collector allowlist starts empty

A separate contract means separate storage. The exchange's allowlist does not carry over, so a non-zero fee cannot be settled until an admin lists a collector on this router.

The per-currency settlement cap, and its fail-closed default

function setSettlementCap(address token, uint256 wholeUnits) external; // DEFAULT_ADMIN_ROLE
function perTxCap(address token) external view returns (uint256);           // raw units
function perTxCapWholeUnits(address token) external view returns (uint256); // as it was set
  • The cap is set in whole tokens and stored in raw units. decimals() is read once, in the admin call, and never on the settlement path, so a Safe transaction stays readable and the hot path stays a pure comparison. The decimals in force at set time are on the event.
  • A zero cap means the currency cannot be settled at all. It does not mean "unlimited". Every token starts unconfigured, so enabling a new settlement currency is a deliberate admin action, and a currency nobody sized cannot reach a single line of the money path.
  • It is charged on venueQuoteIn + buyerFee, the full authorised debit, before the first external call. The refund is not known until after the venue has run, so charging the net amount would be a check made after the money moved. The full debit is the conservative reading and is also the number a human sizing the cap is looking at.
  • It bounds bugs, not attackers. What a per-transaction cap catches cheaply and reliably is an arithmetic or decimals mistake, the factor of a trillion between a 6-decimal currency and an 18-decimal asset. Ten to a hundred times the largest plausible order never fires in normal business and still turns that class of bug into a revert.

A token that does not report decimals(), or reports more than 36, cannot be capped and therefore cannot be settled in.

Compliance gating is closed by default

The router does not enumerate its actions at initialization. It stores the inverse, an exemption, so every action ordinal is gated from the moment the proxy exists, including ordinals that do not exist yet. An admin can exempt an action deliberately; nobody can exempt one by forgetting about it.

function complianceRequired(uint8 action) external view returns (bool);
function setComplianceRequired(Action action, bool required) external; // DEFAULT_ADMIN_ROLE

Turning the KYC gate off does not disable fee verification and does not disable intent verification. A settlement always needs a valid intent from the settlement operator.

The compliance attestation's orderId is passed as a literal zero on this path, so a non-zero one is rejected. There is no order book here, and leaving the field free would be a second unchecked degree of freedom in a signature the compliance signer produces.

Roles

RoleHolds
DEFAULT_ADMIN_ROLEupgrades, pause and unpause, the settlement caps, the collector allowlist, the compliance toggles, and the funding-wallet handshake. A multisig in production.
SETTLEMENT_OPERATOR_ROLEsigns the settlement intent. The only one of the three service roles whose holder can cause a transfer, and it is given its own key for that reason.
KYC_OPERATOR_ROLEsigns the compliance attestation.
FEE_OPERATOR_ROLEsigns the fee attestation.

Events

EventMeaning
PrimarySettledone settlement, reported entirely from measured effects: buyer, assetToken, venue (all indexed), then assetDelivered, settlementToken, venueIn, refund, fee, feeCollector, supplierReference, nonce.
IntentConsumedthe intent nonce was burned, under a given action ordinal. Joins to PrimarySettled on (buyer, nonce).
KycConsumed / FeeConsumedthe two attestations were consumed, in their own nonce namespaces.
CollectorAllowedthe fee-collector allowlist changed.
ComplianceRequiredSetthe KYC gate for one action ordinal was toggled.
SettlementCapSeta settlement currency's cap changed, with both forms and the decimals used.
WhitelistHandshakenative currency was passed through for a venue's funding-wallet onboarding.

PrimarySettled deliberately does not carry what kind of primary sale this was. The ledger leg is identical either way, and the distinction is on chain in the same transaction anyway, on IntentConsumed.

Point one address filter at one contract. AsseteraPrimarySales and AsseteraECS are different addresses with different event catalogs.

Errors you will actually hit

ErrorWhat went wrong
IntentBuyerMismatchintent.buyer is not the resolved actor. Nobody settles for somebody else.
BuyerConsentBadSignaturethe buyer's own signature is missing, malformed, or not valid for intent.buyer.
IntentBadSignerthe intent signature does not recover to a settlement operator.
IntentExpired / IntentTtlTooLongthe deadline has passed, or is further out than MAX_INTENT_TTL.
IntentNonceUsedalready settled. Intents are single-use per buyer.
CalldataHashMismatch / SelectorMismatchthe calldata is not the calldata that was signed.
ZeroAmountminAssetOut is zero, which would make the delivery assertion vacuous.
ZeroVenueQuotevenueQuoteIn is zero. A free distribution is refused by design; enabling one would be a deliberate change with its own gate.
MaxSettlementTooLowmaxSettlementIn < venueQuoteIn + buyerFee.
PerTxCapExceeded(token, debit, cap)over the cap, or the currency was never opened (cap reads zero).
BuyerFeeMismatch(attested, expected)the two fee signers disagree.
MakerFeeNotSupporteda non-zero issuer-side fee was attested.
FeeCollectorMismatchthe intent and the fee attestation name different collectors.
SettlementPullMismatch(requested, received)the currency did not move exactly what it was asked to move.
VenueCallFailedthe venue reverted. Its revert data is deliberately not bubbled: those are attacker-controlled bytes from an address nothing on chain constrains, and they are visible in a trace anyway.
InsufficientAssetDelivered(delivered, minAssetOut)the measured delivery is below the signed floor.
RouterBalanceChangedthe router's balance of one of the two tokens did not return to its pre-call value.

Read the source

The contract repository is public: Assetera-AG/AsseteraEvmContracts.

FileWhat it covers
contracts/src/primary/AsseteraPrimarySales.solthe assembled router: the entry point, the admin surface, the gate policy.
contracts/src/primary/types/PrimaryTypes.solthe action set, SettlementIntent, and the typehash.
contracts/src/primary/IntentGate.solintent verification, the buyer's consent, and the calldata binding.
contracts/src/primary/settle/VenueSettler.solthe money path, step by step, and every measurement.
contracts/src/primary/admin/SettlementLimits.solthe per-currency cap and the shared authorisation preamble.
contracts/src/primary/interfaces/ISettler.solPrimarySettled and the settlement errors.
contracts/src/primary/interfaces/IIntentGate.solIntentConsumed and every intent error.

Next

On this page