Assetera Docs
Smart contracts

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:

QuantityValueMeaning
unitPrice12_500_00012.50 of the currency buys 1.000000000000000000 of the asset
settlementIn125_000_000the buyer pays 125.00
ASSET_UNIT10 ** 18one whole asset token, in the asset's own units
assetOut125e6 * 1e18 / 12.5e6 = 10e18the 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);
The caller is the router, and the sale is not paused. The caller check runs first, so a stranger calling a paused venue is told the durable fact rather than the transient one.
The per-purchase cap is charged against settlementIn, what the caller authorised, before any external call.
Quote: floor the quantity, refuse a quote that rounds to nothing, hold it to the caller's floor.
Price it: the exact ceiling cost of the quantity about to be minted, which is provably at most settlementIn, guarded anyway.
Pull it, and measure what arrived. Anything other than the exact amount reverts.
Mint, and measure what the buyer received. A short delivery reverts.
Emit, from the measurements rather than from the quotes.

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 buyer is, 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.

RoleMay
DEFAULT_ADMIN_ROLEadminister roles, set the per-purchase cap, and unpause. A multisig in production.
RATE_SETTER_ROLEmove 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_ROLEstop 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_ROLEwithdraw the proceeds and rescue a stray token. Held by the issuer.
PropertyBehaviour
Router addressimmutable. 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.
Upgradeabilitynone, 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 capset 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.
Repricingallowed 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.
Proceedsaccumulate 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.
Withdrawalwithdraw(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.
Rescuerescue(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 currencynever accepted. No receive, no fallback, no payable function.
Roundsa 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

EventMeaning
IssuanceMintedone purchase: buyer and assetToken indexed, then assetMinted, settlementToken, settlementIn, and the unitPrice in force when it executed.
UnitPriceSetthe offering was repriced, with the previous and the new price.
PurchaseCapSetthe per-purchase cap changed, in both forms, with the decimals they were converted against.
ProceedsWithdrawnproceeds left the venue.
TokensRescueda 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

ErrorWhat 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.
RescueOfSettlementTokenrescue was pointed at the settlement currency. Proceeds leave through withdraw.
PriceBoundsInvalid, SameToken, TokenDecimalsImplausible, ZeroAddress, ZeroAmountdeployment-time and argument validation.

Deliberately not built

Not builtInstead
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 managementdeploy a second venue and pause this one.
A lifetime issuance capan 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

On this page