Compliance-native stablecoin issuance on Solana. The rules are enforced by the token itself — a Token-2022 mint with a transfer hook — not by an application that can be bypassed. An issuer composes a policy in a wizard, signs three transactions, and from then on every transfer, from any wallet or any client, passes the same on-chain check.
Policy is data, not code. One program (issuer_forge) serves every
issuer. Each issuer's rules live in an on-chain account and change without
re-issuing the token or migrating holders.
Everything below is deployed on devnet and measured by a script, not by eye. The program is not audited.
An officer freezes an account alone; seizure, pause, resume, a policy change
and a change of signers are proposals that take effect only on the second of
the issuer's signatures, counted by the program. Every action carries one of
twelve reason codes and a case reference. The platform's operational key holds
only the routine powers the issuer delegates, and loses them in one action.
The journal exports a period as NDJSON, and tools/verify-journal checks it
against the chain without the api or our database.
| Criterion | Budget | Measured on devnet |
|---|---|---|
| From the officer's request to a holder's refused transfer | < 30 s | freeze max 17.0 s (10 cycles, p50 7.2 s), seizure max 11.6 s (3 cycles); 0 of 1 345 transfers attempted afterwards went through |
| Journal records without on-chain confirmation | 0 | 0 of 14, checked by the independent verifier; 4 of 4 forged copies caught (altered amount, deleted seizure, invented line, truncated file) |
| Money actions performed by the platform's operational key | 0 of ≥ 10 | 0 of 31 with every delegable power granted — 15 through the program, 9 against Token-2022 directly, 7 through the api |
| One-of-two-signature actions with an on-chain effect | 0 of ≥ 10 per kind | 0 of 270 — seize, pause, resume 50 each, policy and delegation changes 60 each; the api refused 50 of 50 before the chain; the second signature executes every kind |
The spread in the freeze time is the free public devnet node and its rate limits, not the program: the cleanest cycles took 1.4–2.0 s.
| Criterion | Budget | Measured on devnet |
|---|---|---|
| Wizard → working token on a clean account | ≤ 5 min | 10.1 s end to end through the API (9.5–10.0 s for the on-chain half alone; 1.9 s on a local validator) |
| Transfers that violate a rule are refused | 100 % of ≥ 50 attempts across four vectors | 64 of 64, three runs in a row — third-party client, CPI through a hostile program, delegate, and splitting under the per-transfer limit |
| Cost of a checked transfer vs. an unchecked one, in lamports | ≤ 2× | 1.00× (5 000 vs. 5 000) |
| Cost of a checked transfer, in compute units | < 100 000 CU | 52 410 – 70 410 CU (unchecked: 2 045) |
| Initial issuance above the attested reserve | 0 of ≥ 10 attempts | 10 of 10 refused |
| Wizard simulation vs. on-chain outcome | 0 mismatches on ≥ 15 scenarios | 16 of 16 agree — same refusal code, not merely "also refused" |
The spread in compute units between runs is not measurement noise: PDA derivation walks the bump downwards, and a bump of 254 costs a couple of thousand CU more than 255. The number is reported as a range on purpose.
The 10-second issuance time is bounded by the free public devnet RPC, not by the chain or the program — the same issuance takes 1.9 s against a local validator.
| Program | Address |
|---|---|
issuer_forge |
DLkwvpN7EjtXLiXJFMiibLf7NXgFFFTBCmMcFvKgsGe5 |
attacker (measurement-only CPI relay) |
9ZCmUGqkrtBrm83uiiMwBgRrV2cBPE9HGMgA25iRJGkQ |
- No continued minting. Initial issuance is checked against the attested
reserve; a separate
mintinstruction does not exist yet — not disabled, absent. That is M3. - No public transparency page. M3.
- Fixtures where real-world services would be. The reserve attestation is published by our own key acting as attestor — the mechanism is real, the content is a fixture. Holder statuses (tier, jurisdiction, denial) are written by the issuer's own register; there is no KYC provider integration.
- No redemption into local currency. M4.
- No licences, no filings. The platform carries out an issuer's obligations; it does not grant permission to issue.
- The hook never creates accounts.
HolderStatusandVelocityCounterare created when a holder is thawed. A missing account means the transfer is refused — never skipped. - The platform's operational key never signs money. Minting, seizing, pausing and policy changes require the issuer's 2-of-N quorum, and the program verifies it. The operational key is limited on chain to a delegation mask (thawing holders, updating the issuer's own status register), and the issuer can revoke it in one action. Revocation does not freeze onboarding: every delegated route also works unsigned, returning the transaction to the issuer's own wallet.
- Two implementations of the rules model, one truth. The Rust evaluator inside the hook and the TypeScript simulator behind the wizard are checked against each other by differential tests on a shared fixture, and against the live network by the demo (the 16-of-16 above). Without those the two copies would drift silently.
- Tenant isolation comes from the roster, not the request. The API derives
issuer_idfrom the wallet's membership in an issuer's roster; a header can narrow the choice among proven memberships but can never grant one. - No
any. Zod at every API boundary. Comments explain why, never what.
programs/issuer-forge Anchor program: issuer config, policy, transfer hook,
holder status, velocity counters, reserve check
programs/attacker Minimal hostile program for the CPI attack vector.
No tests and no checks — on purpose; it is a probe.
packages/chain Vendored IDL, PDAs, transaction builders, base58,
program-error decoding. The only source of the
program address.
packages/policy Rules model in TypeScript: canonical binary layout,
rules hash, transfer simulation, scenario catalogue
packages/shared Error codes, API primitives, indexed event types,
refusal codes (shared by indexer and verifier)
packages/db Drizzle schema and migrations (Supabase/Postgres).
RLS is on from the first migration, with no policies
yet — deny-all except the owner.
apps/api Hono. Login (Privy), roster, policy simulation, token
issuance (unsigned transactions), holder onboarding,
compliance actions and proposals, delegations,
journal export and live feed
apps/landing The landing page: static HTML and CSS, no build step
apps/web React console: issuance wizard with live simulation,
the officer's screen, delegations of the
operational key
apps/worker Indexer: chain → Postgres mirror and event log; runs
inside the api behind RUN_WORKER, or on its own
tools/demo The measurement scripts behind the tables above
tools/verify-journal Checks an exported journal against the chain, both
ways, talking only to a Solana node
tools/spikes Feasibility spikes kept with their tests (can the hook
resolve a provider attestation directly? — it can)
scripts/ WSL build/test/localnet helpers, IDL sync
.github/workflows/ Pages deploy of the landing and the console, keep-alive
ping of the api
render.yaml Render Blueprint for the api (one free web service)
docs/ SPEC, PLAN, TASKS, SCRATCHPAD — not tracked in git
Node 26 (runs .ts directly, no build step for the api) · pnpm workspaces (no
Turborepo) · TypeScript 5.9 strict · Biome · Vitest 4 ·
Hono · Drizzle + Supabase · React 19 + Vite 8 · Anchor 0.32.1 / Token-2022 ·
Solana CLI 4.2 · Rust 1.97 · mollusk-svm 0.15 for program tests
Pinned on purpose — do not "update while you are at it":
spl-transfer-hook-interface = 0.10.0 (2.x splits Pubkey into two
incompatible types), solana-address 2.6.1, mollusk-svm 0.15.0 instead of
litesvm (which does not compile against Anchor 0.32.1).
pnpm install
cp .env.example .env # fill in DATABASE_URL and the keys; see comments inside
pnpm gate # idl:check + lint + typecheck + test — green before every commitSupabase needs two connection strings: the session pooler on port 5432 for
DDL, the transaction pooler on port 6543 for the API (prepare: false is set
for exactly that). The direct db.<ref>.supabase.co host is IPv6-only and
will not resolve from most networks. Connect as postgres, not anon —
RLS is enabled with no policies, so any other role sees empty tables without
an error.
DATABASE_URL=<session pooler, port 5432> pnpm --filter @forge/db db:migrateThe Anchor toolchain runs in WSL; invoke it from PowerShell, not Git Bash
(Git Bash rewrites /mnt/... paths):
wsl.exe -e bash /mnt/<repo>/scripts/wsl-build.sh # anchor build
wsl.exe -e bash /mnt/<repo>/scripts/wsl-test.sh # cargo test, all targets
wsl.exe -e bash /mnt/<repo>/scripts/wsl-localnet.sh # local validator with both programs in genesiswsl-test.sh runs every target, not just --lib: the differential check
against the TypeScript rules model lives in tests/rules.rs.
pnpm --filter @forge/api start # http://localhost:8787, reads ../../.env
pnpm --filter @forge/web dev # http://localhost:5173Reading the journal and the feed of a token (both need a session token):
# A period, as NDJSON. `from`/`to` take either a slot number or an ISO date;
# the first line is a manifest with the slot window and the line count.
curl -H "authorization: Bearer $TOKEN" \
"http://localhost:8787/api/tokens/$MINT/journal?from=2026-09-01&to=2026-09-30"
# The live feed. Read with `fetch`/`curl`, not `EventSource`: the session
# travels in the header, never in the query string.
curl -N -H "authorization: Bearer $TOKEN" \
"http://localhost:8787/api/tokens/$MINT/stream?backlog=20"
# Check an exported period against the chain, without the api.
node tools/verify-journal/src/main.ts journal.ndjson --rpc https://api.devnet.solana.comtools/demo creates an issuer from scratch on every run — that is what
"on a clean account" means — issues a token, onboards holders, checks the
simulator against the network, measures the cost of a checked transfer, and
then throws 64 rule-violating transfers and 10 over-reserve issuances at it.
# local validator, funded from the faucet
node --env-file=.env tools/demo/src/main.ts --rpc http://127.0.0.1:8899
# devnet, funded by transfer from the deploy wallet (the faucet is rate-limited)
node --env-file=.env tools/demo/src/main.ts \
--rpc https://api.devnet.solana.com --payer ~/.config/solana/id.json
# the same, with issuance and onboarding routed through the API
# (requires the API running with RUN_WORKER=true)
node --env-file=.env tools/demo/src/main.ts \
--rpc https://api.devnet.solana.com --payer ~/.config/solana/id.json \
--api http://127.0.0.1:8787With --api the demo stands in for one thing it cannot have on a fresh run:
Privy. Through PRIVY_API_URL a fixture answers the same
GET /api/v1/users/<did> request with the run's wallets, and the access token
is signed with the key whose public half is in PRIVY_VERIFICATION_KEY. The
API's authentication code is not modified — it verifies signature, audience
and expiry exactly as in production. The issuer's roster is not written by
the demo at all: it creates the issuer on chain and then polls
GET /api/session until the API's own indexer has mirrored the membership,
the way any client would.
Public devnet RPC rate-limits aggressively; the demo paces its requests with a
token bucket, and web3.js retries on 429. Expect a handful of retry lines
per run.
The console is a static bundle and the api is one Node process, so the whole thing runs at $0: GitHub Pages for the console, Render (free web service) for the api, Supabase for Postgres. Nothing here touches mainnet.
https://issuerforge.github.io/IssuerForge/ apps/landing GitHub Pages, on every push to main
https://issuerforge.github.io/IssuerForge/app/ apps/web the same Pages deploy
https://issuerforge-api.onrender.com apps/api Render Blueprint, on every push to main
Landing and console → GitHub Pages (.github/workflows/pages.yml). The
landing page is plain static files (apps/landing, no build step) copied to
the site's root; the console is built into app/ beside it. Once, in the
repository settings:
- Settings → Pages → Source: GitHub Actions.
- Settings → Secrets and variables → Actions → Variables (not
Environments):
VITE_API_URL= the Render URL,VITE_PRIVY_APP_ID= the Privy app id.VITE_DEVNET_RPC_URLis optional and defaults to the public devnet node — the paid node with a key stays on the api side, because everything inVITE_*is baked into a public bundle.
The console lives under /IssuerForge/app/; the workflow passes that as
BASE_PATH to Vite and the router picks it up as basename. Pages has no
rewrites and serves a custom 404.html only from the site's root, so the
root 404.html is the console's shell and deep links land in the router; a
short script in front of it sends links from before the move
(/IssuerForge/<path>) to /IssuerForge/app/<path>. For a custom domain set
the variable PAGES_BASE_PATH=/app/ and add the domain under
Settings → Pages; the landing page then takes the domain's root.
Api → Render (render.yaml). Render → New → Blueprint → this
repository, then fill in the values marked sync: false: WEB_ORIGIN is
https://issuerforge.github.io (scheme and host only — no path), DATABASE_URL
is the Supabase transaction pooler string (port 6543), and the rest are
the same secrets as in .env.example. Render provides PORT itself. The free
instance sleeps after 15 minutes of silence; .github/workflows/keepalive.yml
pings /health every 5 minutes, which the 750 free hours a month cover.
GitHub disables the schedule after 60 days without a commit — re-enable it
under Actions if the repository goes quiet.
The indexer runs inside the api process (RUN_WORKER=true in the
blueprint) rather than as a second service: Render's free plan has no
background workers. Its cursor lives in the database, so a spin-down costs
latency, not records.
- M3 — the reserve holds. Continued minting under the attested reserve, attestation expiry as a token parameter, the platform fee, a public transparency page (Astro) showing circulation, reserve and attestation age.
- M4 — off-ramp. Redemption against a fixture partner.
Out of scope on every milestone: real KYC providers, a real bank reserve, a real off-ramp partner, and mainnet.
Milestones M1 and M2 are measured on devnet. Milestone M3 is next.
Apache-2.0 — see LICENSE.