> For the complete documentation index, see [llms.txt](https://docs.rome.builders/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rome.builders/apps-on-rome/bloom/building.md).

# 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:

```bash
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):

```bash
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:

```
draft     proxy live, no module linked — nothing gates a transfer
  ↓
ungated   module linked, transfersAllowed = true   (WhitelistRestrictions.initialize sets this)
  ↓  mint supply, build the allowlist               [reversible]
gated     setTransfersAllowed(false)                [ONE-WAY DOOR — supply becomes final]
```

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](/files/oSqAlZADmeZJYCYPdN0q)

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:

```bash
# one-time per chain (ops, not per token) — deploys the four shared contracts
npx tsx deploy-infra.ts

# per token — the wizard, replayed as a script
NAME="Mineral Vault I" SYMBOL=ARCV SUPPLY=1000000 DECIMALS=6 npx tsx create-token.ts
```

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:

```solidity
// rome-contracts/src/ArcTokenFactoryV2.sol
bytes32 private immutable CANONICAL_PROXY_CODEHASH =
    keccak256(type(ArcTokenProxy).runtimeCode);

function registerToken(address token, address implementation) external {
    if (token.codehash != CANONICAL_PROXY_CODEHASH) revert ProxyCodehashUnknown();
    // …implementation must be factory-whitelisted, and msg.sender must hold the
    //    token's ADMIN_ROLE, and the token must not already be registered.
}
```

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):
>
> ```bash
> cd scripts && CHAIN_ID=<id> npx tsx verify-artifact-provenance.ts
> ```
>
> 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:

```bash
cd scripts && CHAIN_ID=<id> npx tsx rotate-factory.ts
```

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:

```solidity
// contracts/src/ArcTokenPurchase.sol
function enableToken(address _tokenContract, uint256 _numberOfTokens, uint256 _tokenPrice)
    external onlyTokenAdmin(_tokenContract)                       // (a) you hold the token's ADMIN_ROLE
{
    // (b) the token must be factory-known — this is what registerToken (step 2) bought:
    if (ArcTokenFactory(ps.tokenFactory).getTokenImplementation(_tokenContract) == address(0))
        revert TokenNotCreatedByFactory();
    // (c) the storefront must already hold the sale inventory:
    if (ArcToken(_tokenContract).balanceOf(address(this)) < _numberOfTokens)
        revert ContractMissingRequiredTokens();
    // …price and count must be positive.
}
```

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:

```solidity
// contracts/src/restrictions/WhitelistRestrictions.sol — the on-chain state, in full
struct WhitelistStorage {
    mapping(address => bool) isWhitelisted;   // ← one boolean per address. No PII, ever.
    bool transfersAllowed;                    // false = gated; only whitelisted addresses transfer
    EnumerableSet.AddressSet whitelistedAddresses;
}
```

### The interface your process writes to

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

```solidity
interface IWhitelistRestrictions {
    function addToWhitelist(address account) external;                  // onlyRole(MANAGER_ROLE)
    function batchAddToWhitelist(address[] calldata accounts) external; // onlyRole(MANAGER_ROLE)
    function removeFromWhitelist(address account) external;             // onlyRole(MANAGER_ROLE)
    function isWhitelisted(address account) external view returns (bool);
    function setTransfersAllowed(bool allowed) external;                // onlyRole(ADMIN_ROLE)
}
```

### Who may write

> **The authority an approval needs is `MANAGER_ROLE` on the&#x20;*****module*****&#x20;— 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:

```typescript
import { createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

// `signer` must hold MANAGER_ROLE on THIS token's whitelist module.
const wallet = createWalletClient({ account, transport: http(chain.rpcUrl) });

// vendor said "approved" for these addresses:
await wallet.writeContract({
  address: whitelistModule,                 // resolved FROM the token via getRestrictionModule
  abi: whitelistAbi,
  functionName: 'batchAddToWhitelist',
  args: [approvedAddresses],
});
```

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:

```solidity
function approve(address account) external {
    IWhitelistRestrictions target = IWhitelistRestrictions(module);   // immutable — one module, forever
    if (!target.isWhitelisted(account)) target.addToWhitelist(account);
    emit Approved(account, msg.sender);
}
```

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:

```
synthetic EVM address = keccak256(solana_pubkey)[12..32]
```

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:

```typescript
export function syntheticAddress(pubkey: SolanaPubkeyInput): `0x${string}` {
  return `0x${keccak256(pubkeyBytes(pubkey)).slice(-40)}`;
}
```

**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:

```solidity
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
_grantRole(MANAGER_ROLE, msg.sender);
_grantRole(YIELD_MANAGER_ROLE, msg.sender);
_grantRole(YIELD_DISTRIBUTOR_ROLE, msg.sender);
```

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:

```solidity
function distributeYieldWithLimit(uint256 totalAmount, uint256 startIndex, uint256 maxHolders)
    external onlyRole(YIELD_DISTRIBUTOR_ROLE) nonReentrant
    returns (uint256 nextIndex, uint256 totalHolders, uint256 amountDistributed);
```

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.

```bash
# USAGE.md reference form: walk one holder per tx until nextIndex wraps to 0
distributeYieldWithLimit(total, offset, 1)
```

***

## 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):

```bash
cd scripts && CHAIN_ID=<id> npx tsx verify-deployments.ts
```

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.

```bash
CHAIN_ID=200010 npx tsx register-asset.ts <tokenAddress>        # verify + prepare the registry record
CHAIN_ID=200010 DRY=1 npx tsx register-asset.ts <tokenAddress>  # read-only preview
```

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:

```bash
PRIVATE_KEY=… CHAIN_ID=… SOLANA_KEYPAIR=… npx tsx smoke.ts
```

***

## 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).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.rome.builders/apps-on-rome/bloom/building.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
