A self-hosted, single-tenant platform for tokenizing a real-world asset class as a permissioned ERC-20, with an auditor-attested supply lifecycle and on-chain cash redemption. One deployment serves one issuer, one token, one Asset Profile, one quote token, one auditor. To tokenize a materially different asset class, stand up a separate deployment.
Not an SDK. The issuer deploys and operates its own contracts, server, database, IPFS pinning, and admin interface. The investor interface is optional and issuer-owned — a self-contained example lives in
investor-web/to fork from.
Stack: Solidity (Foundry) · Go (server + offline signer) · React (Vite). Runs against any EVM chain with conventional JSON-RPC and one conventional ERC-20 quote token.
- Deploys a permissioned fungible token representing claims on a real-world asset class.
- Every supply increase requires a valid EIP-712 auditor signature bound to this chain, this
SupplyController, this profile, one metadata record, one amount, one Vault, and one unused nonce. The admin broadcasts the auditor-signed mint from their own wallet; the server never relays it and cannot create supply. - All minting targets a Vault (digital warehouse) — never directly to investors.
- Transfers require both sender and recipient to be currently allowed (mint/burn exempt).
- On-chain purchase with one quote token (
Vault.buy). - On-chain cash redemption:
requestRedemption→ issuerfundRedemption→ permissionlessclaimRedemption. Funded quote lives in an isolated escrow, is never withdrawable, and pays only the recorded beneficiary. Unfunded requests can be cancelled after a timeout. - Auditor-attested de-tokenization burn from unsold Vault inventory only.
- Air-gapped auditor signing via an offline Go CLI (zero network during signing).
It does not establish legal title, custody, regulatory classification, or a guarantee that every redemption is funded — those remain issuer and jurisdiction responsibilities.
┌───────────────────────── Issuer operator ─────────────────────────┐
Offline / air-gapped │ React admin SPA ── embedded in ──► Go server │
┌───────────────┐ │ │ │ │ │ │
│ Signer (CLI) │ │ ▼ HTTP (OpenAPI) │ │ └► IPFS │
│ EIP-712 sign │ │ Go server: API · indexer · tx-manager · KeyProvider │ └───► Mongo │
└──────┬────────┘ └────────┼──────────────────────────────────────────────┘ │
│ signed-result.json │ JSON-RPC (submit compliance status only)
▼ ▼
┌──────────────────────────────────────── EVM chain ─────────────────────────────────────┐
│ RWAFactory → ComplianceRegistry · RWAToken · SupplyController · Vault · │
│ FixedPriceStrategy · RedemptionEscrow │
└────────────────────────────────────────────────────────────────────────────────────────┘
| Path | What | README |
|---|---|---|
contracts/ |
Foundry contracts, scripts, unit/fuzz/invariant/adversarial tests | contracts/README.md |
signer/ |
Offline Go EIP-712 signer + .rwa package/validation library |
signer/README.md |
server/ |
Go API, chain integration, indexer, tx manager, embedded web | server/README.md |
web/ |
React admin console SPA (built and served by the Go binary) | web/README.md |
investor-web/ |
Standalone example investor SPA — a self-contained reference to fork for your own investor UI (not served by the binary) | investor-web/README.md |
shared/ |
EIP-712 types, golden vectors, JSON schemas, deployment manifests | — |
api/openapi.yaml |
HTTP contract | — |
docs/ |
Specs, ADRs, operator/auditor/security guides | docs index |
docker/ |
Local anvil + MongoDB + IPFS compose (dev only) | — |
shared/, contracts/src/interfaces/, api/openapi.yaml, docs/spec/, the Makefile, and the
CI workflows are the contracts between components. Change them through a reviewed ADR
(docs/adr/), since they're what keeps the Solidity, Go, and TypeScript sides in agreement.
- Docker
- Foundry (
forge/cast/anvil) - Go 1.25+
- Node.js 22+
Install every subrepo's dependencies once with make bootstrap.
Runs against the local Docker stack (anvil chainId 31337 + MongoDB + IPFS, all on loopback — dev only).
- Copy the example config:
cp server/config.example.yaml server/config.yaml - Set
security.admin_addressto an Anvil account address (e.g. account 0,0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) — the wallet that logs into the admin console. - Set
security.jwt_secretto a random 64-character string (openssl rand -hex 32). - Set
keys.compliance_keyto the matching Anvil private key (keys.provider_modestaysraw, the example default — dev only). - Set
contract.project_idto a fresh UUID (uuidgen). The admin console reads it fromGET /api/v1/configand every Asset Profile is gated against it, so it must be set before you create a profile. - Start the stack:
make up - Deploy the
RWAFactory:cd ./contracts && forge script script/DeployFactory.s.sol --rpc-url http://localhost:8545 --broadcast -vv - Copy
factoryandstart_blockfrom that output intocontract.factory_addressandcontract.start_blockinserver/config.yaml. - Deploy a test USDT quote token:
cd ./contracts && forge script script/DeployTestToken.s.sol --rpc-url http://localhost:8545 --broadcast -vv - Note the
tokenaddress from that output — you'll enter it as the Quote Token in the admin console's project setup. - Build the platform binary (with a fresh embedded SPA):
make platform - Run it:
./server/bin/platform --config ./server/config.yaml
The console is served at http.addr (default :8080). Connect the admin_address wallet, create the
Asset Profile, then broadcast RWAFactory.deploy from the console.
Wipes the local chain, database, and IPFS state for a clean run:
make downrm -rf ./docker/data- Repeat dev steps 6–12 (redeploying the factory + test token gives fresh addresses, so
contract.factory_address/contract.start_blockand the project's quote token must be updated again).
Production runs the same binary against a real EVM chain, managed MongoDB, and durable IPFS pinning,
with environment: production — which turns on fail-closed startup checks (the server refuses to
start if any of the below is missing or weak). Work through
docs/security/release-checklist.md and qualify the chain with
docs/operator/testnet-qualification.md first.
- Copy
server/config.example.yamltoserver/config.yamland setenvironment: production. security.admin_address— the real admin wallet (a multisig is strongly recommended).security.jwt_secretandsecurity.kyc_webhook_hmac_secret— strong secrets (≥ 32 bytes each); both are required.contract.project_id— a fresh UUID; required and immutable for the life of the deployment.keys.provider_mode—local-keystoreorvault(neverraworkms-mockin production). The compliance key is the server's only hot key; keep it out of plaintext config.mongo.*— a real MongoDB URI (persistence_mode: mongo;memoryis refused in production).ipfs.*— a real Kubo endpoint plus at least one backup destination (backup_archive_dirorbackup_kubo_url) and areplication_threshold.chain.*— the target chain RPC/id/confirmations and fee caps (max_fee_per_gas_weietc., which production requires).- Deploy the factory with a real key on the target chain, then set
contract.factory_address/contract.start_blockfrom the output:DEPLOYER_PK=0x… forge script script/DeployFactory.s.sol --rpc-url <chain-rpc> --broadcast -vv - Build and run behind TLS / a reverse proxy:
make platform, then./server/bin/platform --config ./server/config.yaml.
The quote token is your real ERC-20 (not the test token). Deploy the project and broadcast the
auditor-signed mint from the admin wallet through the console — the server never holds a deployer,
relayer, or pricer key — and set adminTransferDelay ≥ 24h in the project config for production.
make <target>: bootstrap · format · lint · contracts-test · signer-test · server-test · web-test · vectors-check · bindings-check · e2e · ci · up · down · platform. make ci is the PR gate (format,
lint, all tests, cross-language vectors).
bash contracts/script/e2e.sh # live anvil: deploy → allow → mint → buy → request → fund
# → claim → burn, plus timeout → cancel (real broadcasts)
bash server/e2e/run_e2e.sh # the same lifecycle driven purely over the documented HTTP API,
# with the offline signer producing the auditor signature- Setup — the admin authors an Asset Profile (a JSON Schema subset), which is canonicalized
(RFC 8785 JCS), hashed (SHA-256 →
profileDigest), and pinned to IPFS. The admin then broadcastsRWAFactory.deployfrom their own wallet, which deploys and wires the whole stack and registers the Vault and Escrow as allowed. The server observes theProjectDeployedevent and verifies it before treating the project as usable. - Onboard — the investor proves wallet ownership; external KYC (via a signed webhook or a
manual action) sets the wallet
AllowedinComplianceRegistry. - Record + attest — the admin creates a metadata record, the server builds a
.rwapackage, the auditor validates and signs it offline, and the admin uploadssigned-result.jsonand broadcastsSupplyController.mintfrom their wallet (tokens go to the Vault). - Sell — an allowed investor calls
Vault.buywith the quote token (on-chain purchase only). - Redeem — the investor calls
requestRedemption(snapshots the quote, escrows RWA), the treasury callsfundRedemption(re-checks compliance, escrows the exact quote), and anyone callsclaimRedemption(pays the beneficiary, returns RWA to the Vault). Unfunded requests can be cancelled after the timeout. - Retire — the auditor signs a
BurnAttestationand the controller burns Vault inventory only.
- Contracts —
forge test(unit, fuzz, invariant conservation, adversarial reentrancy/token mocks, integration) plus a contract-size guard (every contract ≤ 24,576 B), a gas snapshot, and Slither. - Server —
go test ./... -race,cmd/verifyabi(checks for ABI drift against the compiled contracts), andserver/e2e/run_e2e.shfor black-box API coverage. - Signer —
go test ./..., including byte-for-byte reproduction ofshared/vectors. - Web —
tsctypecheck, Vitest, Playwright (mock and live modes), axe accessibility. - Cross-language parity — the EIP-712 mint digest/signature and the JCS/CID canonicalization are
identical across Solidity, the Go signer, and the Go server. This is the thing most likely to go
subtly wrong, so the golden vectors in
shared/vectorspin it down for all three.
The CI workflow runs the contracts/signer/server/web jobs plus a dependency scan on every PR.
- The server holds exactly one hot signing key —
compliance(whitelists KYC'd wallets) — behind aKeyProvider(vault/kms/local;rawenv-hex is dev-only). Everything else, including project deployment, the auditor-signed mint, and all admin/treasury/redemption-manager/ pricer actions, is broadcast as calldata from the connected admin wallet or multisig — never a server hot key. There is no deployer or relayer key. - Request size and rate limits, security headers (strict CSP,
X-Frame-Options: DENY,nosniff), atomic idempotency, webhook replay protection, and constant-time HMAC. - The Vault and RedemptionEscrow are pinned
Allowed(see ADR-001) so a compromised compliance key cannot freeze core flows. - Threat model and acceptance matrix live under
docs/spec/; incident response is indocs/security/incident-response.md.
RWAFactory.deploy(ProjectConfig) deploys the whole stack in one transaction (the factory delegates
child creation to four helper contracts to stay under the EIP-170 code-size limit). The admin
broadcasts it from their own wallet — the factory address comes from GET /api/v1/config — and the
server observes the ProjectDeployed event and verifies bytecode, roles, and allowlisting before
treating a project as usable. Qualify each target chain with
docs/operator/testnet-qualification.md.
- Specs:
docs/spec/— contracts, roles, redemption state machine, asset profile and limits. - Operator:
docs/operator/— operator & admin, redemption ops, testnet qualification. - Auditor:
docs/auditor/auditor-guide.md. - Security: incident response under
docs/security/.
V1 deliberately leaves out: instant, pooled, or partial redemption; redemption fees; native-currency purchases; multiple quote tokens; Chainlink/PoR/DEX/Safe/ERC-3643 integrations; force transfer/burn/clawback; upgradeable proxies and timelocks; cross-chain; and provider-specific KYC/payment adapters.
MIT (see SPDX headers in sources).