Skip to content

Repository files navigation

RedDuck

Whitelabel RWA Platform

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.

Layout

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.

Built with

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

Contracts (apps/contracts)

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

Design in one page

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.

Getting started

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 included

deploy: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.

Commands

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.

Forking

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.

Front end (apps/web)

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.

Features

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

The deed catalogue

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, and purchase carries 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 tokenURI points at, hashes the bytes as served, and compares that to documentHash. 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.

Setup

pnpm install
cp apps/web/.env.example apps/web/.env.local

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

Development

pnpm --filter web dev                     # http://localhost:5173
pnpm --filter web... typecheck
pnpm --filter web... lint

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

Checks

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 hash

verify-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.ts

Build

pnpm --filter web build                   # output: apps/web/dist

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

Deployment

Static assets on Cloudflare Workers — no worker entrypoint, no server code.

pnpm --filter web build
pnpm --filter web run deploy     # wrangler deploy

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

Deployed contracts (Sepolia)

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.
  • minAmountWad means 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 alongside isHealthy() 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 from getRequest(id).
  • Neither is the deed catalogue — hence the subgraph. tokenURI is the token's base plus the per-token path, so the two are stored separately and joined on read.

Repository hygiene

  • Contract addresses live in apps/web/src/lib/config.ts only — CI greps for any other 0x literal under apps/web/src.
  • apps/web/public/deeds/ is generated. Edit apps/contracts/scripts/demo/catalogue.ts, then re-run both gen-deed-metadata and seed-deeds: running one alone leaves a catalogue whose hashes no longer verify.
  • apps/subgraph/subgraph.yaml is generated by pnpm --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 with pnpm --filter @workspace/ui exec shadcn add <name> rather than writing a local copy.
  • apps/web/src/lib/abi/ is generated. Edit apps/web/scripts/gen-abi.ts, not its output.
  • Never commit .env.local, dist/, or anything under node_modules/.

Not included

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.

Contributing

See CONTRIBUTING.md for the development workflow and SECURITY.md for what to report privately.

License

MIT © RedDuck Limited

About

Whitelabel RWA platform for NAV-priced tokenized bonds/treasuries and deed-backed assets, with Solidity contracts, compliance controls, Sepolia app, and subgraph indexing.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages