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.
If your token does not mint through a plain mint(address,uint256), you do not need a new sale
contract. You need one overridden function.
AsseteraIssuanceVenue is the contract Assetera deploys for its own primary offerings. It is MIT
licensed and public, so you can inherit from it, change the single line that touches your token, and
keep every economic guarantee the base contract makes. This page takes you from an empty directory to
a deployed CustomerIssuanceVenue.
The worked example is the common one: a token that requires the investor to be whitelisted before a privileged issuance call may credit them.
A venue is any address the router can call
Primary settlement runs through AsseteraPrimarySales, the Assetera router. The contract it
settles against is called the venue, and the router imposes no interface on it whatsoever. It
approves the venue an exact amount of settlement currency, calls it with calldata whose selector and
hash were signed in advance, and then judges the whole settlement on balances it measured itself.
That is the trust boundary. Everything to the left of the router is Assetera's. Everything from the venue rightwards is yours, and the router does not read your storage, does not call your token and does not believe anything your contract returns.
What it does instead, after your venue has run:
| The router asserts | Why it matters to you |
|---|---|
| The buyer's measured asset balance grew by at least the signed floor | Issuing to any address other than the buyer argument fails the whole transaction |
| Your venue consumed no more currency than it was approved | You cannot spend more of the buyer's money than the quote |
| Anything you left unspent went back to the buyer | Rounding a fill down is fine and costs the buyer nothing |
| The router's own balances returned to where they started, on both tokens | Never send anything back to the router |
This is why inheriting is the cheap option
Because the router checks outcomes rather than interfaces, a venue written from scratch is allowed.
It is also a contract that has to get flooring, ceiling, measured transfers, a fail-closed cap and
four roles right on its own. AsseteraIssuanceVenue already has those, with a Foundry suite behind
them, so inheriting is a much shorter path to the same result.
Set up the project
The contracts live in a public repository. The Foundry project is the contracts/ subdirectory, which
is why the remapping below reaches into it.
forge init customer-issuance-venue
cd customer-issuance-venue
forge install OpenZeppelin/openzeppelin-contracts@v5.1.0
forge install Assetera-AG/AsseteraEvmContracts@evm-contracts-v8.2.1[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.28"
remappings = [
"assetera/=lib/AsseteraEvmContracts/contracts/src/",
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
]Three things about that configuration are worth a sentence each.
solcmust be0.8.28or later. Every Assetera source file pinspragma solidity 0.8.28.- The OpenZeppelin remapping is not optional. The Assetera sources import
@openzeppelin/contracts/..., and the line above points those imports at the copy in your own project. Version 5.1.0 is the version Assetera builds against. - Pin the Assetera tag.
evm-contracts-v8.2.1is the current release. Tracking a branch means your sale contract can change under you between a review and a deployment.
The one function you override
Inside the base contract, the entire assumption about how your token issues units is this:
/// The ONE place this contract assumes anything about how an asset token mints.
function _mintAsset(address to, uint256 amount) internal virtual {
IMintableERC20(address(ASSET_TOKEN)).mint(to, amount);
}ASSET_TOKEN is your token, fixed at deployment. to is the buyer. amount is the quantity the base
contract has already priced, charged for and is about to verify. Nothing else on the purchase path
knows what kind of token it is dealing with.
Override it and your token's own call takes its place:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {AsseteraIssuanceVenue} from "assetera/primary/sale/AsseteraIssuanceVenue.sol";
/// Your token's issuance surface, as your token declares it.
interface ICustomerAssetToken {
function isWhitelisted(address investor) external view returns (bool);
function whitelist(address investor) external;
function subscribeFor(address investor, uint256 amount) external;
}
contract CustomerIssuanceVenue is AsseteraIssuanceVenue {
constructor(SaleConfig memory config) AsseteraIssuanceVenue(config) {}
/// @inheritdoc AsseteraIssuanceVenue
function _mintAsset(address to, uint256 amount) internal override {
ICustomerAssetToken token = ICustomerAssetToken(address(ASSET_TOKEN));
// Idempotent on purpose. A buyer's second purchase must not revert because they are
// already on the register.
if (!token.isWhitelisted(to)) token.whitelist(to);
token.subscribeFor(to, amount);
}
}That is the whole contract. The cap, the price, the rounding, the measured settlement pull, the measured delivery check, the four roles and all five events are inherited untouched.
Do not override anything else
If a second override ever looks necessary, the safety the base contract provides has been weakened
rather than extended. purchase measures your token's balance change across _mintAsset and reverts
when it is short, so an override cannot accidentally skip the delivery check. Overriding purchase
itself removes that guarantee.
Where your override runs
The base purchase function is the only way units are ever created, and only the router may call it.
Your override is step six.
Two properties of that order are load bearing. The currency is pulled before your token is
touched, so units can never exist against a payment that has not landed. And the delivery is
measured, not trusted: the base contract records balanceOf(buyer) before and after your override
and reverts AssetDeliveryShortfall if the buyer received less than was quoted.
What your token has to satisfy
| Requirement | What happens otherwise |
|---|---|
subscribeFor credits the buyer in the same call | A function that queues an allocation for later leaves the measured delta at zero, and every purchase reverts AssetDeliveryShortfall |
The token exposes decimals() | Deployment reverts. The venue reads it once and builds one whole unit from it |
| No transfer fee on the way to the buyer | The measured delta comes up short and the purchase reverts |
| Your venue holds both rights on the token | Whitelisting and issuing are two grants, not one. Neither is checkable at deployment |
| Whitelisting is idempotent, or you guard it as above | A repeat buyer's second purchase reverts |
Confirm the synchronous credit before you write any code
A function named for a subscription is the one case where this design does not fit. If your token records a commitment and issues later, the buyer's money and the buyer's units are separated in time, which needs an escrow and a refund path that this contract deliberately does not have. Ask that question first.
Deploy it
Every field of the deployment configuration is named, because four of them are addresses of the same type sitting next to each other and a transposed pair is invisible in a positional call.
CustomerIssuanceVenue venue = new CustomerIssuanceVenue(
AsseteraIssuanceVenue.SaleConfig({
// Roles. A multisig for the first, and keep the others separate from it.
admin: SAFE, // roles, the purchase cap, and unpause
rateSetter: PRICING_KEY, // may move the price inside the bounds below, nothing else
pauser: OPS_KEY, // may stop the sale, and may not restart it
treasurer: TREASURY, // may withdraw the proceeds
// Immutable wiring.
router: PRIMARY_SALES, // the ONE address allowed to call purchase
settlementToken: USDC, // what the buyer pays in
assetToken: YOUR_TOKEN, // what this venue issues
// Price of ONE WHOLE asset token, in the settlement token's smallest unit.
// With 6-decimal USDC, 12_500_000 means 12.50 USDC buys one whole token.
unitPrice: 12_500_000,
minUnitPrice: 10_000, // 0.01 USDC. Must be above zero
maxUnitPrice: 10_000_000_000, // 10,000 USDC
// Per-purchase ceiling, in WHOLE settlement tokens. Zero deploys the venue CLOSED.
maxSettlementPerPurchaseWholeUnits: 100_000
})
);The price is the field a decimals mistake lives in
unitPrice is the price of one whole asset token, expressed in the settlement token's
smallest unit. It is not a ratio and not a price per smallest asset unit. Getting that wrong for an
18-decimal asset is wrong by a factor of a quintillion. The per-purchase cap and the router's own
currency cap exist to turn that mistake into a revert instead of a fill. The full arithmetic, in both
directions, is on Issuance venue.
A freshly deployed venue cannot sell yet. Three things still have to happen, and none of them can be done from the venue itself.
whitelist, and
whichever permits subscribeFor. Assetera never holds either.Keep the view functions answering
Assetera prices your offering by reading your contract, at the moment a buyer asks for a quote. You inherit all of these from the base contract and they need no work, but an override that changed what any of them reports would put the quote and the fill out of step.
| Function | What Assetera reads it for |
|---|---|
quoteAssetOut(settlementIn) | The quantity a payment buys right now, and its exact cost. This is the number a buyer is shown and signs against |
quoteSettlementIn(assetOut) | The inverse, for a buyer who names a quantity rather than an amount of money |
unitPrice, MIN_UNIT_PRICE, MAX_UNIT_PRICE | The live price and the bounds it may move within |
maxSettlementPerPurchase | The per-purchase ceiling, so an oversized order is refused before the buyer signs |
paused | Your one-way stop. It halts quoting as well as settlement |
ROUTER, SETTLEMENT_TOKEN, ASSET_TOKEN | Cross-checks that the venue on chain is the one the offering describes |
Ask the contract, do not reimplement the rounding
quoteAssetOut floors the quantity and then charges the exact ceiling cost of that quantity, so it
never takes money for a unit it did not issue. Two implementations of that rounding is how a
one-unit disagreement ships, and here it would make every purchase whose price does not divide
exactly revert at the delivery floor.
Test it before you deploy
The base contract ships with a Foundry suite, and the repository already contains a worked subclass
that overrides _mintAsset for a partitioned token. Both are the fastest way to check your own
override against a mock of your token.
forge test --match-contract CustomerIssuanceVenue -vvvThe one test to write first is the delivery assertion, because it is the failure that a mock hides and a real token does not:
function test_PurchaseCreditsTheBuyerSynchronously() public {
uint256 before = token.balanceOf(buyer);
vm.prank(ROUTER);
(uint256 minted,) = venue.purchase(buyer, 125_000_000, 0);
assertEq(token.balanceOf(buyer) - before, minted);
}When something reverts
Your revert reason does not reach the buyer
The router discards the venue's return data and raises a bare VenueCallFailed. A compliance refusal
inside your whitelist call therefore surfaces as an opaque failure. Reproduce it with a direct call
against your venue, where the real error is visible.
| Error | What went wrong |
|---|---|
CallerNotRouter(caller) | Something other than the configured router called purchase. There is one legitimate caller and it is fixed at deployment |
PurchaseCapExceeded(settlementIn, cap) | Over the per-purchase cap, or the venue was deployed with a cap of zero and has never been opened |
AssetDeliveryShortfall(delivered, expected) | Your token did not credit the buyer with the quoted quantity during _mintAsset |
NothingToMint(settlementIn, unitPrice) | The payment is too small to buy a single unit at the current price |
InsufficientAssetOut(assetOut, minAssetOut) | The price moved between the quote and execution, so the buyer would receive less than they agreed to |
SettlementPullMismatch(requested, received) | The settlement currency did not move exactly what it was asked to move. A fee-charging currency cannot be sold in |
VenueCallFailed | Raised by the router. Your venue reverted, and the reason was not forwarded |
Next
Issuance venue
The base contract in full: the price arithmetic in both directions, the roles, the caps and every event.
Settlement router
AsseteraPrimarySales: what is signed and by whom, and how a settlement is judged on measured balances.
Primary issuance
The end-to-end flow a buyer goes through, and what an integrator has to build.
Source on GitHub
The public contracts repository: MIT licensed Solidity, Foundry tests and the audit scope.
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.
Events
The AsseteraECS event catalog: what each event means, its indexed fields, and the meta-transaction actor model.