Assetera Docs
Smart contracts

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 assertsWhy it matters to you
The buyer's measured asset balance grew by at least the signed floorIssuing to any address other than the buyer argument fails the whole transaction
Your venue consumed no more currency than it was approvedYou cannot spend more of the buyer's money than the quote
Anything you left unspent went back to the buyerRounding a fill down is fine and costs the buyer nothing
The router's own balances returned to where they started, on both tokensNever 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
foundry.toml
[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.

  • solc must be 0.8.28 or later. Every Assetera source file pins pragma 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.1 is 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:

src/CustomerIssuanceVenue.sol
// 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

RequirementWhat happens otherwise
subscribeFor credits the buyer in the same callA 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 buyerThe measured delta comes up short and the purchase reverts
Your venue holds both rights on the tokenWhitelisting and issuing are two grants, not one. Neither is checkable at deployment
Whitelisting is idempotent, or you guard it as aboveA 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.

You grant the venue its rights on your token: whichever role permits whitelist, and whichever permits subscribeFor. Assetera never holds either.
Assetera opens a settlement cap for your currency on the router. Until then the router refuses every settlement in it, which is the fail-closed default rather than a fault.
Assetera points the offering at your venue address. The venue is catalog data, so it reaches the buyer's transaction through the signed settlement bundle rather than through a registry.

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.

FunctionWhat 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_PRICEThe live price and the bounds it may move within
maxSettlementPerPurchaseThe per-purchase ceiling, so an oversized order is refused before the buyer signs
pausedYour one-way stop. It halts quoting as well as settlement
ROUTER, SETTLEMENT_TOKEN, ASSET_TOKENCross-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 -vvv

The 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.

ErrorWhat 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
VenueCallFailedRaised by the router. Your venue reverted, and the reason was not forwarded

Next

On this page