Skip to content
View issuerforge's full-sized avatar

Block or report issuerforge

Block user

Prevent this user from interacting with your repositories and sending you notifications. Learn more about blocking users.

You must be logged in to block users.

Content in all repositories owned by your account will be closed.
Maximum 250 characters. Please don’t include any personal information such as legal names or email addresses. Markdown is supported. This note will only be visible to you.
Report abuse

Contact GitHub support about this user’s behavior. Learn more about reporting abuse.

Report abuse
IssuerForge/README.md

IssuerForge

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.

What is built (milestones M1 and M2)

Everything below is deployed on devnet and measured by a script, not by eye. The program is not audited.

M2 — the order is carried out (measured 2026-10-03 … 2026-10-05)

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.

M1 — the rule holds (closed 2026-09-16)

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.

Devnet addresses

Program Address
issuer_forge DLkwvpN7EjtXLiXJFMiibLf7NXgFFFTBCmMcFvKgsGe5
attacker (measurement-only CPI relay) 9ZCmUGqkrtBrm83uiiMwBgRrV2cBPE9HGMgA25iRJGkQ

What is deliberately not there yet

  • No continued minting. Initial issuance is checked against the attested reserve; a separate mint instruction 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.

Design rules the code is built around

  • The hook never creates accounts. HolderStatus and VelocityCounter are 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_id from 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.

Repository layout

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

Stack

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

Getting started

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 commit

Database

Supabase 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:migrate

On-chain program (WSL only)

The 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 genesis

wsl-test.sh runs every target, not just --lib: the differential check against the TypeScript rules model lives in tests/rules.rs.

Running the apps

pnpm --filter @forge/api start   # http://localhost:8787, reads ../../.env
pnpm --filter @forge/web dev     # http://localhost:5173

Reading 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.com

The demo / measurement script

tools/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:8787

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

Deploying for free

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:

  1. Settings → Pages → Source: GitHub Actions.
  2. 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_URL is optional and defaults to the public devnet node — the paid node with a key stays on the api side, because everything in VITE_* 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.

What comes next

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

Status

Milestones M1 and M2 are measured on devnet. Milestone M3 is next.

License

Apache-2.0 — see LICENSE.

Popular repositories Loading

  1. IssuerForge IssuerForge Public

    Compliance-native stablecoin issuance on Solana: policy as data, enforced by the token itself through a Token-2022 transfer hook, not by the app.

    TypeScript