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
| Signature | Signer | Accepted if | Nonce namespace |
|---|---|---|---|
| Settlement intent | SETTLEMENT_OPERATOR_ROLE | recovers to a role holder | intent nonces, per buyer |
| Buyer consent | the buyer, over the same digest | validates for intent.buyer, EOA or ERC-1271 | (none of its own) |
| Compliance attestation | KYC_OPERATOR_ROLE | recovers to a role holder, paramsHash matches | KYC nonces, per account |
| Fee attestation | FEE_OPERATOR_ROLE | recovers to a role holder, paramsHash matches | fee 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.
| Binding | Check |
|---|---|
| By hash | keccak256(venueCalldata) == intent.calldataHash, or CalldataHashMismatch |
| By selector | bytes4(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 valueThe 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.
minAssetOutis 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
| Question | Answer |
|---|---|
| Which side pays | the buyer, only. A non-zero makerFeeBps reverts with MakerFeeNotSupported |
| In which currency | the settlement currency. The fee attestation's feeToken must equal intent.settlementToken, or FeeTokenNotALeg |
| How much | buyerFee == floor(venueQuoteIn * takerFeeBps / 10_000), or BuyerFeeMismatch |
| Where it is taken | after the refund and after the delivery assertion, from the router's own holding |
| To whom | intent.feeCollector, which must be on this router's allowlist and be the same collector the fee attestation names |
| Can the venue reach it | no. 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_ROLETurning 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
| Role | Holds |
|---|---|
DEFAULT_ADMIN_ROLE | upgrades, pause and unpause, the settlement caps, the collector allowlist, the compliance toggles, and the funding-wallet handshake. A multisig in production. |
SETTLEMENT_OPERATOR_ROLE | signs 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_ROLE | signs the compliance attestation. |
FEE_OPERATOR_ROLE | signs the fee attestation. |
Events
| Event | Meaning |
|---|---|
PrimarySettled | one settlement, reported entirely from measured effects: buyer, assetToken, venue (all indexed), then assetDelivered, settlementToken, venueIn, refund, fee, feeCollector, supplierReference, nonce. |
IntentConsumed | the intent nonce was burned, under a given action ordinal. Joins to PrimarySettled on (buyer, nonce). |
KycConsumed / FeeConsumed | the two attestations were consumed, in their own nonce namespaces. |
CollectorAllowed | the fee-collector allowlist changed. |
ComplianceRequiredSet | the KYC gate for one action ordinal was toggled. |
SettlementCapSet | a settlement currency's cap changed, with both forms and the decimals used. |
WhitelistHandshake | native 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
| Error | What went wrong |
|---|---|
IntentBuyerMismatch | intent.buyer is not the resolved actor. Nobody settles for somebody else. |
BuyerConsentBadSignature | the buyer's own signature is missing, malformed, or not valid for intent.buyer. |
IntentBadSigner | the intent signature does not recover to a settlement operator. |
IntentExpired / IntentTtlTooLong | the deadline has passed, or is further out than MAX_INTENT_TTL. |
IntentNonceUsed | already settled. Intents are single-use per buyer. |
CalldataHashMismatch / SelectorMismatch | the calldata is not the calldata that was signed. |
ZeroAmount | minAssetOut is zero, which would make the delivery assertion vacuous. |
ZeroVenueQuote | venueQuoteIn is zero. A free distribution is refused by design; enabling one would be a deliberate change with its own gate. |
MaxSettlementTooLow | maxSettlementIn < 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. |
MakerFeeNotSupported | a non-zero issuer-side fee was attested. |
FeeCollectorMismatch | the intent and the fee attestation name different collectors. |
SettlementPullMismatch(requested, received) | the currency did not move exactly what it was asked to move. |
VenueCallFailed | the 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. |
RouterBalanceChanged | the 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.
| File | What it covers |
|---|---|
contracts/src/primary/AsseteraPrimarySales.sol | the assembled router: the entry point, the admin surface, the gate policy. |
contracts/src/primary/types/PrimaryTypes.sol | the action set, SettlementIntent, and the typehash. |
contracts/src/primary/IntentGate.sol | intent verification, the buyer's consent, and the calldata binding. |
contracts/src/primary/settle/VenueSettler.sol | the money path, step by step, and every measurement. |
contracts/src/primary/admin/SettlementLimits.sol | the per-currency cap and the shared authorisation preamble. |
contracts/src/primary/interfaces/ISettler.sol | PrimarySettled and the settlement errors. |
contracts/src/primary/interfaces/IIntentGate.sol | IntentConsumed and every intent error. |
Next
Issuance venue
The per-offering sale contract: price semantics, the decimals trap, and why the price beats the signed floor.
Primary issuance
What primary issuance is, and what an integrator actually has to build.
Attestations
The shared EIP-712 KYC and fee attestation model.
Security reviews
The independent review of this router, and the report itself.
Primary issuance
How a buyer's first acquisition of an asset settles on Assetera: offerings, the AsseteraPrimarySales router, and what an integrator actually has to build.
Issuance venue
AsseteraIssuanceVenue: one contract per offering, holding the price and the minting right. Unit-price semantics, the decimals trap, and why price beats the signed floor.