For the complete documentation index, see llms.txt. This page is also available as Markdown.

Building on Bloom

This guide answers one question: how does a builder do this themselves? Take a Solidity real-world-asset contract, stand it up on a Rome chain as a permissioned token, open it to Solana wallets as well as EVM wallets with no bridge and no second token, and plug in your own KYC/compliance decision.

The audience is a developer evaluating or adopting the approach. Bloom is the worked example throughout — its live asset ARCV ("Mineral Vault I") on Hadrian (200010) is a real instance of every step below, and its deploy receipt (deployments/200010.tokens/ARCV.json) records the exact three contracts and eight transactions this method produces.

Nothing here modifies the asset contracts. Bloom runs Plume's Arc framework unmodified (the vendored tree in contracts/, byte-identical to plumenetwork/contracts at the pin in NOTICE); the only Rome-side contract is ArcTokenFactoryV2, which adds one function. The work is in how you deploy and how you drive the contracts — not in changing them.

The method, at a glance. A permissioned token is three deployed contracts. It is deployed piecewise — one contract per transaction — because a one-transaction factory call exceeds Rome's per-transaction account cap. Its proxies must come from provenance-locked artifacts or on-chain registration fails closed. Compliance is one boolean per address, written by whoever your KYC process authorises. And the same deployment is reachable from a Solana wallet through a synthetic sender derived on-chain — no bridge, no wrapped token, no second allowlist.

Prerequisites

All the reference tooling lives in scripts/ (run npm install there first). Every command resolves chain facts from the registry rather than hardcoding them, so retargeting another Rome chain is an environment change, not a code change:

export PRIVATE_KEY=# funded deployer/issuer key on the target chain
export CHAIN_ID=200010     # picks the chain
export REGISTRY_ROOT=# a checkout of the Rome chain registry
# Solana-lane flows also need:
export SOLANA_KEYPAIR=# path to a funded Solana keypair JSON (the fee payer)

Gas on Rome chains is USDC-denominated; the full deploy sequence costs on the order of 10–15 native units on devnet. Keys come from the environment only — never the repo. Build the contracts once before deploying, because artifacts must come from rome-contracts/out (see step 3):

cd contracts       && forge build --via-ir     # the vendored Arc suite
cd rome-contracts  && forge build --via-ir     # ArcTokenFactoryV2 + the canonical proxy

Step 1 — Know what a permissioned token is here

A permissioned Bloom token is not one contract. It is three deployed contracts, plus four shared contracts that ops deploys once per chain and every issuer reuses.

The three per-token contracts — one asset owns exactly these:

Contract
Source
What it is

ArcTokenProxy

contracts/src/proxy/ArcTokenProxy.sol

The token itself — a UUPS proxy pointing at the shared ArcToken implementation. Balances, holders, and roles live here.

WhitelistRestrictions

contracts/src/restrictions/WhitelistRestrictions.sol

The allowlist module. One boolean per address; enforced on every transfer while the asset is gated.

YieldBlacklistRestrictions

contracts/src/restrictions/YieldBlacklistRestrictions.sol

The yield module. Excludes addresses from yield distributions without touching their holding.

The four shared, one-per-chain contracts (deployed by deploy-infra.ts):

Contract
Role

RestrictionsRouter

Module-type registry — chain wiring, set once by ops.

ArcTokenFactoryV2

Registration + the canonical proxy codehash (steps 2–3).

ArcToken (implementation)

The shared, reusable asset logic every token proxies to.

ArcTokenPurchase

One storefront, shared by every token and issuer (step 4).

wUSDC

The cash leg — yield currency and sale-purchase currency.

Transfer restriction and yield restriction are deliberately separate modules: a holder can be barred from income while keeping the asset, which is how sanctions and court orders actually work.

This "three contracts" fact is load-bearing for verification. verify-deployments.ts and register-asset.ts both expect a token to resolve to three deployed contracts — verifying a token by hand once missed seven of them across a chain (every yield module and three whitelist modules).

The lifecycle has three phases and a one-way door:

Gating is one-way for supply because mint and burn pass address(0) as the counterparty, and address(0) can never be allowlisted — so once gated, no new supply can ever be minted. That is the guarantee a permissioned asset makes to its holders.


Step 2 — Deploy it piecewise, never as one transaction

Arc ships a one-shot createToken that deploys the proxy and both modules and wires them in a single call. On Rome you cannot use it. That transaction needs 77 Solana account locks, and Rome's per-transaction cap is 62 locks. It can never fit — not with tuning, not with an optimizer pass.

So the deploy is piecewise: each contract in its own transaction, and the token gains factory-equivalent status through ArcTokenFactoryV2.registerToken instead of the monolithic createToken. This is the create wizard, and it is what the issuer console does step by step:

the create wizard

The reference implementation is arc/plan/create.ts (createSequence()), which the app renders as six cards (bloom/lib/createCards.ts) over eight transactions, in the exact order the contracts force:

#
Transaction
Contract call

1

Deploy the asset

new ArcTokenProxy(impl, initData) where initData = ArcToken.initialize(...)

2

Deploy the allowlist module

new WhitelistRestrictions()

3

Set you as its administrator

whitelist.initialize(issuer)

4

Enforce it on transfers

token.setRestrictionModule(TRANSFER, whitelist)

5

Deploy the yield module

new YieldBlacklistRestrictions()

6

Set you as its administrator

yieldBlacklist.initialize(issuer)

7

Enforce it on yield

token.setRestrictionModule(YIELD, yieldBlacklist)

8

Register with the factory

factoryV2.registerToken(token, impl)

The script form is one command:

The issuer (PRIVATE_KEY) signs every transaction and ends up holding the token's roles (ArcToken.initialize grants to msg.sender — see step 7). Registration (tx 8) is what unlocks the storefront's enableToken and any factory-mediated upgrade.

Do not "optimize" this back into a monolith. Batching these calls into one transaction to save round-trips reintroduces exactly the 62-lock overflow the piecewise path exists to avoid. The same cap is why yield distribution walks one holder per transaction (step 8). If a call fails with Too many accounts: N > 62, one transaction is touching too many accounts — split it, don't tune it.


Step 3 — Deploy proxies only from the provenance-locked artifacts

registerToken is the security boundary that lets piecewise deploys be safe. It will only accept a token whose runtime codehash matches the canonical ArcTokenProxy baked into the factory at deploy time:

Because that codehash is an immutable, a proxy built from any other artifact fails closed with ProxyCodehashUnknown. Two rules follow, and both are enforced by CI and the hard rules in CLAUDE.md:

  1. Token proxies MUST be deployed from rome-contracts/out — never from contracts/out, never from a different build. That is the compilation the factory's canonical codehash pins.

  2. bytecode_hash = "none" and cbor_metadata = false in rome-contracts/foundry.toml are load-bearing. With metadata enabled, the ArcTokenProxy runtime embedded in the factory (type().runtimeCode) carries a different CBOR/IPFS tail than the standalone out/ artifact the wizard deploys from — so on-chain registerToken reverts while every in-source Foundry test passes (Foundry embeds both copies, so it can't see the mismatch). rome-contracts/test/ArtifactProvenance.t.sol pins this property; the funded run is what originally caught it.

A green in-source suite does not prove provenance. Before shipping an image, and after ANY compiler-setting change, prove the property against the deployed factory (read-only, no keys):

It hashes your local ArcTokenProxy artifact and asserts that hash appears verbatim inside the deployed factory's bytecode.

If compiler settings must change, rotate the factory — deploy a fresh one whose immutable pins the new codehash, and re-wire — rather than hand-patching the check:

Rotation retains the old address in the receipt; tokens registered against the old factory must re-register against the new one.


Step 4 — Open a sale through the shared storefront

Selling is a separate act from creating, and it runs on the one shared ArcTokenPurchase storefront. Three things must be true before a sale opens, and ArcTokenPurchase.enableToken checks each on-chain:

There is a fourth, implicit requirement that a gated asset forces: because every transfer runs the allowlist gate, moving inventory to the storefront only succeeds if the storefront address is itself whitelisted on your token. So the full open-a-sale sequence is:

  1. Whitelist the storefront on your token's whitelist module (batchAddToWhitelist([storefront]) — see step 5). Without this, the inventory transfer in (2) reverts TransferRestricted() once gated.

  2. Transfer inventory to the storefront address (from the infra receipt's arcTokenPurchase).

  3. enableToken(token, amount, price) — buyers now pay wUSDC and receive the RWA.

The withdrawal split is worth knowing on a shared storefront, and it is not symmetric (arc/roles.ts is the authority):

Action
Authority
Held by

enableToken / disableToken / withdrawUnsoldArcTokens

the token's ADMIN_ROLE (onlyTokenAdmin)

you, the issuer

withdrawPurchaseTokens (the wUSDC proceeds)

the storefront's DEFAULT_ADMIN_ROLE

whoever deployed the storefront

On a self-hosted chain you are both. On a shared storefront the proceeds pool is every issuer's takings, so its withdrawal is platform-level — design your settlement around that rather than assuming unilateral control.


Step 5 — Plug in your own KYC / compliance

This is the section a customer reads before saying yes, so it is code, not prose. Compliance is enforced by the token contract itself — not by an off-chain screen, a sequencer filter, or a venue policy. The rules travel with the token to any execution path, on both wallet lanes. What the chain records is the outcome of your KYC decision, as a single boolean:

The interface your process writes to

Your KYC vendor's approve/deny decision becomes exactly one of these calls:

Who may write

The authority an approval needs is MANAGER_ROLE on the module — not ADMIN_ROLE on the token. addToWhitelist / batchAddToWhitelist / removeFromWhitelist are all onlyRole(MANAGER_ROLE), and that role lives on the WhitelistRestrictions instance. Authenticating ADMIN_ROLE on the token and then writing to the module is a real defect this codebase has already paid for. Do not offer WHITELIST_ADMIN_ROLE as the fix for a refusal — it is granted at initialize and checked nowhere in the suite (arc/roles.ts asserts it is the one role that gates nothing).

WhitelistRestrictions.initialize(issuer) (tx 3 of the wizard) grants the issuer DEFAULT_ADMIN_ROLE, ADMIN_ROLE, MANAGER_ROLE, WHITELIST_ADMIN_ROLE, and UPGRADER_ROLE on that module. So the issuer holds MANAGER_ROLE out of the box and can also delegate it: because the issuer holds DEFAULT_ADMIN_ROLE, a grantRole(MANAGER_ROLE, serviceAccount) hands the write to a backend signer.

A decision becoming an on-chain write

Point your vendor's webhook at a signer that holds MANAGER_ROLE, and on an approve, batch the addresses:

Resolve whitelistModule from the token itself (ArcToken.getRestrictionModule(TRANSFER_RESTRICTION_TYPE)), never from a cached projection — aiming a write at the wrong module is how "approve on any asset" once wrote to one token's allowlist.

The wallet-signed variant (self-serve demo)

For a permissionless test deployment where visitors admit themselves, grant MANAGER_ROLE to a TestApprover (rome-contracts/src/TestApprover.sol) whose module is immutable. Then approve(address) is callable by anyone, from their own wallet, paying their own gas — it calls addToWhitelist only if the address is not already listed:

This is the "sign-in gate" — it shows a visitor the real mechanism (an approval becoming an on-chain allowlist entry the token then enforces) without custodying anything. Never grant MANAGER_ROLE to a TestApprover on a production token — it makes that token's allowlist permissionless.

When the gate bites, and swapping providers

The gate only restricts once the asset is gated (setTransfersAllowed(false), ADMIN_ROLE on the module). While ungated, transfers are open — build your allowlist first, then gate. Once gated, a transfer where either side is off the list reverts with the typed error TransferRestricted() (0xe827105e).

Swapping KYC providers later requires no on-chain change. The allowlist module, its interface, and the boolean it stores are provider-agnostic. To switch vendors, point a different decision source at the same MANAGER_ROLE signer — no redeploy, no migration, no state change. The chain never knew which vendor made the call, only the outcome.


Step 6 — Open the same asset to Solana wallets

The same three contracts are reachable from a Solana wallet with no bridge and no second token. A Solana-native user has no EVM key anywhere; instead their EVM identity is synthetic, derived on-chain from the transaction's actual Solana signer:

The Rome EVM program derives this from the signer itself (do_tx_unsigned::derive_sender), so it cannot be spoofed and no secp256k1 private key for it exists — the Solana signature is the only thing that can ever drive this address. The reference derivation is arc/materialise/solana/identity.ts (syntheticAddress), byte-compatible with the on-chain rule:

What a builder does to support the lane — note that none of it is a contract change:

  1. Whitelist the Solana user by their synthetic address. To the compliance layer a synthetic address is an ordinary address: paste the user's Solana pubkey, derive the synthetic, and batchAddToWhitelist([synthetic]). One allowlist spans both wallet worlds.

  2. Provision the signing PDA once, before their first transaction. The external-authority PDA that authorises a synthetic's signature must exist first, and creating it is an EVM-lane call (create_pda) — a Solana-only user cannot do it for themselves, so the issuer provisions it. Provisioning is external_auth, not lazy.

  3. Submit through the lane library, not by hand. submitDoTxUnsigned (arc/materialise/solana/submit.ts) builds a DoTxUnsigned instruction — an unsigned EIP-1559 payload authorised by the Solana signature — and handles the four things a hand-built transaction gets wrong:

    • Account discovery via rome_emulateCallAccounts, which decides each account's writability. The call's value must be forwarded to discovery, or a value transfer marks the recipient's balance PDA read-only and dies on-chain.

    • Compute budget — a DoTxUnsigned needs a 250 KB heap frame and a 1.35 M CU limit (Rome's EVM overflows the 32 KB / 200 K defaults); leaving ~50 K headroom under Solana's 1.4 M ceiling.

    • The treasure (fee) wallet PDA, appended writable — discovery omits it.

    • The v0 + lookup-table fallback. When a call's accounts overflow the 1232-byte legacy transaction envelope, the client resubmits as a v0 transaction over a fresh address lookup table. (Measured today a storefront buy fits in the legacy envelope at ~1,061 of 1232 bytes; a first-time holder whose associated token account is still to be created can cross it.)

The user is the fee payer and pays lamports (SOL) from their Solana wallet; the synthetic they control holds the assets and never holds SOL. After each lane send, poll the EVM nonce before the next one — the indexer lags Solana confirmation slightly.

The lane is ATOMIC-ONLY. A DoTxUnsigned is one EVM transaction executed inside one Solana transaction by Rome's atomic VM. The iterative VM (one EVM execution staged across several Solana transactions) is not reachable through this lane yet. A call that will not fit atomically cannot take this lane at all — the fix is a smaller call, not a fallback. Note the v0+ALT fallback is a bigger envelope, not a different execution; it is still one atomic transaction.

Why the RWA can't leak out of the perimeter. The asset is EVM-state-resident — its balances are storage inside the Rome EVM program's accounts. There is no SPL mint of it and nothing Solana-native to move; the lane's asset-moving primitives operate on SPL token accounts, which an EVM storage balance does not have. Every movement the Solana signature can express is a call into the token contract, which runs the allowlist gate — the funded suite asserts a transfer to a non-whitelisted address reverts identically whether signed by an EVM key or a Solana key. The cash leg (wUSDC) is the deliberate exception: it is SPL-backed and does sweep out to the user's own token account.


Step 7 — Know who holds authority after deploy

ArcToken.initialize runs from the proxy constructor (tx 1) and grants roles to msg.sender — the wizard driver, i.e. the issuer. It grants five roles explicitly:

Two consequences a builder must not miss:

  • MINTER_ROLE, BURNER_ROLE, and UPGRADER_ROLE are NOT granted at initialize. The initial supply is minted inside initialize (an internal _mint, no role needed), but a later mint or burn requires the issuer to first grant themselves the role. They can, because they hold DEFAULT_ADMIN_ROLE (the admin of every role — nothing calls _setRoleAdmin, so DEFAULT_ADMIN_ROLE administers all of them). But it is a deliberate extra step, not an ambient power.

  • UPGRADER_ROLE is never granted to the factory. Factory-mediated upgrades (ArcTokenFactoryV2.upgradeToken) require the factory itself to hold UPGRADER_ROLE on the token — granting it to yourself does nothing. That grant is a one-way door the factory cannot reverse, so it is opt-in: if you want factory-mediated upgrades, grantRole(UPGRADER_ROLE, factory) explicitly; otherwise the issuer upgrades the token directly.

The full 21-authority model (11 role names across six contracts — the same name is a different authority on each contract) is codified in arc/roles.ts. Ask rolesRequiredFor(contract, functionName) rather than reading a role's own contract, because four storefront functions and both factory functions are gated by an authority that lives on a different contract.


Step 8 — Distribute yield

The token pays yield in wUSDC (fixed at initialize, changeable via setYieldToken under YIELD_MANAGER_ROLE). Distribution walks the holder set:

The mechanics that matter for driving it correctly:

  • The full totalAmount is pulled from the caller only on the startIndex == 0 window (safeTransferFrom(msg.sender, this, totalAmount)). Approve that total to the token before the first window; later windows pay out of the balance already held.

  • nextIndex wraps to 0 when the walk completes. Loop distributeYieldWithLimit(total, nextIndex, …) until nextIndex comes back 0.

  • Yield-restricted holders' shares stay in the token contract — they are skipped, not redistributed.

On Rome the practical ceiling is maxHolders = 1 per transaction — the same 62-account cap from step 2 — so yield is walked one holder per transaction (measured ~318 K gas per holder). Every whitelisted holder, EVM or Solana-native, receives pro-rata; the reference script is measure-yield-batch.ts and the funded smoke's yield step asserts it end-to-end.


Step 9 — Verify what you deployed

Nothing auto-submits sources anywhere in Rome, so verification is a step you run. After any deploy — infrastructure or a new token — run the read-only, key-less, idempotent verifier (already-verified contracts are skipped, so re-running after issuing a token verifies just the new ones):

It expects the three contracts per token (proxy + both modules) plus the infrastructure, and reports what it could not verify rather than failing a deploy. Doing this by hand once missed seven contracts.

Registering the asset into the Rome registry treats verification as a gate, not decoration (register-asset.ts): a token earns its apps/arc/<chain>.json record — carrying standard: arc-permissioned-erc20 and a transferRestriction naming its allowlist gate — only when all three of its contracts are verified on Sourcify. That record reads the chain for its facts (a wizard-deployed token has no committed receipt) and writes only a local registry checkout; landing the PR is a person's job.

For an end-to-end proof of the whole loop — create, whitelist, gate, sell on both lanes, distribute yield — run the funded smoke against any chain:


Step 10 — Know what stays off-chain

The trust model is deliberately honest, and a builder should represent it the same way to their own users:

  • The allowlist flag is your off-chain decision. KYC/AML happens in your process — your vendors, your rules. The chain records and enforces only the outcome, isWhitelisted(addr).

  • The only on-chain state is one boolean per address. No PII, no documents, no identity claims, no on-chain attestations, no cryptographic proof of who an address belongs to. This is Arc's model as used on Plume; it trades the heavier transfers of an on-chain-identity standard (e.g. ERC-3643) for lighter mechanics and issuer-held trust.

  • Recovery is an issuer power, not an escape hatch. A lost wallet — EVM or Solana — is recovered by whitelisting a replacement address and, if needed, using mint/burn/upgrade under your own legal process (Arc has no built-in forced-transfer primitive). The issuer's key custody is therefore part of the compliance posture.

  • No relayer, no custody surface. Users hold their own gas on both lanes. A fee-payer service would mean holding keys — a surface an RWA product should not add.


Reference: the tooling

Every script runs from scripts/ after npm install, with PRIVATE_KEY, CHAIN_ID, and REGISTRY_ROOT exported.

Script
What it does

deploy-infra.ts

One-time per chain: the four shared contracts, wired for wUSDC.

create-token.ts

The piecewise wizard — eight transactions, one token.

rotate-factory.ts

Deploy a fresh factory after a compiler/artifact change; re-wire.

verify-artifact-provenance.ts

Prove your proxy artifact is accepted by the deployed factory (read-only).

verify-deployments.ts

Verify all three contracts per token + infra on Sourcify (read-only).

register-asset.ts

Verify-gate, then prepare the registry compliance record.

measure-yield-batch.ts

Re-measure the per-tx yield ceiling on a chain.

smoke.ts

The funded end-to-end suite across both lanes.

sweep-synthetic-native.ts

Reclaim native gas from a synthetic account (test hygiene).

Deeper background lives alongside this guide: docs/ARCHITECTURE.md (the layered app model), docs/LANES.md (the two lanes and custody), docs/COMPLIANCE.md (the trust model), and docs/USAGE.md (the operator runbook).

Last updated

Was this helpful?