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.
AsseteraIssuanceVenue is one offering's primary sale, deployed one per offering. It does exactly
one thing: take settlement currency from the router and mint the asset to the buyer, at a price a
compliance officer set. No oracle, no order book, no inventory, no holder list, no currency
conversion. It is a swap with a price.
To the router it is just a venue
To AsseteraPrimarySales it is a venue in that router's exact
sense of the word: an address in a signed intent that gets approved an exact amount, called with calldata
bound by hash and selector, then judged entirely on measured balance deltas. Nothing here is privileged
there. The router cannot tell it from a third party's contract.
This contract is MIT licensed and public, so an issuer whose token does not mint through a plain
mint(address,uint256) can inherit from it and override one function rather than write a sale contract
of their own. See Extend the issuance venue.
Why the issuance path is a separate contract at all
Every field of a settlement intent belongs to the settlement operator, the delivery floor included. A mint module inside the router would therefore hand a compromised settlement key an unpriced mint: name yourself the buyer, set the floor to one wei, mint.
This contract removes that, because the economics are enforced by code rather than by a signature. It mints only what it has just been paid for, measured. The signer cannot mint without paying, whatever they sign.
The units. Read this before you integrate
unitPrice is the price of ONE WHOLE asset token, denominated in the SETTLEMENT token's smallest
unit. Nothing else. Not a ratio, not a fixed-point number with a scale of its own, not a price per
smallest asset unit.
Worked through with a 6-decimal settlement currency paying for an 18-decimal asset:
| Quantity | Value | Meaning |
|---|---|---|
unitPrice | 12_500_000 | 12.50 of the currency buys 1.000000000000000000 of the asset |
settlementIn | 125_000_000 | the buyer pays 125.00 |
ASSET_UNIT | 10 ** 18 | one whole asset token, in the asset's own units |
assetOut | 125e6 * 1e18 / 12.5e6 = 10e18 | the buyer receives 10 whole asset tokens |
So, in both directions:
assetOut = floor(settlementIn * ASSET_UNIT / unitPrice)
settlementIn = ceil (assetOut * unitPrice / ASSET_UNIT)Both run through 512-bit intermediate arithmetic, so the 1e6 * 1e18 in the middle of a realistic
quote is exact rather than merely not-yet-overflowing.
This is the field a decimals bug lives in
A price expressed per smallest asset unit instead of per whole asset token is wrong by a factor of
10 ** assetDecimals, which for an 18-decimal asset is a factor of a quintillion. The router's
per-currency cap and this contract's per-purchase cap both exist to turn exactly that mistake into a
revert rather than into a fill. Do not compute the quote yourself.
Ask the contract, do not reimplement the arithmetic
function quoteAssetOut(uint256 settlementIn)
external view returns (uint256 assetOut, uint256 settlementCharged);
function quoteSettlementIn(uint256 assetOut)
external view returns (uint256);quoteAssetOut is what an off-chain quote should call to fill in the intent's venueQuoteIn and
minAssetOut. Two implementations of the same rounding is how a one-wei disagreement ships, and here
it would make every purchase whose price does not divide exactly revert at the delivery floor. Both
reverts of purchase that a caller can trigger by arithmetic (ZeroAmount, NothingToMint) are
raised by quoteAssetOut too, so an answer you get back is an answer you can act on.
quoteSettlementIn is the inverse, for the "I want N units" side of a front end. Note that feeding
its answer back into quoteAssetOut can return slightly more than you asked for, because both
directions round in the offering's favour. That is correct, and it is why purchase prices the
quantity it derived rather than trusting a quantity it was handed.
Why the price bounds are per offering rather than fixed
The price is a settlement-currency amount, so it is written the way every other settlement-currency
amount is written. The bounds MIN_UNIT_PRICE and MAX_UNIT_PRICE are therefore in
settlement-currency units too, they are immutable per deployment, and they are supplied at
deployment rather than hardcoded.
The reason is the same decimals problem. "One cent" and "ten thousand" are decimal numbers a human
means; their raw values depend on a decimals count the contract only learns at deployment. A constant
would silently mean a different price on a currency with different decimals, which is the exact class
of bug this section exists to prevent. With a 6-decimal currency, a floor of 10_000 reads as one
cent and a ceiling of 10_000_000_000 reads as ten thousand.
A zero floor is rejected at deployment: a price of zero divides into any payment an unbounded number of times, and the bounds are the only thing between a fat-fingered repricing and an offering given away.
Rounding, and who it favours
assetOut floors, so the buyer never receives more asset than they paid for. The residue is then
not kept either: the venue charges the exact ceiling cost of the quantity it is actually about to
mint, which is provably at most the payment offered, and simply does not take the difference.
How large that residue can be depends on the two decimal counts.
Whenever unitPrice < ASSET_UNIT, which is the ordinary case of a low-decimal currency buying a
high-decimal asset, the ceiling cost of the floored quantity lands back exactly on the payment offered.
The charge equals the offer and the router's refund path never fires.
Reverse the decimals and the residue becomes real, bounded by one smallest settlement unit. The router refunds it to the buyer as it would for any venue that rounds a fill down.
The price is enforced by the contract, not by the signed floor
This is the property the whole arrangement rests on, and it is worth stating on its own.
The venue never consults minAssetOut when deciding what to mint. It consults its own on-chain
price. The floor is only ever a floor: a check that the price has not moved against the buyer
between the quote and execution.
The consequence is that a settlement authorised with a vacuous delivery floor still fills at the real price. Give the venue a full-sized quote and a floor of one wei, and the buyer receives the full priced quantity anyway, and pays for it in full. There is no field of the settlement intent that turns a payment into less asset than the price says it buys.
The mirror half holds as well: a settlement that pays almost nothing mints almost nothing. The smallest settlement the router's gates will carry is one raw unit of the currency, and it buys exactly one raw unit's worth.
Two independent floor checks, and they mean different things
The venue asserts the floor against the quantity it derived from its own price, and names itself when it fails. The router then asserts the same floor against the buyer's measured balance delta. The second is what catches calldata that minted to somebody other than the buyer named in the signed intent: the venue accepts it, and the router refuses the whole transaction.
The purchase path
function purchase(address buyer, uint256 settlementIn, uint256 minAssetOut)
external returns (uint256 assetMinted, uint256 settlementCharged);settlementIn, what the caller authorised, before
any external call.settlementIn, guarded anyway.The pull comes before the mint, so no asset can exist against a payment that has not landed. The reverse order would be a mint on credit for the duration of one external call, which is exactly the property this contract exists to deny.
Two more things worth knowing:
- The venue does not check who
buyeris, and must not. The router asserts the measured delta on the buyer named in four signatures, so calldata that minted elsewhere fails there. Re-deriving the buyer here would mean this contract having an opinion about a router payload it cannot see. - A fee-on-transfer or deflationary settlement currency cannot be sold in at all. Minting the full quantity against a short payment would put the shortfall in the issuer's proceeds, silently, every purchase. The listing decision is taken at the line that moves the money.
Configuration, roles and levers
Everything that cannot change is fixed at deployment: the router address, both tokens, both decimals counts and the price bounds. Everything that can moves under a role.
| Role | May |
|---|---|
DEFAULT_ADMIN_ROLE | administer roles, set the per-purchase cap, and unpause. A multisig in production. |
RATE_SETTER_ROLE | move unitPrice within the immutable bounds, and nothing else. Held by the officers who price the offering, deliberately not the admin key, because repricing is routine. |
PAUSER_ROLE | stop purchases. It cannot restart them: pausing is a safety action whose worst outcome is a stopped sale, restarting is a claim that the sale is safe again. |
TREASURY_ROLE | withdraw the proceeds and rescue a stray token. Held by the issuer. |
| Property | Behaviour |
|---|---|
| Router address | immutable. The router is the one address whose call causes a mint, so a settable one would be a single admin transaction away from an arbitrary mint. Following a redeployed router means redeploying the venue. |
| Upgradeability | none, and not a proxy. The terms of an offering should be the terms it was sold under. The escape hatch is to pause and deploy the next one. |
| Per-purchase cap | set in whole settlement tokens, stored raw. Zero means the venue cannot sell, not "unlimited": a venue deployed with a zero cap is deployed closed. It bounds bugs, not attackers, and is sized like the router's cap. |
| Repricing | allowed while paused, so a stopped offering can be made ready to restart. It is not retroactive and is not coordinated with intents in flight: an intent signed at the old price and executed after a repricing reverts at the delivery floor if the new price is worse for the buyer, and simply fills better if it is not. |
| Proceeds | accumulate in the venue; they are never forwarded during a purchase. A treasury address with a reverting fallback would otherwise break every buyer's purchase, inside the router's measured-delta window, with nothing the buyer could do about it. |
| Withdrawal | withdraw(to, amount), destination chosen per call rather than stored, and it works while paused, because a stopped offering is exactly when an issuer needs to reach the money. |
| Rescue | rescue(token, to, amount) sweeps anything that is not the settlement currency, and refuses the settlement currency by name so an issuer's accounting reads one event for one meaning. |
| Native currency | never accepted. No receive, no fallback, no payable function. |
| Rounds | a second round is a second deployment, not a state machine: a new price, a new cap, a new grant of the minting right, and this one paused. Each round's terms stay permanently readable at its own address. |
A freshly deployed venue cannot sell yet
Two things still have to happen, both on other people's contracts. The issuer must grant the venue the minting right on the asset token (the router never holds it, and neither does Assetera), and the router's admin must open a settlement cap for the currency. Neither can be done from the venue, and a purchase attempted before them reverts rather than half-settling.
The venue also holds no compliance logic, no fee logic and no attestation of its own. All of that belongs to the router, which has already verified four signatures, screened the buyer and taken the fee out of the buyer's debit by the time the venue is called. A second copy of those rules would drift from the first.
Events
| Event | Meaning |
|---|---|
IssuanceMinted | one purchase: buyer and assetToken indexed, then assetMinted, settlementToken, settlementIn, and the unitPrice in force when it executed. |
UnitPriceSet | the offering was repriced, with the previous and the new price. |
PurchaseCapSet | the per-purchase cap changed, in both forms, with the decimals they were converted against. |
ProceedsWithdrawn | proceeds left the venue. |
TokensRescued | a token that is not the settlement currency was swept out. |
IssuanceMinted and the router's PrimarySettled are emitted from the same call, from the two
different parties, and join on the transaction hash. They report the same money from two sides, so a
discrepancy between them is a reconciliation alarm rather than a normal state.
One subtlety worth reading if you build a cap table:
assetMinted is the quantity issued; assetDelivered is the quantity measured
IssuanceMinted.assetMinted is what the venue created, because the venue is the issuer of what it
reports. PrimarySettled.assetDelivered is the router's measured balance delta on the buyer. They
are asserted compatible in the same transaction, and they can differ only through something that is
not this sale, such as an upward rebase of a position the buyer already held. Use the issuance
number for issuance records and the measured number for balances.
Errors
| Error | What went wrong |
|---|---|
CallerNotRouter(caller) | somebody other than the configured router called purchase. There is exactly one legitimate caller and it is fixed at deployment. |
PurchaseCapExceeded(settlementIn, cap) | over the per-purchase cap, or the venue was never opened (cap reads zero). |
NothingToMint(settlementIn, unitPrice) | the payment is too small to buy a single unit at the current price. A revert, never a silent zero fill. |
InsufficientAssetOut(assetOut, minAssetOut) | the price moved between the quote and execution, so the buyer would get less than they agreed to. |
UnitPriceOutOfBounds(unitPrice, min, max) | a repricing outside the bounds fixed at deployment. |
SettlementPullMismatch(requested, received) | the currency did not move exactly what it was asked to move. |
AssetDeliveryShortfall(delivered, expected) | the mint did not put the quoted quantity in the buyer's hands: a mint that no-ops, one that returns false instead of reverting, a transfer fee, or a downward rebase inside the call. |
ChargeExceedsAuthorised(charged, authorised) | unreachable by construction, and guarded anyway: it would mean the venue spending more of the router's allowance than the buyer signed for. |
RescueOfSettlementToken | rescue was pointed at the settlement currency. Proceeds leave through withdraw. |
PriceBoundsInvalid, SameToken, TokenDecimalsImplausible, ZeroAddress, ZeroAmount | deployment-time and argument validation. |
Deliberately not built
| Not built | Instead |
|---|---|
| A subscription-style offering (commitments during a window, a minimum raise, an allocation at a deadline) | that separates the buyer's money from the buyer's units in time, which forces an escrow, a refund path, a privileged allocation action, and an answer to a compliance status that changes mid-window. It is a different contract with a different invariant, and it should be written as one. |
| Round management | deploy a second venue and pause this one. |
| A lifetime issuance cap | an offering size is a property of the offering, not of one of its sale contracts. The place to enforce it without arithmetic drift is the asset token's own supply, which the issuer controls. |
Next
Extend the issuance venue
Inherit this contract in your own repository and override the one function that touches your token.
Settlement router
AsseteraPrimarySales: what is signed and by whom, the constrained executor, and the per-currency cap.
Primary issuance
What primary issuance is, and what an integrator actually has to build.
Source on GitHub
The public contracts repository: Solidity source, Foundry tests and audit scope.
Settlement router
AsseteraPrimarySales in detail: the settlement intent, four signatures over three nonce namespaces, and a constrained executor judged on measured balance deltas.
Extend the issuance venue
Build your own sale contract on top of AsseteraIssuanceVenue: install it with Foundry, override one function, and issue through your token's own call.