NAV-priced issuance and redemption for tokenised bonds and treasury products, deed tokens for individual physical assets, and the wBOND front end that drives the fungible half.
A template for tokenising real-world assets, in two shapes that share one access and compliance layer:
- Fungible, NAV-priced — bonds, treasury products and similar instruments. Issuance and redemption run through separate vaults against an oracle-posted NAV. The reference product is wBOND, deployed on Sepolia with a single-page app that shows the whole cycle end to end: mint test USDC, deposit, hold wBOND, redeem, then track or cancel a pending request.
- Non-fungible, individually priced — a flat, a house, a vehicle. One token is one deed. There is no NAV and no vault: the issuer lists an asset at a price it names, a compliant buyer pays exactly that, and the deed is minted to them. The reference product is wDEED, with a catalogue and purchase flow in the same app.
The deliverable is the repository: a client deployment is a fork of it, with its own parameters and its own thin product contract.
Read apps/contracts/docs/TRUST-MODEL.md
before deploying anything. This platform is not trust-minimised, and that document is
where the residual powers are inventoried and the blast radius of a key compromise is
quantified.
rwa-platform/
├── apps/
│ ├── web/ single-page app — wBOND cycle, wDEED catalogue and purchase
│ ├── contracts/ Solidity contracts, tests, Hardhat/Foundry tooling
│ └── subgraph/ deed catalogue indexer (Goldsky)
├── packages/
│ └── ui/ shared shadcn/Tailwind component library
├── package.json root workspace scripts (turbo build/dev/lint/typecheck)
├── pnpm-workspace.yaml
└── turbo.json
A single pnpm workspace covers both areas — one pnpm install, one lockfile. apps/web
consumes apps/contracts' build artifacts directly (see "Contracts → front end"
below); there is no separate repository to keep in sync.
| Area | Technology |
|---|---|
| Contracts | Solidity 0.8.36 (cancun), OpenZeppelin Contracts 5.6.1 + Upgradeable |
| Upgradeability | UUPS proxies behind a TimelockController, @openzeppelin/hardhat-upgrades |
| Contract tooling | Hardhat 3, @nomicfoundation/hardhat-toolbox-viem, viem, TypeScript |
| Contract testing | Solidity unit, fuzz and invariant tests via forge-std; TypeScript integration tests |
| Contract analysis | solhint, Slither, Foundry (branch coverage), EIP-170 size gate |
| Front end | React 19, Vite 8, TypeScript (strict), TanStack Router |
| Chain access | viem 2.55.5, wagmi 2.x, TanStack Query |
| Wallet | Privy (@privy-io/react-auth + @privy-io/wagmi) |
| UI | shadcn base-nova on Base UI, Tailwind v4, hugeicons |
| Hosting | Cloudflare Workers, static assets |
| Monorepo | pnpm workspaces + Turborepo |
apps/contracts/
contracts/
access/ AccessRegistry (DefaultAdminRules + Enumerable), role ids, registry mixin
compliance/ ComplianceRegistry — blacklist, greenlist, sanctions oracle
oracle/ AdminNavAggregator (AggregatorV3-compatible) + DataFeed price policy
pause/ per-operation pause switches
token/ RwaToken — ERC-20 + 2612 + 165 + 7943
RwaNftToken — ERC-721 + 165 + 7943 (non-fungible variant)
vaults/ ManageableVault, DepositVault, RedemptionVault
sale/ RwaNftSale — primary issuance for deeds, no oracle and no escrow
products/ the per-client contracts — wbond/ and wdeed/, a name and a symbol each
libraries/ DecimalsConverter
mocks/ hostile and awkward tokens, a misbehaving sanctions oracle
testers/ harnesses that make internal branches reachable
scripts/
config.ts THE file a fork edits
deploy.ts deployment + the Safe grant batch
handover.ts stage 2 end to end, for an admin whose key is available locally
verify-deployment.ts role and configuration audit
verify-etherscan.ts publishes sources for every contract in a deployment
recover-deployment.ts rebuilds a lost deployment record from the chain
accounts.ts the configured accounts, balances, and what to put in config.ts
upgrade.ts storage-layout validation + timelock calldata
docs/
TRUST-MODEL.md privileges, blast radius, accepted residual risks, deviations
FORKING.md onboarding checklist, sizing rules, the handover runbook
SEPOLIA.md the same runbook with testnet values filled in and stage 2 automated
ACCEPTANCE.md every acceptance criterion mapped to the test that proves it
Issuance and redemption are separate contracts. DepositVault holds MINTER_ROLE and
never BURNER_ROLE; RedemptionVault is the mirror. A bug on one side cannot undo the
other, and deployment verification asserts both the positive and the negative.
One registry holds every privilege, split into an operational tier the client multisig
controls outright and a critical tier only a TimelockController can touch. The role
hierarchy is written once at initialisation and there is no setRoleAdmin to re-point it.
Compliance is one replaceable module. Blacklist status is a mapping rather than a role,
because AccessControl roles are always self-renounceable and a prohibition its subject
can lift is not a prohibition.
Prices arrive through an adapter. The vaults depend only on IDataFeed, so the bundled
admin-posted aggregator can be swapped for a Chainlink feed without touching a vault. Three
independent constraints bound a compromised NAV key: a per-update deviation cap, a
cooldown, and absolute hard bounds.
Fail closed on entry, fail open on exit. Every guardrail blocks new business. None of
them may strand an unresolved request: rejectRequest and cancelRequest require no price
and are not gated by any pause, and the token exposes a single-use privileged refund path
so a transfer pause or a blacklist cannot trap escrow. Sanctions are the one control that
still applies.
The deed product borrows the layers and none of the economics. RwaNftToken reuses the
access registry, compliance, pauses and the ERC-7943 machinery, and drops everything
NAV-shaped: no feed, no vault, no escrow, no refund carve-out — with nothing held on a user's
behalf there is nothing to strand. Price is a term of each listing, in the payment token's own
units. What replaces NAV as the link to the real world is documentHash: a deed cannot be
minted without one, and every amendment emits both the old and the new value.
One origin, not one URL per token. The deed token holds a base URI and each token holds a
path, so moving the metadata host is one transaction rather than one per deed, and
BatchMetadataUpdate tells indexers it moved. The base is METADATA_ADMIN's to change
because documentHash does not move with it — a redirected reader downloads something that
fails verification.
Two products, one registry, no shared privileges. WdeedToken namespaces the roles bound
to a contract — wdeed.MINTER, wdeed.BURNER, wdeed.METADATA_ADMIN — so the sale cannot
mint bonds and the redemption vault cannot burn deeds. Roles held by people stay shared: a
breaker that stops one product and not the other is worse than one that stops both.
verify-deployment asserts both directions.
pnpm install
pnpm --filter rwa-contracts-evm build
pnpm --filter rwa-contracts-evm test
pnpm --filter rwa-contracts-evm deploy:local # full stack on an in-memory chain, handover includeddeploy:local deploys both products paused, replays the role-grant batch, waits out the
timelock for the one critical grant, verifies the wiring, posts NAV, unpauses, and runs two
smoke tests: mint → redeem on the fungible side, and list → buy → transfer on the deed side.
That sequence is the production runbook, minus the multisig.
Run from apps/contracts, or prefix with pnpm --filter rwa-contracts-evm from the
repo root.
| Command | What it does |
|---|---|
test |
Solidity and TypeScript suites |
coverage |
line coverage (Hardhat) |
FOUNDRY_PROFILE=coverage coverage:branch |
branch coverage (Foundry — see below) |
lint / slither |
solhint / static analysis |
size |
EIP-170 check on the production profile |
gas |
gas snapshot |
deploy:local |
local bring-up |
accounts --network <n> |
configured accounts and balances |
deploy:sepolia |
deploy to Sepolia — see apps/contracts/docs/SEPOLIA.md |
handover --network <n> |
complete stage 2 of the handover |
verify-deployment --network <n> |
audit a deployment on a persistent network |
recover-deployment --network <n> |
rebuild a lost deployments/<n>/ from the chain |
verify:etherscan --network <n> |
publish sources for every deployed contract |
deploy:deeds --network <n> |
add the deed product to an existing deployment |
gen-deed-metadata --network <n> |
regenerate apps/web/public/deeds/ from the demo catalogue |
seed-deeds --network <n> |
list the demo catalogue on the primary sale |
The last two read scripts/demo/, which holds the showcase catalogue and nothing a real
deployment needs. A fork deletes the directory and lists its own inventory.
Foundry is a development-only dependency. Hardhat 3 reports line and statement coverage
but emits no branch data, and crytic-compile cannot read its build-info layout — so
Foundry supplies the branch metric and the front end Slither compiles through. It is not
part of the production toolchain, and forge test is not a gate.
Start with apps/contracts/docs/FORKING.md. The
short version: edit scripts/config.ts, copy contracts/products/wbond/ (or
contracts/products/wdeed/ for a non-fungible product) and change the name and symbol, and
leave the core contracts alone. If you find yourself editing contracts/access,
contracts/vaults or contracts/token during onboarding, check the three intended
extension points first.
A fork that ships only one of the two products deletes the other's product contract and its grant-batch entries; nothing in the core contracts needs to change either way.
A single-page application for the wBOND product deployed on Sepolia
(chain id 11155111). It shows live NAV, TVL, limits, fees and pause states, and walks a
user through the whole cycle: mint test USDC, deposit, hold wBOND, redeem, then track or
cancel a pending request.
Every number comes from a direct contract call over a public RPC. The deed catalogue is the one exception — it needs an enumeration the contracts do not provide, and reads it from a subgraph.
- Live product metrics — NAV with an "updated N ago" stamp, TVL, supply cap and headroom, minimum investment, effective fee, remaining daily limit, and a badge for every paused operation.
- Deposit and redemption, each in instant and request mode, with a preview taken from a contract simulation rather than a local reimplementation.
- User-owned slippage, seeded from the vault's tolerance but never driven by it.
- A faucet for the mock USDC, sized from the vault's own first-deposit minimum.
- Request tracking — ids captured from the receipt, statuses re-read on chain, cancellation that reports what actually happened rather than what the receipt implies.
- Human revert messages — contract errors decoded through a merged ABI and rendered with the right denomination for the side that raised them.
- Degraded mode — without a Privy app id every read-only screen still works.
Present only when the deed contracts are configured; otherwise the section and its routes do not exist.
/deeds— a grid of every listed asset, filterable by For sale / Mine / All./deeds/$tokenId— metadata, attributes, on-chain addresses and a one-click purchase. No amount to enter and no slippage: the price is a term of the listing, andpurchasecarries the exact terms shown on screen, so a re-listing between broadcast and inclusion reverts rather than settling at a number the buyer never saw.- Document verification — the app downloads what
tokenURIpoints at, hashes the bytes as served, and compares that todocumentHash. Verified, mismatched and unavailable are three distinct verdicts; only the second is an error, because a host being down says nothing about the deed.
Enumeration. Neither contract can list itself — RwaNftSale answers getListing(id) one
id at a time, and the deed token is ERC721URIStorage, not ERC721Enumerable — so the ids
come from apps/subgraph. Without VITE_SUBGRAPH_URL the app falls back to
scanning VITE_DEED_ID_FIRST..LAST in one multicall, which is enough for a seeded catalogue
and blind to anything listed outside it. The page says which source it used.
pnpm install
cp apps/web/.env.example apps/web/.env.localVite reads .env, .env.local, .env.[mode] and .env.[mode].local — it does not
read .env.example. Without .env.local the app refuses to start and names the variable
that is missing.
| Variable | Required | Notes |
|---|---|---|
VITE_PRIVY_APP_ID |
no | From the Privy Dashboard. Without it read-only screens work and the Connect button explains why it is disabled. |
VITE_SEPOLIA_RPC_URL |
no | Defaults to a public endpoint. Override if it rate-limits you. |
| contract addresses (7) | yes | Pre-filled with the public Sepolia deployment. |
VITE_WDEED_TOKEN_ADDRESS, VITE_NFT_SALE_ADDRESS |
no | The deed product. Set both or neither — one alone is rejected at startup. Unset, the deed screens do not render. |
VITE_DEED_ID_FIRST, VITE_DEED_ID_LAST |
no | The token-id window the deed catalogue is scanned over. Defaults 1–36. |
pnpm --filter web dev # http://localhost:5173
pnpm --filter web... typecheck
pnpm --filter web... lintYou will also want Sepolia ETH for gas. The mock USDC has a faucet in the app; ETH does not, and the header shows the balance so an empty tank is visible before a transaction fails.
Read-only against live Sepolia — no wallet, no gas:
pnpm --filter web exec vite-node scripts/check-config.ts # env parses
pnpm --filter web exec vite-node scripts/check-abi.ts # vendored ABIs intact
pnpm --filter web exec vite-node scripts/check-units.ts # preview arithmetic
pnpm --filter web exec vite-node scripts/verify-onchain.ts # live metrics vs reference
pnpm --filter web exec vite-node scripts/verify-reverts.ts # revert messages are human
pnpm --filter web exec vite-node scripts/verify-deeds.ts # catalogue + every document hashverify-deeds runs the same reads and the same hashing the catalogue page performs, over the
whole id window, and fails on any deed whose served document does not hash to its on-chain
commitment. It exits cleanly when no deed product is configured.
ABIs are vendored from apps/contracts. Regenerate after a contract change:
pnpm --filter rwa-contracts-evm build
pnpm --filter web exec vite-node scripts/gen-abi.tspnpm --filter web build # output: apps/web/distbuild runs vite build alone. Type checking is a separate task on purpose:
routeTree.gen.ts is generated by the router plugin during the Vite stage, so running
tsc first fails on a clean clone.
Static assets on Cloudflare Workers — no worker entrypoint, no server code.
pnpm --filter web build
pnpm --filter web run deploy # wrangler deployBecause the app is fully static, configuration is baked in at build time from VITE_*
variables. Change an address and you must rebuild; .dev.vars has no effect, as it only
reaches server-side worker code.
chainId 11155111, all verified:
| Contract | Address |
|---|---|
| wBOND token | 0x4C09fA8266018EFB4B062a712Ffd4Bda14212F42 |
| DepositVault | 0x42CB1dAF16ad98Ddf315288Ee702eef256d44EC9 |
| RedemptionVault | 0x5D4940E7Ababe91124B20Bf59C7C97CebB8e8E36 |
| DataFeed | 0xE0C70Bc3AB7b10F1471F7CA10c5502Bd5D9FF99A |
| AdminNavAggregator | 0x478800bB46f922747D7bf8F87E36bF040fD57552 |
| ComplianceRegistry | 0xD4b2CaB348516602Aa0801Cb7d6FbD8349bADB46 |
| Mock USDC | 0xcd169A9900A0F24dc1Ec4772ab56614EcAC980CE |
| WdeedToken | 0x8cF7C227Fc05e530e38Bc4970f705165eFd3114A |
| RwaNftSale | 0xd353073236d27D96Cf997495B9AaEf99Ef423de8 |
The deed pair is deployed by deploy:deeds against
the registries already on chain: a second product joins a live deployment without redeploying
the first, which is the point of keeping every privilege in one registry.
The catalogue holds 36 demo assets; verify-deeds re-hashes every one against the file the
app serves.
Worth knowing before reading the code:
- Decimals differ by asset. wBOND is 18, the mock USDC is 6, the NAV aggregator
reports 8 while
DataFeed.getPrice()returns 18. The deposit vault charges its fee in token units before converting; the redemption vault charges in wBOND. minAmountWadmeans different things on the two vaults — payment-token value on the deposit side, wBOND on the redemption side.DataFeed.getPrice()reverts when the price is stale or out of bounds, so it is always read alongsideisHealthy()and the UI degrades to "Price unavailable".- Redemption payouts are pulled from
tokensProvider, not the vault, so they can fail on provider liquidity alone. The form checks that before you spend gas. - Requests are not enumerable on chain. Ids are captured from the receipt and kept in
localStorage; every displayed field is re-read fromgetRequest(id). - Neither is the deed catalogue — hence the subgraph.
tokenURIis the token's base plus the per-token path, so the two are stored separately and joined on read.
- Contract addresses live in
apps/web/src/lib/config.tsonly — CI greps for any other 0x literal underapps/web/src. apps/web/public/deeds/is generated. Editapps/contracts/scripts/demo/catalogue.ts, then re-run bothgen-deed-metadataandseed-deeds: running one alone leaves a catalogue whose hashes no longer verify.apps/subgraph/subgraph.yamlis generated bypnpm --filter subgraph sync. A manifest pointed at a stale address does not fail — it indexes nothing and looks like it is syncing.- UI primitives come from
packages/ui; add them withpnpm --filter @workspace/ui exec shadcn add <name>rather than writing a local copy. apps/web/src/lib/abi/is generated. Editapps/web/scripts/gen-abi.ts, not its output.- Never commit
.env.local,dist/, or anything undernode_modules/.
APY, historical NAV or TVL charts, holder counts and volumes do not exist on chain and
would need an indexer. Listing requests via eth_getLogs, operator and admin screens,
networks other than Sepolia, and i18n are out of scope as well.
No admin screens, for either product. Listing a deed, pausing, approving a request and maintaining the compliance lists are done with scripts and the multisig.
Deed metadata is demo data served as static files. apps/web/public/deeds/ is generated
by pnpm --filter rwa-contracts-evm gen-deed-metadata — synthetic addresses, procedural
placeholder artwork, a disclaimer in every file. A real deployment serves it from wherever
it keeps title documents; the app only follows tokenURI and checks the hash.
Secondary trading of deeds. Deeds transfer peer to peer under the token's own gates. There is no marketplace contract, no escrow and no order book.
See CONTRIBUTING.md for the development workflow and SECURITY.md for what to report privately.
MIT © RedDuck Limited