diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8806373..d0dc706 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,6 +20,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm build
+ # node:test via tsx — no database, no network, no fixtures. Today it holds
+ # the CF-01 suspension closed: the catalog may not go back on sale one tier
+ # at a time while a deployed token still lands on the vault-held deployer.
+ - run: pnpm test
contracts:
name: Committed bytecode matches the source
diff --git a/MAP.md b/MAP.md
index 87354f7..e82b2e8 100644
--- a/MAP.md
+++ b/MAP.md
@@ -35,7 +35,7 @@ apps/forge-mint/ the React SPA this same service serves
deployed`, with `failed` as the retryable sink. `draft` is declared and never
written — `insertOrder` starts at `awaiting_payment` (`src/store.ts:42`).
-### 1.1 Create — `POST /tokens` (`src/routes/tokens.ts:39`)
+### 1.1 Create — `POST /tokens` (`src/routes/tokens.ts:114`)
Body is validated by the shared zod schema (`forgemint.ts:219`): symbol
`/^[A-Z0-9]+$/` 2–10 chars, decimals 0–18, supply a positive integer *string*,
@@ -46,33 +46,85 @@ an SPL mint has no cap primitive, so a Solana Foundry token must not pretend to
have one.
Then: the offer must exist, the chain must exist, and **the offer's
-`standardsIncluded` must contain the chain's standard** (`tokens.ts:58`) — which
+`standardsIncluded` must contain the chain's standard** (`tokens.ts:144`) — which
is what confines SPL to the Foundry tier. Mainnet authorisation is checked
*here*, before the order row exists, not only at deploy: the customer pays
Shards two steps later and taking money for an order that will be refused at the
-last step is the worst possible place to discover it (`tokens.ts:73-82`).
-
-### 1.2 Pay — `POST /tokens/:id/pay` (`tokens.ts:115`)
-
-Owner-guarded, and only from `awaiting_payment` (`tokens.ts:121`). It forwards
+last step is the worst possible place to discover it (`tokens.ts:175-181`).
+
+**`ownerAddress` is required and is the whole of CF-01's fix** (`tokens.ts:138`,
+`src/owner.ts`). It is the customer's own wallet address; the deploy passes it as
+the contract's `recipient_`/`owner_`, so the supply — and, on Forge and Foundry,
+the ownership — lands somewhere the customer holds the key, and the vault-held
+deployer is left doing the only thing its `creation` transaction shape can safely
+do, which is pay for the transaction. It used to be `deployerAddress` for both
+roles, which meant every token ever deployed sat at an address that may sign one
+contract creation and nothing else (audit CF-01).
+
+It is validated *here* rather than in the shared zod schema for two reasons: the
+rule depends on the chain's family, which is only known once `chain` resolves;
+and `createTokenOrderSchema` lives in `@cloudsforge/shared`, so requiring it
+there is a published-package change this service must not wait on. The schema
+strips unknown keys, so the field is read off the raw body. EVM addresses must
+pass `ethers.isAddress` and are returned EIP-55 checksummed — a *mixed-case*
+address with one character wrong fails, which is the only typo check that exists
+before the value is written into a contract forever. The zero address is refused.
+Solana addresses must be on the ed25519 curve, so a program-derived address —
+which has no private key — cannot be the recipient.
+
+**Solana orders are suspended** and this route answers 403 `chain_suspended` for
+them (`src/suspended.ts`, called at `tokens.ts:166`). An SPL mint's authority is
+set when the mint is created and moving it needs `SetAuthority`, which keyvault
+refuses by design — so the supply and a live, permanent power to inflate it would
+stay with the vault. See `chain/solana.ts` for the two shapes that were
+considered and why both fail. The gate is an allowlist of chain *families*
+(`evm`), so a sixth EVM chain is deliverable by construction and a new non-EVM
+one is refused until somebody reads it; it is not an environment flag, because no
+environment makes an SPL mint deliverable. Every tier remains on sale — all three
+sell ERC-20 — so `/offers` reports no suspended tier and `/chains` reports one
+suspended chain.
+
+### 1.2 Pay — `POST /tokens/:id/pay` (`tokens.ts:214`)
+
+Owner-guarded, and only from `awaiting_payment` (`tokens.ts:220`). It forwards
**the caller's own Bearer token** to forge-pay's `POST /spend`
(`src/clients/pay.ts:38-59`) so the debit is attributed to the authenticated
user — ForgeMint never holds the wallet.
-Idempotency key is `forge-mint:order:` (`tokens.ts:131`): one order is
+Idempotency key is `forge-mint:order:` (`tokens.ts:240`): one order is
paid for exactly once however many times pay is retried.
+A Solana order answers 403 `chain_suspended` here, before any spend
+(`tokens.ts:229`), read from the order's own `chain` rather than its tier. The
+gate on create does nothing for orders that already exist — one created while SPL
+was on sale is sitting in `awaiting_payment` behind a live Pay button — so this
+is the line that actually stops the Shards moving (audit CF-01). `/provision`
+(§1.3) and `/deploy` (§1.4) are gated too. Nothing here refunds — there is no
+refund path back to forge-pay (§8) — but a customer who paid before the gate
+shipped must not be walked one step *further* into spending: `/provision` hands
+out a funding address next to "send gas here, then call /deploy", and gas sent
+to a deployer can never be moved out again, while the deploy it would pay for is
+refused. So a paid order on a suspended chain rests at `paid` and costs its
+owner nothing more.
+
The 409 decoding is not naive. forge-pay returns 409 for *both* insufficient
funds and an idempotency conflict, so `spendShards` reads `body.code` and only
reports `insufficient` when the code is absent or is `insufficient_balance`
(`clients/pay.ts:64-70`) — telling a paying customer they are out of Shards while
their first attempt is mid-commit would be a lie. `insufficient` becomes 402;
-anything else becomes 502 `pay_error` (`tokens.ts:134-139`).
+anything else becomes 502 `pay_error` (`tokens.ts:243-248`).
-### 1.3 Provision — `POST /tokens/:id/provision` (`tokens.ts:146`)
+### 1.3 Provision — `POST /tokens/:id/provision` (`tokens.ts:318`)
-Only from `paid`. It sets `provisioning`, then asks ForgeKeyvault for a fresh
-address bound to this order (`tokens.ts:157-168`):
+Only from `paid`, and only on a deliverable chain. A Solana order answers 409
+`chain_suspended` here before any address exists (audit CF-01): this route takes
+no money and delivers nothing, but it is the one that hands out a funding
+address together with "send gas here, then call /deploy" — and gas sent to a
+deployer is unrecoverable by design, for a deploy §1.4 refuses anyway. The order
+stays `paid`, so nothing is confiscated; it simply stops costing its owner.
+
+Then it sets `provisioning` and asks ForgeKeyvault for a fresh address bound to
+this order (`tokens.ts:329-339`):
```
{ chain, userId, orderId, network: order.network, purpose: 'deployer' }
@@ -80,10 +132,10 @@ address bound to this order (`tokens.ts:157-168`):
`network` is passed explicitly and the comment says why: the vault binds every
later `/sign` to it, so a mainnet order must never be provisioned under the
-default testnet network (`tokens.ts:162-164`).
+default testnet network (`tokens.ts:333-335`).
**The rollback.** If keyvault throws, the order is put back to `paid` and a 502
-`keyvault_error` is returned (`tokens.ts:183-193`). The comment names the
+`keyvault_error` is returned (`tokens.ts:355-364`). The comment names the
stakes: the customer has already been debited, so a rollback with nothing
written down looks to them like the money vanished — hence an `error`-level log
line carrying orderId, userId, chain and network before the state change.
@@ -91,117 +143,242 @@ line carrying orderId, userId, chain and network before the state change.
spend is idempotent, so a retry does not double-charge.
On success the response carries the funding address, the network, a per-chain
-testnet faucet hint (`tokens.ts:15-22`) and an explicit mainnet warning
-(`tokens.ts:177-181`).
-
-### 1.4 Deploy — `POST /tokens/:id/deploy` (`tokens.ts:198`)
-
-Six gates, in order:
-
+testnet faucet hint (`tokens.ts:23-30`) and an explicit mainnet warning
+(`tokens.ts:349-353`).
+
+### 1.3a Set the owner — `POST /tokens/:id/owner` (`tokens.ts:268`)
+
+CF-01's other half. Every order created before `ownerAddress` existed carries
+NULL, and the deploy refuses those rather than minting to the vault-held deployer
+again — so without this route the ones that were already paid for would be
+exactly the "row in a state no route can leave" shape the suspension was written
+to avoid. It also serves a customer who mistyped or has since moved wallets: the
+address is only consequential at the moment of the deploy and is encoded in the
+contract forever afterwards, so before that moment it should be as easy to change
+as possible.
+
+Owner-guarded, validated by the same `normalizeOwnerAddress`, and closed for good
+once the deploy is on the wire: `deployed`/`contractAddress` is a 409 that says
+to transfer it from the wallet that holds it, and `deploying`/`txHash` is a 409
+`deploy_in_flight` — the constructor args are already signed. The change is
+logged at `info` with the *previous* value, because "it used to say what?" is the
+first question support will have about the one field whose being wrong costs the
+customer the entire token.
+
+### 1.4 Deploy — `POST /tokens/:id/deploy` (`tokens.ts:370`)
+
+One settlement, then eight gates, in order:
+
+0. **Settle first, judge second.** `settleInFlightDeploy()` runs before any gate
+ below can refuse (`tokens.ts:394`), so a retry is measured against what the
+ chain says now rather than against a `txHash` written by an attempt that has
+ since died. See §1.6 — this is CF-21, and without it gate 4 was permanent.
1. **Owner + provisioned.** 404 if not the caller's order; 409 if no
- `deployerAddress` (`tokens.ts:202-206`).
-2. **Already deployed** returns the order unchanged (`tokens.ts:207`), so the
- endpoint is safe to re-hit.
+ `deployerAddress` (`tokens.ts:375-377`).
+2. **Already deployed** returns the order unchanged (`tokens.ts:396`), so the
+ endpoint is safe to re-hit — including for a deploy the settlement above just
+ resolved.
3. **Status must be one of `awaiting_funds | failed | deploying`**
- (`tokens.ts:208`).
+ (`tokens.ts:397-399`).
4. **In-flight guard.** `txHash && !contractAddress` → 409 `deploy_in_flight`
- carrying the hash (`tokens.ts:214-222`). Broadcasting another would pay gas
- twice and orphan whichever contract lost.
-5. **Mainnet authorisation, then mainnet confirmation** — two different things,
+ carrying the hash and its explorer link (`tokens.ts:404-413`). Broadcasting
+ another would pay gas twice and orphan whichever contract lost. Reaching this
+ line now means the settlement could *not* call the broadcast dead — it is
+ mined-and-unaccounted-for, still in the mempool, or the RPC would not answer
+ — so the message says the retry will be accepted once it resolves.
+5. **Deliverable chain** (`tokens.ts:426`). A non-EVM order is refused 409
+ `chain_suspended`, and `chain/solana.ts` refuses the same thing at the point of
+ danger (audit CF-01). Unreachable for anything created since §1.1's gate; it
+ exists for the paid Solana orders that predate it. Deliberately *after* the
+ settlement and the in-flight guard, so a legacy order whose deploy is still on
+ the wire is resolved (CF-21) before it is refused for anything else — refusing
+ first is how it would become permanently unsettled.
+6. **Owner address present** (`tokens.ts:441`). No `ownerAddress` means the order
+ predates CF-01's fix, and deploying it would mint the whole supply to
+ `deployerAddress` — the defect itself. 409 `owner_address_required`, naming
+ §1.3a as the way out rather than leaving the customer somewhere they cannot
+ leave.
+7. **Mainnet authorisation, then mainnet confirmation** — two different things,
see §3. A denial here is logged at `error` because an order that got this far
was paid for and provisioned, so the capability was withdrawn between creation
and deploy: an operator action someone has to connect to the customer now
- stuck in front of it (`tokens.ts:231-254`).
-6. **Balance gate.** `readBalance` (`tokens.ts:25`) reads the order's network RPC
+ stuck in front of it (`tokens.ts:475-496`).
+8. **Balance gate.** `readBalance` (`tokens.ts:35`) reads the order's network RPC
— `getBalance` for EVM, `getBalance` in lamports for Solana. Unfunded → 409
`awaiting_funds` with the address and a rendered balance
- (`tokens.ts:278-287`). An RPC *failure* is a separate 409 `rpc_unavailable`
- (`tokens.ts:270-276`), because to the customer it is indistinguishable from
+ (`tokens.ts:501-509`). An RPC *failure* is a separate 409 `rpc_unavailable`
+ (`tokens.ts:493-499`), because to the customer it is indistinguishable from
"unfunded" and the difference is whether they should send gas or we should
change provider. The RPC URL is passed through `safeUrl` on every path — an
`RPC__` override is usually a hosted endpoint with the API key
- in its path (`tokens.ts:271-272`).
+ in its path (`tokens.ts:494-495`).
-Then the **claim**: `claimDeploy(order.id, userId)` (`store.ts:97`) is a single
+Then the **claim**: `claimDeploy(order.id, userId)` (`store.ts:124`) is a single
conditional `UPDATE … RETURNING` that sets `status='deploying'` and
`deploy_started_at=now()` where the row still matches
`status IN (awaiting_funds, failed, deploying) AND tx_hash IS NULL` and the
lease is either NULL, older than 300s, or the status is not yet `deploying`.
Everything above that line read a snapshot; this is the only step that decides
-who broadcasts, and exactly one concurrent caller wins (`tokens.ts:289-301`).
+who broadcasts, and exactly one concurrent caller wins (`tokens.ts:516-523`).
This closes the audit's "concurrent `/deploy` can double-spend gas" item. The old
shape had both callers read `awaiting_funds`, both pass the in-flight guard —
-`txHash` is written only *after* broadcast — and both pay gas (`store.ts:80-96`).
+`txHash` is written only *after* broadcast — and both pay gas (`store.ts:107-123`).
It is a **lease** and not a latch so a deploy whose process died mid-flight is
still retryable, and the lease (300s) is deliberately longer than the 180s
-receipt wait in `chain/evm.ts:157` so a slow-but-live deploy never loses its claim
-to an impatient caller (`store.ts:73-78`).
+receipt wait in `chain/evm.ts:312` so a slow-but-live deploy never loses its claim
+to an impatient caller (`store.ts:100-105`).
-### 1.5 Remote sign → broadcast → verify (EVM, `src/chain/evm.ts:51`)
+### 1.5 Remote sign → broadcast → verify (EVM, `src/chain/evm.ts:189`)
1. `encodeDeployData(variant, params)` builds creation bytecode + ABI-encoded
- constructor args (`chain/erc20.ts:40`), with the **customer's own funded
- deployer** as recipient and owner (`evm.ts:75-76`).
+ constructor args (`chain/erc20.ts:53`), with **the customer's own address**
+ as recipient and owner (`evm.ts:225-231`). This is CF-01's fix and it is one
+ argument: it used to be `deployerAddress`, the vault-held key that may sign
+ this creation and nothing else, so the supply and the ownership landed
+ somewhere the customer could never spend from. The deployer now pays the gas
+ and receives nothing.
2. `getTransactionCount(deployer, 'pending')` — pending, so a retry after a
broadcast already in the mempool does not reuse a nonce and get rejected as a
- replacement (`evm.ts:79-81`).
-3. `estimateGas` + 20% headroom (`evm.ts:86-87`). **A failed estimate refuses to
- broadcast** (`evm.ts:110-112`). The old code substituted a fixed 1,500,000
+ replacement (`evm.ts:234-236`).
+3. `estimateGas` + 20% headroom (`evm.ts:241-242`). **A failed estimate refuses to
+ broadcast** (`evm.ts:265-267`). The old code substituted a fixed 1,500,000
limit for both possible causes — a reverting constructor and an unreachable
RPC — which on mainnet bought a reverting constructor real money and told the
- customer only via a warning nobody read (`evm.ts:88-97`). This closes the
+ customer only via a warning nobody read (`evm.ts:243-253`). This closes the
audit's "silent gas fallback" item; the fallback is gone, not tuned.
4. Fee model: EIP-1559 when the node reports both fields, else legacy
- `gasPrice` with a 5 gwei floor (`evm.ts:126-133`).
+ `gasPrice` with a 5 gwei floor (`evm.ts:281-288`).
5. **Remote sign.** The unsigned object goes to keyvault's `POST /sign` with the
full binding — address, family, purpose, chain, network, orderId
- (`evm.ts:135-146`). The private key never enters this process.
-6. **Broadcast, then record the hash immediately** via the `onBroadcast` callback
- (`evm.ts:149-153`, wired at `tokens.ts:316-318`). Everything below can throw,
- and without this a timed-out wait leaves a live pending deploy the order has
- no record of — so a retry would pay gas for a second one.
+ (`evm.ts:290-301`). The private key never enters this process.
+6. **Broadcast, then record the hash and its nonce immediately** via the
+ `onBroadcast` callback (`evm.ts:303-308`, wired at `tokens.ts:542-544`).
+ Everything below can throw, and without this a timed-out wait leaves a live
+ pending deploy the order has no record of — so a retry would pay gas for a
+ second one. The nonce goes with the hash because §1.6's settlement cannot
+ tell a dropped transaction from a mined one without it.
7. **Wait for the receipt** (1 confirmation, 180s) rather than trusting the
precomputed CREATE address: a reverted deploy must not be recorded as a live
- contract (`evm.ts:155-159`).
+ contract (`evm.ts:310-314`).
8. **Verify with `getCode`** — an empty `0x` at the contract address throws
- (`evm.ts:163-165`).
+ (`evm.ts:318-320`).
-Solana (`src/chain/solana.ts:43`) is the same shape with different primitives:
+Solana (`src/chain/solana.ts:87`) is the same shape with different primitives:
the ephemeral mint keypair is generated locally and partial-signs
-(`solana.ts:54,77`), the four-instruction transaction is serialized unsigned
-(`solana.ts:79-81`), keyvault adds the payer signature, and the confirmation is
+(`solana.ts:104,127`), the four-instruction transaction is serialized unsigned
+(`solana.ts:129-131`), keyvault adds the payer signature, and the confirmation is
bounded twice — by the blockhash's `lastValidBlockHeight` and by a 90s race —
because a bare `confirmTransaction(sig)` waits on a websocket that may never
-fire again (`solana.ts:104-116`).
+fire again (`solana.ts:158-166`).
+
+**It does not run** (`solana.ts:98`, `SPL_DELIVERABLE`). Its
+`createInitializeMint2Instruction(mint, decimals, payer, payer)` makes the
+vault-held deployer both mint and freeze authority, and keyvault refuses
+`SetAuthority` (6), so that authority can never be handed to the customer — while
+`MintTo` (7) *is* signable, leaving the platform a permanent power to inflate the
+customer's token (audit CF-01). The body is kept intact rather than deleted
+because it is the transaction the keyvault change has to be written against, and
+`SPL_DELIVERABLE` is typed `boolean` precisely so it stays typechecked code.
+Opening it needs a bounded `SetAuthority` shape in forge-keyvault — deployer to
+the order's own `ownerAddress`, mint and freeze only — which is a second repo's
+ticket.
### 1.6 Failure and recovery
A deploy exception sets `failed` rather than `awaiting_funds`, so an attempt that
may already have broadcast is not silently indistinguishable from one that never
left the gate; both are retryable but only `failed` records that a spend happened
-(`tokens.ts:344-349`). The error log is described in source as the most expensive
+(`tokens.ts:570-575`). The error log is described in source as the most expensive
event this service can produce, and it reads `txHash` back **from the row** —
because `onBroadcast` is what wrote it — so `broadcast: true` means funds left the
-deployer and a human is needed, not a retry (`tokens.ts:350-371`).
+deployer (`tokens.ts:576-604`).
A vault refusal is handled separately as 409 `keyvault_refused`
-(`tokens.ts:376-383`), and the message promises the customer their gas is
+(`tokens.ts:609-616`), and the message promises the customer their gas is
untouched, which is true: `/sign` runs before anything reaches the wire. The
distinction is created in the client — a keyvault 403 becomes `VaultRefused`
rather than a generic `UpstreamError`, because a refusal is deterministic (the
identical payload loses again, so a retry is wasted) whereas a 502 is an outage
worth retrying (`src/clients/keyvault.ts:53-71`).
-**In-flight settlement recovery** lives on `GET /tokens/:id/status`
-(`tokens.ts:407-423`): when the row has a `txHash` and no `contractAddress` on an
-EVM chain, `settleEvmDeploy` (`chain/evm.ts:32`) fetches the receipt and returns
-null while still pending, `{status:'failed'}` on a revert, or the contract
-address and explorer URL on success. This is the exit from the
-`deploy_in_flight` 409 — without it, an order whose transaction mined after the
-180s timeout would never reach `deployed`. It logs at `warn` rather than `error`
-because pending is the expected state, but an order that never leaves it is a
-broadcast transaction permanently lost track of (`tokens.ts:413-422`).
+**In-flight settlement recovery** is `settleInFlightDeploy()`
+(`tokens.ts:59-110`), and it runs on **both** `GET /tokens/:id/status`
+(`tokens.ts:641`) and `POST /tokens/:id/deploy` (`tokens.ts:394`), before either
+one judges the row. When the row has a `txHash` and no `contractAddress` on an
+EVM chain it calls `settleEvmDeploy` (`chain/evm.ts:88`), which reaches one of
+four conclusions:
+
+| conclusion | receipt | what it writes | retryable after |
+| --- | --- | --- | --- |
+| `deployed` | `status 1` + a contract address | contract address, explorer URL, `deployed` | n/a |
+| `reverted` | `status 0` | `failed`, **`txHash` cleared** into `lastFailedTxHash` | yes — the nonce is spent, so the retry takes the next one |
+| `dropped` | none, and three conditions below all hold | `failed`, **`txHash` cleared** into `lastFailedTxHash` | yes — at the same nonce |
+| `mined_unresolved` | `status 1`, no contract address | `failed`, `txHash` **kept** | no — deliberately; this one needs a human |
+| (still pending) | none | nothing | — |
+
+This is the exit from the `deploy_in_flight` 409. It used to be only the first
+row of that table: a revert returned `{status:'failed'}` and a missing receipt
+returned `null`, and neither cleared `txHash` — which is exactly what `/deploy`
+refuses on (`tokens.ts:404`) and what `claimDeploy()` requires to be NULL
+(`store.ts:139`). So `'failed'` in `DEPLOYABLE` readmitted every failure that
+happens *before* the broadcast (a refused gas estimate, a vault refusal, an RPC
+outage) and neither of the two that happen after it. A customer whose deploy
+reverted or was dropped from the mempool had spent their Shards, sent gas to the
+deployer, held no token, and had no route back: every retry 409'd and every
+`/status` left the row where it was. The only remedy was an operator running
+`UPDATE token_orders SET tx_hash = NULL` by hand. **CF-21.**
+
+Of the two, `dropped` is the one that happens: since §1.5's estimate refusal a
+constructor that reverts on its own terms is turned away before it is signed, so
+a revert now needs the chain to change between the estimate and the mining. The
+`reverted` branch stays because that gap is real, but the case this section
+exists for is the transaction that left the deployer and never mined.
+
+Calling a transaction `dropped` needs three things to hold together, because
+being wrong means broadcasting a second one (`evm.ts:146-183`): it is older than
+`DEPLOY_DROP_AFTER_MS` (15 minutes, deliberately much longer than the 5-minute
+deploy lease); `getTransaction` says the node no longer holds it, so a
+transaction merely waiting out a fee spike is pending rather than dead; and
+`getTransactionCount(deployer,'latest')` has not moved past the nonce it
+occupies — recorded at broadcast in `token_orders.deploy_nonce`, and read as 0
+on rows written before that column existed, which is the nonce a per-order
+deployer's first and only transaction uses. Even if all three are somehow wrong,
+the retry re-broadcasts at the *same* nonce with the *same* constructor data, so
+at most one of the two can be included and both would produce identical bytecode
+at the identical CREATE address.
+
+**The settlement is written conditionally**, through
+`applyDeploySettlement()` (`store.ts:124`) rather than a bare `updateOrder`:
+`WHERE id = $1 AND tx_hash = $2`, the hash the settlement was about. It has to
+be, because the settler runs on a polled GET and a clicked POST at the same time
+and decides what to write several RPC round-trips after reading the row. Applied
+unconditionally, the slower of two conclusions lands on top of whatever resolved
+the order first — `{status:'failed', tx_hash:NULL}` over a row another request
+has already claimed and is broadcasting for. `'failed'` is re-claimable
+immediately (§1.4 gate 3, `DEPLOYABLE`), so that write hands the lease to a
+second attempt while the first is still live, and if it lands after that
+attempt's `onBroadcast` it erases the new hash as well: the replacement then
+takes the *next* nonce, because `deployErc20Evm` counts `'pending'` — so the two
+are no longer mutually exclusive, and the customer pays gas twice for two
+contracts, one of which no order references. The compare-and-set makes the
+settlement idempotent in the statement that performs it, the same shape
+`claimDeploy()` uses; a conclusion that finds no row is discarded and logged,
+and the caller re-reads the row the winner left (`tokens.ts:82-91`).
+
+The cleared hash is kept, not discarded: `last_failed_tx_hash` and
+`last_failed_outcome` are returned by `/status` and rendered on the order screen
+(`Order.tsx:440-460`), because `dropped` (the gas was never spent) and `reverted`
+(it was) are not a distinction to make a customer guess at. Settlement logs at
+`warn` rather than `error` because pending is the expected state, and every
+non-`deployed` outcome gets its own line carrying `outcome` and `retryable`
+(`tokens.ts:78-95`).
+
+`test/settle-deploy.test.ts` drives the settler against a scripted JSON-RPC node
+— all four conclusions, all three refusals, and the legacy row with neither
+column recorded. Its last two tests read the source instead, because the
+conditional write is a `WHERE` clause and a call site: no value the settler
+returns can express it, and both are one line each.
---
@@ -255,13 +432,13 @@ constructor args, and never calls a method on a deployed token
1. `FORGE_MINT_MAINNET_ENABLED` must be explicitly on. `flag()` treats anything
that is not `true` / `1` / `yes` as off (`env.ts:32-35`), and the default is
- off (`env.ts:109`). This is the kill switch: flipping it stops new mainnet
+ off (`env.ts:115`). This is the kill switch: flipping it stops new mainnet
deploys everywhere without touching per-user state.
2. Then **either** the caller's Nimbus `sub` is in
`FORGE_MINT_MAINNET_ALLOWLIST`, **or** — when no allowlist is configured — the
caller holds the `admin` role (`mainnet.ts:36-47`). An empty allowlist means
"admins only", not "everybody", because an empty allowlist is what an operator
- who has not thought about it yet has (`env.ts:110-113`).
+ who has not thought about it yet has (`env.ts:116-119`).
This is the answer to the audit's "the mainnet guard permits everything".
Previously the only barrier was `{confirmMainnet: true}`, which any authenticated
@@ -312,27 +489,30 @@ what the funding step and the faucet hints exist for.
| method | path | auth | notes |
| --- | --- | --- | --- |
-| GET | `/health` | none | `{ ok, service }` (`index.ts:57`) |
-| GET | `/chains` | none | `SUPPORTED_CHAINS` verbatim (`index.ts:58`) |
-| GET | `/offers` | none | `MINT_OFFERS` verbatim (`index.ts:59`) |
-| GET | `/capabilities` | Nimbus | `{ mainnetDeploys }` (`index.ts:63`) |
-| POST | `/tokens` | Nimbus | create an order (`tokens.ts:39`) |
-| GET | `/tokens` | Nimbus | my orders, newest first (`tokens.ts:99`, `store.ts:47`) |
-| GET | `/tokens/:id` | Nimbus | owner-only (`tokens.ts:104`) |
-| POST | `/tokens/:id/pay` | Nimbus | idempotent Shard spend (`tokens.ts:115`) |
-| POST | `/tokens/:id/provision` | Nimbus | mint the deployer (`tokens.ts:146`) |
-| POST | `/tokens/:id/deploy` | Nimbus | sign + broadcast (`tokens.ts:198`) |
-| GET | `/tokens/:id/status` | Nimbus | balance, explorer links, in-flight settlement (`tokens.ts:395`) |
-| GET | `/*` | none | SPA fallback (`index.ts:101`) |
+| GET | `/health` | none | `{ ok, service }` (`index.ts:75`) |
+| GET | `/chains` | none | `SUPPORTED_CHAINS` + `suspended`/`suspendedReason` per chain (`index.ts:81`) |
+| GET | `/offers` | none | `MINT_OFFERS` + `suspended`/`suspendedReason` per tier (`index.ts:95`) |
+| GET | `/capabilities` | Nimbus | `{ mainnetDeploys }` (`index.ts:108`) |
+| POST | `/tokens` | Nimbus | create an order — requires `ownerAddress`; **403 `chain_suspended` on Solana** (`tokens.ts:114`) |
+| GET | `/tokens` | Nimbus | my orders, newest first (`tokens.ts:196`, `store.ts:70`) |
+| GET | `/tokens/:id` | Nimbus | owner-only (`tokens.ts:203`) |
+| POST | `/tokens/:id/pay` | Nimbus | idempotent Shard spend — **403 `chain_suspended` on Solana** (`tokens.ts:214`) |
+| POST | `/tokens/:id/owner` | Nimbus | set where the token goes, before it deploys (`tokens.ts:268`) |
+| POST | `/tokens/:id/provision` | Nimbus | mint the deployer (`tokens.ts:318`) |
+| POST | `/tokens/:id/deploy` | Nimbus | sign + broadcast (`tokens.ts:370`) |
+| GET | `/tokens/:id/status` | Nimbus | balance, explorer links, in-flight settlement, `suspended` (`tokens.ts:628`) |
+| GET | `/*` | none | SPA fallback (`index.ts:146`) |
Every `/tokens/:id` route resolves through `getOwnedOrder(id, userId)`
-(`store.ts:122`), so there is no IDOR surface: a wrong owner is a 404, not a 403.
+(`store.ts:154`), so there is no IDOR surface: a wrong owner is a 404, not a 403.
**Error codes**: `validation`, `not_found`, `offer_chain_mismatch`, `conflict`,
-`insufficient_balance` (402), `pay_error` (502), `keyvault_error` (502),
-`deploy_in_flight`, `awaiting_funds`, `rpc_unavailable`, `keyvault_refused`,
-`deploy_failed`, `mainnet_disabled`, `mainnet_not_permitted`,
-`mainnet_confirmation_required`, `unauthorized`, `auth_unavailable` (503). Every
+`chain_suspended` (403), `offer_suspended` (403, reported by the catalog only —
+no tier is one today), `owner_address_required`, `insufficient_balance` (402),
+`pay_error` (502), `keyvault_error` (502), `deploy_in_flight`, `awaiting_funds`,
+`rpc_unavailable`, `keyvault_refused`, `deploy_failed`, `mainnet_disabled`,
+`mainnet_not_permitted`, `mainnet_confirmation_required`, `unauthorized`,
+`auth_unavailable` (503). Every
body carries `requestId`, and the header is CORS-exposed so the dev SPA on :3004
can read it (`index.ts:22-25`).
@@ -351,15 +531,15 @@ the request so it can be forwarded to Pay (`auth.ts:45-46`).
## 6. The client, served by the service itself
`apps/forge-mint` is a React 19 + Vite SPA, and **this same Fastify process
-serves it** via `@fastify/static` (`index.ts:88-91`). API routes are registered
+serves it** via `@fastify/static` (`index.ts:133-136`). API routes are registered
first and take precedence; `wildcard: false` means only real files are served,
and an explicit `GET /*` returns `index.html` for anything that is not an API
-prefix and accepts `text/html` (`index.ts:101-107`). Anything else is handed to
+prefix and accepts `text/html` (`index.ts:146-152`). Anything else is handed to
`obs.ts`'s single JSON 404 handler via `callNotFound()` — a catch-all route
rather than a second not-found handler, because Fastify permits only one per
-prefix and an API-only deploy needs it too (`index.ts:93-100`).
+prefix and an API-only deploy needs it too (`index.ts:138-145`).
-The SPA is only mounted if `apps/forge-mint/dist` exists (`index.ts:85`), so
+The SPA is only mounted if `apps/forge-mint/dist` exists (`index.ts:130`), so
local dev on Vite :3004 still boots the API; CI asserts the dist *is* in the
image, since a missing one is a blank page rather than a build failure
(`.github/workflows/ci.yml:66-72`).
@@ -370,10 +550,10 @@ unreachable product.
| route | page | does |
| --- | --- | --- |
-| `/` | `pages/Home.tsx` | public catalog: `GET /offers` + `GET /chains` (`Home.tsx:42`) |
-| `/create` | `pages/Create.tsx` | three-step wizard — package → chain + network → details (`Create.tsx:14-22`), ending in `POST /tokens` |
+| `/` | `pages/Home.tsx` | public catalog: `GET /offers` + `GET /chains` (`Home.tsx:41`) |
+| `/create` | `pages/Create.tsx` | three-step wizard — package → chain + network → details (`Create.tsx:34-42`), ending in `POST /tokens` |
| `/tokens` | `pages/MyTokens.tsx` | `GET /tokens` (`MyTokens.tsx:16`) |
-| `/tokens/:id` | `pages/Order.tsx` | the lifecycle: status tracker, pay / provision / deploy buttons (`Order.tsx:149-153`), mainnet confirmation checkbox (`Order.tsx:306`), funding address + faucet copy (`Order.tsx:272`), and a 4-second poll while the server is mid-flight (`Order.tsx:96-104`) |
+| `/tokens/:id` | `pages/Order.tsx` | the lifecycle: status tracker, pay / provision / deploy buttons (`Order.tsx:153-165`), the owner address and the form that sets one on a pre-CF-01 order (`Order.tsx:227-241`, `:243-276`), mainnet confirmation checkbox (`Order.tsx:420`), funding address + faucet copy (`Order.tsx:347`), and a 4-second poll while the server is mid-flight (`Order.tsx:103-109`) |
`/create`, `/tokens` and `/tokens/:id` are behind `ProtectedRoute`
(`App.tsx:16-38`); everything unknown redirects to `/` (`App.tsx:40`). The typed
@@ -385,27 +565,49 @@ typecheck failure on both sides.
## 7. Data model
-One table, `token_orders` (`src/db/schema.ts:8`), created idempotently on every
+One table, `token_orders` (`src/db/schema.ts:9`), created idempotently on every
boot (`db/migrate.ts:4`):
`id` (PK, uuid), `user_id`, `offer_id`, `chain`, `network` (default `testnet`),
`name`, `symbol`, `decimals` (default 18), `supply` (**text**, so precision is
-never lost on large totals), `cap` (nullable text), `status`,
+never lost on large totals), `cap` (nullable text), `status`, `owner_address`,
`deployer_address`, `contract_address`, `tx_hash`, `explorer_url`,
-`deploy_started_at`, `created_at`. Indexed on `user_id` (`migrate.ts:29`).
-
-`cap` and `deploy_started_at` arrive as idempotent
-`ALTER TABLE … ADD COLUMN IF NOT EXISTS` (`migrate.ts:26-28`); the lease column
+`deploy_started_at`, `deploy_nonce`, `last_failed_tx_hash`,
+`last_failed_outcome`, `created_at`. Indexed on `user_id` (`migrate.ts:41`).
+
+**`owner_address` is the customer's; `deployer_address` is the vault's.** They
+were the same value in the constructor until CF-01, which is the entire defect —
+the two columns exist to keep them apart. `owner_address` is required by
+`POST /tokens` and nullable only because rows written before it existed have
+none; those are refused by the deploy and fixed by §1.3a. It is deliberately
+*not* backfilled from `deployer_address`, which would write the defect into the
+column that exists to end it (`migrate.ts:35-40`).
+
+Every column after `cap` arrives as an idempotent
+`ALTER TABLE … ADD COLUMN IF NOT EXISTS` (`migrate.ts:24-40`); the lease column
is NULL on every pre-existing row, which `claimDeploy` reads as "unclaimed" —
exactly what those rows already meant.
-**Configuration** (`src/env.ts:87-114`): required —
+**Configuration** (`src/env.ts:87-120`): required —
`FORGE_MINT_DATABASE_URL`, `NIMBUS_JWKS_URL`, `KEYVAULT_SERVICE_TOKEN`
(≥24 chars, placeholders rejected by name). Optional — `FORGE_MINT_PORT` (4004),
-`NIMBUS_ISSUER`, `PAY_API_URL`, `KEYVAULT_URL`, `EVM_RPC_URL`, `SOLANA_RPC_URL`,
+`NIMBUS_ISSUER`, `PAY_API_URL`, `KEYVAULT_URL`,
`CORS_ORIGINS`, `FORGE_MINT_MAINNET_ENABLED`, `FORGE_MINT_MAINNET_ALLOWLIST`,
plus per-chain `RPC__` overrides read directly by
-`resolveNetwork` (`chain/networks.ts:25,30`).
+`resolveNetwork` (`chain/networks.ts:34,37`) and never surfaced on the `env`
+object.
+
+That per-chain override is the **only** RPC knob. `EVM_RPC_URL` and
+`SOLANA_RPC_URL` used to sit in this list and in `.env.example`, and neither
+could ever take effect: `resolveNetwork` consulted them only when the chain's
+baked default was empty, and every chain in `SUPPORTED_CHAINS` bakes a non-empty
+default for both networks (`forgemint.ts:46-117`). An operator rate-limited by a
+public testnet node set the one variable `.env.example` showed them and got the
+identical endpoint back. CF-43 deleted them rather than wiring them up, because
+one `EVM_RPC_URL` cannot serve five distinct EVM chains — giving it precedence
+would aim a Polygon or BSC deploy at an Ethereum endpoint, on mainnet, with real
+gas. `test/networks.test.ts` holds both halves: the invariant that made the
+fallback unreachable, and the per-chain override actually overriding.
The service token floor mirrors keyvault's on purpose: keyvault rejects
`dev-keyvault-service-token` at *its* boot, so defaulting to it here did not make
@@ -428,20 +630,34 @@ V8 stack the collector drops (`env.ts:60-85`).
comment saying what a tier actually buys — token features, and via
`standardsIncluded` which chains may be chosen (`forgemint.ts:133-141`). The
old `maxChains < 1` test is gone, with a note that it matched no catalog entry
- that has ever existed and read like a cap being enforced (`tokens.ts:65-71`).
+ that has ever existed and read like a cap being enforced (`tokens.ts:151-157`).
In its place, `warnIfCatalogOversellsChains()` logs a warning at boot if the
- catalog ever goes back to selling more (`index.ts:43-54`) — a warning and not a
+ catalog ever goes back to selling more (`index.ts:44-55`) — a warning and not a
fatal, because refusing to start over a wording problem would take two thirds
of the catalog offline. This repo's lockfile resolves
- `@cloudsforge/shared@0.3.0` (`pnpm-lock.yaml:205`), so it does not currently
+ `@cloudsforge/shared@0.4.0` (`pnpm-lock.yaml:205`), so it does not currently
fire.
- **It does not custody keys.** No private key exists in this process; see §4.
- **It does not verify source on block explorers.** No Etherscan/Sourcify client
exists, so a deployed contract is unverified until the customer does it.
- **It does not track the token after deploy.** No balance reads, no transfer
history, no post-deploy management — the ABIs are compiled and never used
- (`chain/erc20.ts:22-25`). The customer's deployer address is the owner and the
- supply recipient; management happens in the customer's own wallet.
+ (`chain/erc20.ts:22-25`).
+- **It hands an EVM token to the customer, and cannot hand over an SPL mint.**
+ On EVM the supply recipient and `Ownable`'s owner are the customer's own
+ `ownerAddress` (`chain/evm.ts:231`, `contracts/ForgeTokens.sol:29,49,75`), so
+ management does happen in the customer's own wallet — they hold the key, and
+ this service never asks anything of that address again. This entry has said
+ three different things: it originally claimed exactly that while the code did
+ the opposite, which is the doc defect that hid audit CF-01; then it recorded
+ the truth while every tier was suspended; and it now describes the fix.
+
+ Solana is the half that is still not deliverable. An SPL mint's authority is
+ fixed at creation and keyvault refuses `SetAuthority`, so the vault would keep
+ the mint authority — and, through `MintTo`, a live power to inflate the
+ customer's token — for ever. Solana orders are therefore refused at
+ `POST /tokens`, at `/pay` and at `/deploy` (`src/suspended.ts`,
+ `chain/solana.ts:98`). Opening it is a forge-keyvault change; see §1.5.
- **It does not refund.** A failed order keeps its Shard spend; the idempotency
key means a retry is free, but there is no credit path back to forge-pay.
- **It does not sweep the deployer.** Leftover gas stays at the per-order address
@@ -450,9 +666,15 @@ V8 stack the collector drops (`env.ts:60-85`).
ethereum, bsc, polygon, arbitrum, base and solana
(`forgemint.ts:36-122`) — ForgeKeyvault custodies bitcoin, xrp and ember, but
ForgeMint has no deploy path for them.
-- **It has no test suite.** CI is typecheck + build, the bytecode reproduction
- diff, an image build with two runtime assertions, and secret hygiene
- (`.github/workflows/ci.yml`). Nothing exercises the lifecycle.
+- **It has almost no test suite.** CI is typecheck + build, the bytecode
+ reproduction diff, an image build with two runtime assertions, secret hygiene
+ (`.github/workflows/ci.yml`), and four `node:test` files run by `pnpm test`:
+ the deploy settler against a scripted JSON-RPC node
+ (`test/settle-deploy.test.ts`, CF-21), the constructor argument and the owner
+ address rules (`test/owner-address.test.ts`, CF-01 — it decodes what the deploy
+ would actually broadcast and asserts the deployer is not in it), which
+ chains may be sold (`test/suspended.test.ts`), and RPC resolution
+ (`test/networks.test.ts`, CF-43). Nothing exercises the lifecycle end to end.
---
diff --git a/README.md b/README.md
index 3e0677b..5266b4c 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,8 @@
# ForgeMint
-Token deployment for CloudsForge. Users order a token, fund a deployer address
-the keyvault custodies, and ForgeMint deploys a real contract.
+Token deployment for CloudsForge. Users order a token, give the wallet address it
+should belong to, fund a deployer address the keyvault custodies, and ForgeMint
+deploys a real contract whose supply and ownership go to that wallet.
Runs on port 4004 and serves its own SPA from `apps/forge-mint` via
`@fastify/static`.
@@ -55,6 +56,15 @@ and failing on submit.
## Safety properties worth knowing
+- **The deployer never holds the token.** The order carries the customer's own
+ `ownerAddress` and that is what the constructor receives, so the vault-held
+ deployer only signs the creation and pays its gas. It used to receive the whole
+ supply and the contract ownership, which it could never transfer — the vault
+ signs one contract creation from it and nothing else (audit CF-01).
+- **Solana orders are refused.** An SPL mint's authority is fixed when the mint
+ is created and the vault will not sign the instruction that moves it, so the
+ token could not be handed over. EVM chains are unaffected; see
+ `src/suspended.ts`.
- **There is no shared funded deployer to drain** — each order deploys from its
own user-funded address.
- **Deploy waits for the receipt** and verifies contract code exists before
diff --git a/apps/forge-mint/src/lib/forgemint.ts b/apps/forge-mint/src/lib/forgemint.ts
index 023f2b7..fc63639 100644
--- a/apps/forge-mint/src/lib/forgemint.ts
+++ b/apps/forge-mint/src/lib/forgemint.ts
@@ -9,8 +9,23 @@ import { mint } from './api'
// ---- response shapes the service returns (beyond the raw TokenOrder) ------
+/**
+ * CF-01 — `TokenOrder` plus the address the token is actually for.
+ *
+ * The supply, and on Forge and Foundry the contract ownership, used to be minted
+ * to the vault-held deployer, which can sign one contract creation and nothing
+ * else: the customer could never move either. The order now carries their own
+ * address and the deploy hands the token to it. `@cloudsforge/shared`'s
+ * `TokenOrder` does not have the field yet — the service widens it the same way,
+ * in `store.ts`, and the two must be changed together until shared carries it.
+ */
+export interface MintTokenOrder extends TokenOrder {
+ /** Null only on orders created before ForgeMint asked. `POST /tokens/:id/owner` fills it. */
+ ownerAddress: string | null
+}
+
export interface ProvisionResult {
- order: TokenOrder
+ order: MintTokenOrder
fundingAddress: string
network: ChainNetwork
faucet?: string
@@ -18,15 +33,43 @@ export interface ProvisionResult {
}
export interface DeployResult {
- order: TokenOrder
+ order: MintTokenOrder
network: ChainNetwork
realFunds: boolean
message: string
}
+/**
+ * A catalog entry as the service serves it: the shared `MintOffer` plus whether
+ * this tier may currently be bought. CF-01 — a tier is suspended only when every
+ * chain it could target is, which is none of them today; the live suspension is
+ * Solana's and it is reported per chain, below. The flag comes from the service
+ * rather than being hardcoded here so the storefront can never advertise
+ * something the API refuses.
+ */
+export interface CatalogOffer extends MintOffer {
+ suspended: boolean
+ suspendedReason: string | null
+}
+
+/**
+ * A chain as the service serves it. CF-01 — an SPL mint's authority is fixed
+ * when the mint is created and ForgeMint's vault cannot afterwards hand it to
+ * the customer, so Solana orders are refused; the chain is still listed, with
+ * the sentence that explains it, because a chain that silently vanished from the
+ * picker reads as an outage.
+ */
+export interface CatalogChain extends SupportedChain {
+ suspended: boolean
+ suspendedReason: string | null
+}
+
export interface OrderStatus {
- order: TokenOrder
+ order: MintTokenOrder
network: ChainNetwork
+ /** CF-01 — this order's tier can no longer be paid for. */
+ suspended: boolean
+ suspendedReason: string | null
rpcUrl: string
chainId: number | null
deployerAddress: string | null
@@ -37,6 +80,15 @@ export interface OrderStatus {
explorerUrl: string | null
/** The deploy transaction's explorer page. Set from broadcast onwards. */
txExplorerUrl: string | null
+ /**
+ * CF-21 — a deploy transaction that reverted, or that was dropped before it
+ * mined, is cleared out of `order.txHash` so the order can be deployed again.
+ * These keep the customer's record of it: what happened, and where to look.
+ */
+ lastFailedTxHash: string | null
+ /** 'reverted' — the gas was spent. 'dropped' — it never was. */
+ lastFailedOutcome: string | null
+ lastFailedTxExplorerUrl: string | null
}
/** What this account may do, so the UI never offers what the server will refuse. */
@@ -46,20 +98,29 @@ export interface Capabilities {
// ---- catalog (public) -----------------------------------------------------
-export const getChains = () => mint('/chains', { auth: false })
-export const getOffers = () => mint('/offers', { auth: false })
+export const getChains = () => mint('/chains', { auth: false })
+export const getOffers = () => mint('/offers', { auth: false })
// ---- orders (authed) ------------------------------------------------------
export const getCapabilities = () => mint('/capabilities')
-export const listTokens = () => mint('/tokens')
-export const getToken = (id: string) => mint(`/tokens/${id}`)
+export const listTokens = () => mint('/tokens')
+export const getToken = (id: string) => mint(`/tokens/${id}`)
+
+/**
+ * CF-01 — `ownerAddress` is not part of the shared input type (the schema lives
+ * in @cloudsforge/shared and the service validates the field off the raw body),
+ * so it is required here, where the one caller is.
+ */
+export const createToken = (input: CreateTokenOrderInput & { ownerAddress: string }) =>
+ mint('/tokens', { method: 'POST', body: input })
-export const createToken = (input: CreateTokenOrderInput) =>
- mint('/tokens', { method: 'POST', body: input })
+/** CF-01 — set where the token should go, for an order that has not deployed yet. */
+export const setTokenOwner = (id: string, ownerAddress: string) =>
+ mint(`/tokens/${id}/owner`, { method: 'POST', body: { ownerAddress } })
-export const payToken = (id: string) => mint(`/tokens/${id}/pay`, { method: 'POST' })
+export const payToken = (id: string) => mint(`/tokens/${id}/pay`, { method: 'POST' })
export const provisionToken = (id: string) =>
mint(`/tokens/${id}/provision`, { method: 'POST' })
diff --git a/apps/forge-mint/src/pages/Create.tsx b/apps/forge-mint/src/pages/Create.tsx
index 088f7c1..0ff8063 100644
--- a/apps/forge-mint/src/pages/Create.tsx
+++ b/apps/forge-mint/src/pages/Create.tsx
@@ -1,15 +1,33 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
-import {
- createTokenOrderSchema,
- type ChainNetwork,
- type MintOffer,
- type SupportedChain,
-} from '@cloudsforge/shared'
+import { createTokenOrderSchema, type ChainNetwork } from '@cloudsforge/shared'
import { AppShell } from '../components/AppShell'
import { ErrorNote, LoadingScreen, Tag } from '../components/ui'
import { noticeFor, type ErrorNotice } from '../lib/api'
-import { createToken, getCapabilities, getChains, getOffers, type Capabilities } from '../lib/forgemint'
+import {
+ createToken,
+ getCapabilities,
+ getChains,
+ getOffers,
+ type CatalogChain,
+ type CatalogOffer,
+ type Capabilities,
+} from '../lib/forgemint'
+
+/**
+ * CF-01 — the shape of an address, client-side.
+ *
+ * The service is authoritative and checks more than this: EIP-55 checksum, the
+ * zero address, and on Solana whether the key is on the ed25519 curve. This is
+ * only here so an obviously wrong paste is caught next to the field rather than
+ * after a round trip, and it is deliberately permissive — anything it lets
+ * through is checked properly by `POST /tokens`.
+ */
+function ownerAddressLooksValid(family: string, value: string): boolean {
+ const v = value.trim()
+ if (family === 'evm') return /^(0x)?[0-9a-fA-F]{40}$/.test(v)
+ return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(v)
+}
type Step = 1 | 2 | 3
@@ -47,8 +65,8 @@ export function Create() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
- const [offers, setOffers] = useState(null)
- const [chains, setChains] = useState(null)
+ const [offers, setOffers] = useState(null)
+ const [chains, setChains] = useState(null)
const [capabilities, setCapabilities] = useState(null)
const [loadError, setLoadError] = useState(null)
@@ -62,6 +80,10 @@ export function Create() {
const [decimals, setDecimals] = useState('18')
const [supply, setSupply] = useState('1000000')
const [cap, setCap] = useState('')
+ // CF-01 — where the token goes. Not defaulted to anything: there is no address
+ // this form could guess that would be right, and the deployer (which it used
+ // to be, silently) is the one address that is certainly wrong.
+ const [ownerAddress, setOwnerAddress] = useState('')
const [fieldErrors, setFieldErrors] = useState>({})
const [submitError, setSubmitError] = useState(null)
@@ -91,11 +113,13 @@ export function Create() {
.catch(() => setCapabilities({ mainnetDeploys: false }))
}, [])
- // Preselect an offer from ?offer=… once offers arrive.
+ // Preselect an offer from ?offer=… once offers arrive. A suspended tier is
+ // never preselected (CF-01): a link from an old storefront page would
+ // otherwise walk the customer to step 3 and a 403 on submit.
useEffect(() => {
if (!offers) return
const pre = searchParams.get('offer')
- if (pre && offers.some((o) => o.id === pre)) {
+ if (pre && offers.some((o) => o.id === pre && !o.suspended)) {
setOfferId(pre)
setStep((s) => (s === 1 ? 2 : s))
}
@@ -135,19 +159,53 @@ export function Create() {
)
}
+ // CF-01 — a suspended tier cannot be ordered, so it cannot be picked. The
+ // service refuses it regardless of what this form sends; this only keeps the
+ // customer from filling in three screens to be told no at the end.
+ const suspendedReason = offers.find((o) => o.suspended)?.suspendedReason ?? null
+ const allSuspended = offers.length > 0 && offers.every((o) => o.suspended)
+
const pickOffer = (id: string) => {
+ if (offers.find((o) => o.id === id)?.suspended) return
setOfferId(id)
setChainId(null)
setStep(2)
}
+ // CF-01 — a suspended chain cannot be ordered on, so it cannot be picked. Same
+ // shape as the offer picker above and for the same reason: the service refuses
+ // it regardless, this only stops the customer filling in a screen to be told no.
const pickChain = (id: string) => {
+ if (chains.find((c) => c.id === id)?.suspended) return
setChainId(id)
}
const submit = async () => {
setFieldErrors({})
setSubmitError(null)
+ // CF-01 — the picker will not select a suspended tier and a preselect from
+ // ?offer= is ignored, but state held from before the catalog reloaded would
+ // otherwise reach the service and come back as a bare 403.
+ if (offer?.suspended) {
+ setSubmitError({ message: offer.suspendedReason ?? 'This package is paused.' })
+ return
+ }
+ if (chain?.suspended) {
+ setSubmitError({ message: chain.suspendedReason ?? 'This chain is paused.' })
+ return
+ }
+ // CF-01 — the one field the shared schema does not carry, so it is checked
+ // here on its own. Refusing locally rather than sending an empty string: the
+ // service's message is right but the field is on this screen.
+ if (!chain || !ownerAddressLooksValid(chain.family, ownerAddress)) {
+ setFieldErrors({
+ ownerAddress:
+ chain?.family === 'solana'
+ ? 'Enter the Solana wallet address that should own this token.'
+ : 'Enter the wallet address that should receive the token — 0x followed by 40 hex characters.',
+ })
+ return
+ }
const parsed = createTokenOrderSchema.safeParse({
offerId,
chain: chainId,
@@ -169,7 +227,7 @@ export function Create() {
}
setSubmitting(true)
try {
- const order = await createToken(parsed.data)
+ const order = await createToken({ ...parsed.data, ownerAddress: ownerAddress.trim() })
navigate(`/tokens/${order.id}`)
} catch (e) {
setSubmitError(noticeFor(e, 'Could not create the order.'))
@@ -186,30 +244,42 @@ export function Create() {
{/* ---- Step 1: pick a package ---- */}
{step === 1 ? (
-
- {offers.map((o) => (
-
- ))}
-
+ <>
+ {/* CF-01 — before the grid, not after it: the customer is here to buy
+ and the first thing they need to know is that they cannot. */}
+ {allSuspended && suspendedReason ? (
+
+ Orders are paused. {suspendedReason}
+
+ ) : null}
+
+ {offers.map((o) => (
+
+ ))}
+
+ >
) : null}
{/* ---- Step 2: chain + network ---- */}
@@ -234,20 +304,34 @@ export function Create() {
)
})}
+ {/* CF-01 — the reason, under the grid rather than inside a disabled
+ card, because it is long and it is the same reason for every
+ paused chain. */}
+ {eligibleChains.some((c) => c.suspended) ? (
+
+ Some chains are paused.{' '}
+ {eligibleChains.find((c) => c.suspended)?.suspendedReason}
+
- Whole-token count minted to the deployer at launch.
+ Whole-token count minted to your wallet at launch.
+
+ )}
+
+
+ {/* CF-01 — the field that makes the token yours. It used to not
+ exist: the supply, and on Forge and Foundry the ownership, were
+ minted to the deployer ForgeMint provisions, whose key stays in
+ the vault and can only ever sign the deploy itself. Nothing
+ reached a wallet the customer held. It is collected here, before
+ any money moves, because it is a constructor argument — after the
+ deploy it cannot be changed by anyone. */}
+
+ The whole supply is minted to this address
+ {offerId === 'spark' ? '' : ', and it becomes the contract owner'} on{' '}
+ {chain.name}. Check it character by character — it is written into the contract at
+ deploy and can never be changed afterwards. ForgeMint's deployer only pays the gas.
)}
diff --git a/apps/forge-mint/src/pages/Home.tsx b/apps/forge-mint/src/pages/Home.tsx
index 2be8d98..6dc2c6b 100644
--- a/apps/forge-mint/src/pages/Home.tsx
+++ b/apps/forge-mint/src/pages/Home.tsx
@@ -1,11 +1,10 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import type { MintOffer, SupportedChain } from '@cloudsforge/shared'
import { AppShell } from '../components/AppShell'
import { Img } from '../components/Img'
import { ErrorNote, Spinner, Tag } from '../components/ui'
import { useAuth } from '../lib/auth'
-import { getChains, getOffers } from '../lib/forgemint'
+import { getChains, getOffers, type CatalogChain, type CatalogOffer } from '../lib/forgemint'
import { noticeFor, type ErrorNotice } from '../lib/api'
const VALUE_PROPS = [
@@ -33,8 +32,8 @@ export function Home() {
const navigate = useNavigate()
const { signedIn, signIn } = useAuth()
- const [offers, setOffers] = useState(null)
- const [chains, setChains] = useState(null)
+ const [offers, setOffers] = useState(null)
+ const [chains, setChains] = useState(null)
const [error, setError] = useState(null)
const load = () => {
@@ -55,6 +54,13 @@ export function Home() {
else signIn(window.location.origin + to)
}
+ // CF-01 — the service refuses an order for a suspended tier, so the storefront
+ // must not send anyone at one. Undecided (catalog still loading) reads as "not
+ // suspended" only for the disabled state of a button that cannot be clicked
+ // yet; the reason banner needs a loaded catalog to have anything to say.
+ const suspendedReason = offers?.find((o) => o.suspended)?.suspendedReason ?? null
+ const allSuspended = offers != null && offers.length > 0 && offers.every((o) => o.suspended)
+
return (
{/* ---- Hero ---- */}
@@ -70,13 +76,26 @@ export function Home() {
Testnet by default; mainnet once your account is cleared for it.
-
+ {/* CF-01 — said here rather than only on the cards, because this is
+ the button most people press and the order it would start cannot
+ be completed. */}
+ {allSuspended && suspendedReason ? (
+
+ Orders are paused. {suspendedReason}
+
+ ) : null}
{offer.name}
- {offer.badge ? (
+ {/* CF-01 — a suspended tier keeps its price and features on
+ the card, and loses its badge and its button. Hiding the
+ package would read as "we never sold that". */}
+ {offer.suspended ? (
+ Paused
+ ) : offer.badge ? (
{offer.badge}
@@ -172,10 +196,11 @@ export function Home() {
start(offer.id)}
>
- Start with {offer.name}
+ {offer.suspended ? 'Paused' : `Start with ${offer.name}`}
- {c.standard}
+ {/* CF-01 — a chain no order can be placed on keeps its card and
+ loses its standard tag, same as a paused package. Removing
+ it would read as "we never supported that". */}
+ {c.suspended ? (
+ Paused
+ ) : (
+ {c.standard}
+ )}
+ {allSuspended ? 'Orders are paused' : 'Ready to forge your token?'}
+
- Sign in with your CloudsForge account and mint on testnet in minutes.
+ {allSuspended
+ ? 'ForgeMint is not taking new token orders until a deployed token can be handed to the wallet you control.'
+ : 'Sign in with your CloudsForge account and mint on testnet in minutes.'}
- start()}>
+ start()}
+ >
Start minting
diff --git a/apps/forge-mint/src/pages/Order.tsx b/apps/forge-mint/src/pages/Order.tsx
index 4d7f430..5988455 100644
--- a/apps/forge-mint/src/pages/Order.tsx
+++ b/apps/forge-mint/src/pages/Order.tsx
@@ -10,6 +10,7 @@ import {
getTokenStatus,
payToken,
provisionToken,
+ setTokenOwner,
type OrderStatus,
} from '../lib/forgemint'
@@ -77,6 +78,9 @@ export function Order() {
const [needTopUp, setNeedTopUp] = useState(false)
const [confirmMainnet, setConfirmMainnet] = useState(false)
const [notice, setNotice] = useState(null)
+ // CF-01 — only ever filled in for an order created before `ownerAddress`
+ // existed; a new order arrives here with one already set.
+ const [ownerDraft, setOwnerDraft] = useState('')
const refresh = useCallback(async () => {
if (!id) return
@@ -146,6 +150,12 @@ export function Order() {
}
}
+ const onSetOwner = () =>
+ runAction('owner', async () => {
+ await setTokenOwner(order.id, ownerDraft.trim())
+ setOwnerDraft('')
+ setNotice('Owner address saved. The token will be minted to it.')
+ })
const onPay = () => runAction('pay', () => payToken(order.id))
const onProvision = () => runAction('provision', () => provisionToken(order.id))
const onDeploy = () =>
@@ -156,6 +166,17 @@ export function Order() {
const status = order.status
+ // CF-01 — this order's chain cannot hand the token to the customer, so no
+ // step of it may be advertised as workable. `data.suspended` is per chain and
+ // is true for every Solana order; 'deployed' is excluded because an order that
+ // predates the gate and already deployed is finished, and telling its owner it
+ // is paused would be false.
+ const chainPaused = data.suspended && status !== 'deployed'
+ // Whether Shards have already left the customer's balance. It changes what
+ // there is to say: an unpaid order is simply not for sale, a paid one is money
+ // taken for something that is not going to arrive.
+ const paidAlready = status !== 'draft' && status !== 'awaiting_payment'
+
return (
@@ -189,6 +210,19 @@ export function Order() {
{Number(order.supply).toLocaleString()}{statusLabel(status)}
+ {/* CF-01 — shown in the summary, not buried in a step, because it is the
+ answer to "where does my token end up" and the customer should be
+ able to check it against their wallet at any point before the
+ deploy. */}
+
+
+ {order.ownerAddress ? (
+
+ ) : (
+ not set — see below
+ )}
+
+
) : null}
+ {/* ---- CF-01: an order created before we asked where the token goes ----
+ Every such row would otherwise be undeployable: the deploy refuses it
+ rather than minting the supply to the vault-held deployer, which is the
+ defect this whole field exists to end. Deploying is the only thing
+ blocked, so this panel appears for a paid order too — the money is not
+ lost, the token just has nowhere to go yet. */}
+ {!order.ownerAddress && status !== 'deployed' && !chainPaused ? (
+
+
Where should this token go?
+
+ This order was created before ForgeMint asked. The entire supply
+ {offer?.id === 'spark' ? '' : ', and the contract ownership,'} is minted to the address
+ you give here, and it is written into the contract at deploy — it cannot be changed
+ afterwards by anyone, including us.
+
+ setOwnerDraft(e.target.value)}
+ />
+
+ {busy === 'owner' ? 'Saving…' : 'Save owner address'}
+
+
+ ) : null}
+
+ {/* ---- CF-01: this order's chain cannot deliver the token ----
+ This replaces every remaining step, at every status short of
+ 'deployed' — not only at 'awaiting_payment', which is what it used to
+ do and which was its own small version of the ticket. With only the
+ unpaid branch rendered, an order paid for before the gate shipped was
+ shown step 2 and then step 3 — "Send REAL native currency for gas to
+ this address, then deploy" — by a screen that already knew, from this
+ very flag, that the deploy would return a permanent 409. The gas would
+ have been unrecoverable: the vault signs contract creations and
+ nothing else, so nothing can ever move it out of the deployer again.
+ That is the stranded-gas harm CF-01 is about, and it would have been
+ inflicted here rather than merely left unfixed. The service refuses
+ `/provision` and `/deploy` on a suspended chain for the same reason,
+ so this screen and the API now agree; neither refunds, because there
+ is no refund path back to forge-pay, and both stop the spending. */}
+ {chainPaused ? (
+
+
+ {paidAlready ? 'This order is stopped' : 'Payment is paused for this order'}
+
+
{data.suspendedReason}
+ {paidAlready ? (
+ <>
+
+ This order was paid for before {order.chain} was paused, so it stops here rather
+ than being half delivered.{' '}
+ Do not send gas to it. Whatever is sent to a deployer
+ address stays there — its key signs contract creations and nothing else, so no
+ transaction can ever move it out again — and the deploy it would pay for is
+ refused, so the gas would buy nothing.
+
+
+ There is nothing further to do here. Keep this order id for the record:{' '}
+
+
+ >
+ ) : (
+
+ Nothing has been charged for this order — it stays here, unpaid, and you can leave it
+ or ignore it.
+
ForgeMint mints a fresh, isolated deployer address for this token. Its private key lives
- only inside the hardened vault — never exposed.
+ only inside the hardened vault and is never exposed — including to you.
+
+ {/* CF-01 — this paragraph has now said three things. It stopped at
+ "never exposed", which read as a feature while hiding that the
+ token landed there too; it was corrected to say so plainly while
+ the tiers were suspended; and this is what it says now that the
+ deploy hands the token to the customer's own address. The last
+ sentence is the one that matters and is deliberately blunt: gas
+ sent here is not recoverable, and that has always been true. */}
+
+ It does not receive the token. The entire supply
+ {offer?.id === 'spark' ? '' : ', and the contract ownership,'} is minted to{' '}
+ {order.ownerAddress ? (
+
+ ) : (
+ 'the address you gave us'
+ )}{' '}
+ — the deployer only signs the deploy and pays its gas. Send it only what the deploy
+ needs: whatever is left over stays there, because the vault will not sign a transaction
+ that moves it.
3 · Fund & deploy
@@ -283,7 +416,8 @@ export function Order() {
Already broadcast — gas has left the deployer. Re-check below until it settles
- rather than deploying again.
+ rather than deploying again. If it reverts, or is dropped without being mined, this
+ order becomes deployable again on its own.
+ {/* CF-01 — the sentence the whole ticket is for. The supply is in a
+ wallet the customer holds the key to, so the next thing to do is
+ open that wallet, and it needs the contract address to show the
+ balance. It used to be in the deployer, where nothing they could
+ do would ever reach it. */}
+ {order.ownerAddress ? (
+
+ The entire supply
+ {offer?.id === 'spark' ? '' : ', and the contract ownership,'} is at{' '}
+ — your wallet. Add the
+ contract address below as a custom token to see the balance.
+
+ ) : null}
{order.contractAddress ? (
Contract address
diff --git a/package.json b/package.json
index 416c2c5..1245467 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"scripts": {
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
+ "test": "pnpm -r test",
"dev:service": "pnpm --filter @cloudsforge/forge-mint dev",
"dev:app": "pnpm --filter @cloudsforge/forge-mint-web dev",
"compile:contracts": "pnpm --filter @cloudsforge/forge-mint compile:contracts"
diff --git a/services/forge-mint/package.json b/services/forge-mint/package.json
index e779d38..777dc6e 100644
--- a/services/forge-mint/package.json
+++ b/services/forge-mint/package.json
@@ -7,6 +7,7 @@
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
+ "test": "tsx --test test/*.test.ts",
"build": "tsc --noEmit",
"compile:contracts": "node scripts/compile-contracts.mjs"
},
diff --git a/services/forge-mint/src/chain/erc20.ts b/services/forge-mint/src/chain/erc20.ts
index ce8af56..46bf89d 100644
--- a/services/forge-mint/src/chain/erc20.ts
+++ b/services/forge-mint/src/chain/erc20.ts
@@ -32,7 +32,21 @@ export interface DeployParams {
supply: string
/** Whole-token hard cap; required for the foundry variant. */
cap: string | null
- /** Receives the initial supply and, where applicable, contract ownership. */
+ /**
+ * Receives the initial supply and, where applicable, contract ownership.
+ *
+ * CF-01 — THIS IS THE CUSTOMER'S ADDRESS, never the deployer's. It was the
+ * deployer's, which meant the whole supply and (on Forge and Foundry) the
+ * `Ownable` owner landed on a vault-held key that may sign one contract
+ * creation and nothing else: no `transfer`, no `transferOwnership`, no `mint`.
+ * The customer paid for a token, funded its gas, and could not touch it.
+ *
+ * There is no second chance at this. `recipient_`/`owner_` are constructor
+ * arguments, so what is passed here is fixed in the deployed contract's state
+ * for good — the reason `owner.ts` validates the address as hard as it does,
+ * and the reason `deployErc20Evm` takes it as a separate required argument
+ * rather than defaulting to the deployer it already has in hand.
+ */
owner: string
}
diff --git a/services/forge-mint/src/chain/evm.ts b/services/forge-mint/src/chain/evm.ts
index b877537..59741f7 100644
--- a/services/forge-mint/src/chain/evm.ts
+++ b/services/forge-mint/src/chain/evm.ts
@@ -25,23 +25,161 @@ export interface EvmDeployResult {
* sign (the private key never leaves the vault), then broadcast the signed raw tx.
* Works identically on testnet and mainnet — only the resolved RPC/chainId differ.
*/
+/**
+ * CF-21 — how long a broadcast deploy may go unmined before it is eligible to be
+ * declared dead.
+ *
+ * Deliberately far longer than the 300s deploy lease in store.ts, because the two
+ * answer different questions. The lease decides who may broadcast next, and being
+ * early there costs a wasted claim. This decides whether a transaction that left
+ * the deployer is gone, and being early here means broadcasting a second one — so
+ * it waits, and then still refuses to act on age alone (see settleEvmDeploy).
+ */
+export const DEPLOY_DROP_AFTER_MS = 900_000
+
+/** What settling a broadcast deploy concluded. Drives the log line and the copy. */
+export type DeploySettlementOutcome = 'deployed' | 'reverted' | 'dropped' | 'mined_unresolved'
+
+export interface DeploySettlement {
+ outcome: DeploySettlementOutcome
+ /** Applied to the order row verbatim by the caller. */
+ patch: {
+ status: 'deployed' | 'failed'
+ contractAddress?: string | null
+ explorerUrl?: string | null
+ txHash?: string | null
+ deployNonce?: number | null
+ lastFailedTxHash?: string | null
+ lastFailedOutcome?: string | null
+ }
+ /** One sentence, safe to hand to the customer. */
+ detail: string
+}
+
/**
* Resolve a deploy tx that was broadcast but whose receipt we never saw. Returns
* null while it is still pending; the order stays put and can be polled again.
+ *
+ * CF-21. This used to return `{status:'failed'}` on a revert and `null` on a
+ * missing receipt, and in both cases left `txHash` set — which is the exact
+ * condition /deploy refuses with `deploy_in_flight` and claimDeploy() refuses
+ * with `txHash IS NULL`. So `'failed'` in DEPLOYABLE readmitted every failure
+ * that happened *before* the broadcast (a refused gas estimate, a vault refusal,
+ * an RPC outage) and none of the two that happen after it. A customer whose
+ * transaction reverted, or was evicted from the mempool and never mined, had
+ * spent their Shards, sent gas to the deployer, held no token, and had no route
+ * back: every /deploy answered 409 and every /status left the row exactly where
+ * it was. The only remedy was an operator running
+ * `UPDATE token_orders SET tx_hash = NULL` by hand.
+ *
+ * Both dead cases now clear `txHash` (keeping the hash in `lastFailedTxHash`, so
+ * the customer does not lose the link to their gas) and leave the order `failed`,
+ * which is re-claimable.
+ *
+ * Clearing it is safe in a way worth writing down. The deployer is minted fresh
+ * per order and the vault will sign exactly one shape of transaction from it, so
+ * the only transaction that can ever occupy this nonce is this order's deploy. If
+ * the "dropped" transaction turns out to be alive on some node we did not ask,
+ * the retry re-broadcasts at the *same* nonce with the *same* constructor data:
+ * at most one of the two can be included, and whichever it is deploys identical
+ * bytecode to the identical CREATE address. The customer cannot end up with two
+ * contracts, or pay gas twice, from this being wrong.
*/
export async function settleEvmDeploy(
resolved: ResolvedNetwork,
- txHash: string,
-): Promise<{ status: 'deployed' | 'failed'; contractAddress?: string; explorerUrl?: string } | null> {
+ attempt: {
+ txHash: string
+ deployerAddress: string | null
+ /** The nonce `txHash` occupies; null on rows broadcast before it was recorded. */
+ nonce: number | null
+ /** When this attempt claimed the order; null on rows predating the lease. */
+ startedAt: Date | null
+ },
+): Promise {
const provider = new ethers.JsonRpcProvider(resolved.rpcUrl)
try {
- const receipt = await provider.getTransactionReceipt(txHash)
- if (!receipt) return null
- if (receipt.status !== 1 || !receipt.contractAddress) return { status: 'failed' }
+ const receipt = await provider.getTransactionReceipt(attempt.txHash)
+
+ if (receipt) {
+ if (receipt.status === 1 && receipt.contractAddress) {
+ return {
+ outcome: 'deployed',
+ patch: {
+ status: 'deployed',
+ contractAddress: receipt.contractAddress,
+ explorerUrl: addressExplorerUrl(resolved, receipt.contractAddress),
+ },
+ detail: `Deployed at ${receipt.contractAddress}.`,
+ }
+ }
+ if (receipt.status === 1) {
+ // Mined successfully and yet named no contract. Nothing this service
+ // sends can do that — it only ever broadcasts creations — so something
+ // is on chain that we cannot account for. Do NOT clear txHash: unlike a
+ // revert, we cannot say the customer got nothing for their gas, and a
+ // retry would deploy a second contract next to a first one nobody has
+ // identified. This is the case the deploy-failure log means by "needs a
+ // human, not a retry".
+ return {
+ outcome: 'mined_unresolved',
+ patch: { status: 'failed' },
+ detail:
+ 'The deploy transaction was mined but reported no contract address. Support has to look at this one — do not deploy again until they have.',
+ }
+ }
+ // Reverted. The nonce is spent, so a retry takes the next one and cannot
+ // collide with this transaction; the gas, however, is gone.
+ return {
+ outcome: 'reverted',
+ patch: {
+ status: 'failed',
+ txHash: null,
+ deployNonce: null,
+ lastFailedTxHash: attempt.txHash,
+ lastFailedOutcome: 'reverted',
+ },
+ detail:
+ 'The deploy transaction reverted on chain. The gas it used was spent; whatever is left in the deployer can pay for another attempt.',
+ }
+ }
+
+ // No receipt. Three things have to hold before we will call it dropped, and
+ // any one of them failing leaves the order exactly where it is.
+
+ // 1. It is old enough that a normal confirmation would have happened. A row
+ // with no startedAt predates the lease column, so it is old by definition
+ // — the same reading claimDeploy() gives it.
+ if (attempt.startedAt && Date.now() - attempt.startedAt.getTime() < DEPLOY_DROP_AFTER_MS) {
+ return null
+ }
+
+ // 2. The node no longer holds it. This is what "dropped" actually means; a
+ // transaction still sitting in the mempool during a fee spike is pending,
+ // not dead, however long it has been there.
+ const pending = await provider.getTransaction(attempt.txHash)
+ if (pending) return null
+
+ // 3. Nothing has consumed its nonce. The decisive one: if `latest` has moved
+ // past the nonce this transaction occupies then something from this
+ // deployer mined, and we must not assume it was not this. Without a
+ // recorded nonce we compare against 0, which is the nonce a per-order
+ // deployer's first and only transaction uses.
+ if (!attempt.deployerAddress) return null
+ const expectedNonce = attempt.nonce ?? 0
+ const minedNonce = await provider.getTransactionCount(attempt.deployerAddress, 'latest')
+ if (minedNonce > expectedNonce) return null
+
return {
- status: 'deployed',
- contractAddress: receipt.contractAddress,
- explorerUrl: addressExplorerUrl(resolved, receipt.contractAddress),
+ outcome: 'dropped',
+ patch: {
+ status: 'failed',
+ txHash: null,
+ deployNonce: null,
+ lastFailedTxHash: attempt.txHash,
+ lastFailedOutcome: 'dropped',
+ },
+ detail:
+ 'The deploy transaction was dropped before it was mined — it is gone from the network and its gas was never spent. Deploy again to broadcast a replacement.',
}
} finally {
provider.destroy()
@@ -50,7 +188,15 @@ export async function settleEvmDeploy(
export async function deployErc20Evm(input: {
resolved: ResolvedNetwork
+ /** Vault-held, funded by the customer, pays gas and receives nothing. */
deployerAddress: string
+ /**
+ * CF-01 — the customer's own address. Receives the initial supply and, on the
+ * tiers that have an owner, the contract ownership. Required, and separate
+ * from `deployerAddress` on purpose: the defect this ends was one identifier
+ * being used for both roles.
+ */
+ ownerAddress: string
orderId: string
offerId: string
name: string
@@ -58,8 +204,13 @@ export async function deployErc20Evm(input: {
decimals: number
supply: string
cap: string | null
- /** Called with the tx hash as soon as it is broadcast, before it is mined. */
- onBroadcast?: (txHash: string) => Promise
+ /**
+ * Called with the tx hash as soon as it is broadcast, before it is mined. The
+ * nonce goes with it: it is the only record of which slot this transaction
+ * occupies, and settleEvmDeploy() cannot tell a dropped transaction from a
+ * mined one without it (CF-21).
+ */
+ onBroadcast?: (txHash: string, nonce: number) => Promise
ctx: CallContext
}): Promise {
const { resolved, deployerAddress, ctx } = input
@@ -72,8 +223,12 @@ export async function deployErc20Evm(input: {
decimals: input.decimals,
supply: input.supply,
cap: input.cap,
- // The customer's own funded deployer receives the supply and ownership.
- owner: deployerAddress,
+ // CF-01 — the customer's address, not `deployerAddress`. This one
+ // substitution is the whole fix: the supply and the ownership go to a key
+ // the customer holds, and the vault-held deployer is left doing the only
+ // thing its `creation` transaction shape can safely do, which is pay for
+ // this transaction and then hold nothing anyone wants.
+ owner: input.ownerAddress,
})
// 'pending' so a retry after a broadcast that already landed in the mempool
@@ -150,7 +305,7 @@ export async function deployErc20Evm(input: {
// Record the hash the instant it is on the wire. Everything below can throw,
// and without this a timed-out wait would leave a live pending deploy that the
// order has no record of — so a retry would pay gas to deploy a second one.
- await input.onBroadcast?.(sent.hash)
+ await input.onBroadcast?.(sent.hash, nonce)
// Wait for the receipt rather than trusting the precomputed CREATE address —
// a reverted deploy must not be recorded as a live contract.
diff --git a/services/forge-mint/src/chain/networks.ts b/services/forge-mint/src/chain/networks.ts
index 7b876b8..8db0742 100644
--- a/services/forge-mint/src/chain/networks.ts
+++ b/services/forge-mint/src/chain/networks.ts
@@ -1,5 +1,4 @@
import { SUPPORTED_CHAINS, type ChainNetwork, type SupportedChain } from '@cloudsforge/shared'
-import { env } from '../env.js'
export interface ResolvedNetwork {
chain: SupportedChain
@@ -17,17 +16,25 @@ export function findChain(chainId: string): SupportedChain | undefined {
/**
* Resolve the concrete RPC + explorer + numeric chainId for a chain/network.
- * RPC precedence: env `RPC__` override → baked public default
- * → generic EVM_RPC_URL/SOLANA_RPC_URL fallback (testnet only).
+ * RPC precedence: env `RPC__` override → baked public default.
+ *
+ * There used to be a third arm — a generic `EVM_RPC_URL`/`SOLANA_RPC_URL`
+ * consulted when `baked` was empty — and it could never fire, because every
+ * entry in `SUPPORTED_CHAINS` carries a non-empty `mainnetRpc` and `testnetRpc`
+ * (`forgemint.ts:46-117`). It was not merely dead: it was the only RPC knob
+ * `.env.example` offered, so an operator being rate-limited by a public node
+ * set it, saw no change, and had no way to tell that the knob was inert. CF-43
+ * removed it rather than wiring it up, because one `EVM_RPC_URL` cannot serve
+ * five distinct EVM chains — giving it precedence would route a Polygon or BSC
+ * deploy at an Ethereum endpoint, with real gas on mainnet. The per-chain
+ * override below is the knob that works and the only one documented now.
*/
export function resolveNetwork(chain: SupportedChain, network: ChainNetwork): ResolvedNetwork {
const isMainnet = network === 'mainnet'
const overrideKey = `RPC_${chain.id.toUpperCase()}_${network.toUpperCase()}`
const baked = isMainnet ? chain.mainnetRpc : chain.testnetRpc
- const genericFallback = chain.family === 'evm' ? env.evmRpcUrl : env.solanaRpcUrl
- const rpcUrl =
- process.env[overrideKey]?.trim() || baked || (isMainnet ? baked : genericFallback)
+ const rpcUrl = process.env[overrideKey]?.trim() || baked
return {
chain,
diff --git a/services/forge-mint/src/chain/solana.ts b/services/forge-mint/src/chain/solana.ts
index 575bb05..eb11d72 100644
--- a/services/forge-mint/src/chain/solana.ts
+++ b/services/forge-mint/src/chain/solana.ts
@@ -35,10 +35,54 @@ export interface SolanaDeployResult {
}
/**
- * Create an SPL mint (fixed supply) with the vault-held deployer as fee payer +
- * mint/freeze authority. The ephemeral mint account keypair is generated here
- * and partial-signs; ForgeKeyvault adds the payer signature. The deployer's
- * private key never leaves the vault.
+ * CF-01 — can an SPL mint be handed to the customer? Not today; see
+ * `deploySplToken` for exactly what has to change first.
+ *
+ * A source constant and NOT an environment variable, for the same reason the
+ * suspension is not one: no operator can make this true by setting something.
+ * It is written as a `boolean` rather than left to infer `false` so the deploy
+ * below stays live, typechecked code — this is a two-repo fix and the half that
+ * lives here must not rot while the other half is written.
+ */
+const SPL_DELIVERABLE: boolean = false
+
+/**
+ * CF-01 — THIS PATH IS CLOSED AND DOES NOT RUN.
+ *
+ * It creates an SPL mint whose mint and freeze authority is the vault-held
+ * deployer (`createInitializeMint2Instruction(mint, decimals, payer, payer)`
+ * below) and mints the whole supply into the deployer's own associated token
+ * account. The deployer's key never leaves the vault and the vault refuses
+ * `Transfer` (3) and `SetAuthority` (6), so the customer can neither receive the
+ * supply nor ever be given the authority — while the platform keeps a live,
+ * permanent power to inflate their token, because `MintTo` (7) *is* signable.
+ *
+ * The EVM half of CF-01 is fixed by passing the customer's address as a
+ * constructor argument. There is no equivalent move here. Both shapes fail:
+ *
+ * - Customer as mint authority at initialization: correct, and then the deploy
+ * cannot mint the supply it was paid for, because `MintTo` needs that
+ * authority's signature and the vault does not hold the customer's key.
+ * - Deployer as mint authority, supply minted into the customer's ATA (which
+ * the vault would sign — `assertAtaCreation` constrains the funder, not the
+ * owner): the customer holds the tokens, and the platform holds an
+ * unrevocable right to print more of them forever.
+ *
+ * WHAT WOULD OPEN IT: a keyvault signing shape that permits `SetAuthority` (6)
+ * for `MintTokens` and `FreezeAccount` only, only from the deployer, and only to
+ * the address the order records as its owner — after which this function mints
+ * to the customer's ATA and hands both authorities over in the same transaction.
+ * That is `repos/forge-keyvault/.../signing.ts`'s change to make, not this
+ * service's, so ForgeMint stops selling the thing instead of shipping half of
+ * it: `suspended.ts` refuses a Solana order at `POST /tokens` and at `/pay`, and
+ * `POST /tokens/:id/deploy` refuses a non-EVM order outright.
+ *
+ * The refusal is repeated here, at the point of danger, because the gates above
+ * are business rules enforced in route code and this is the line that would
+ * actually mint a customer's token under a key that is not theirs. The body
+ * below is kept intact: it is the transaction the keyvault change has to be
+ * written against, and reconstructing it from a deleted file is how the
+ * mint/freeze authority ends up back on the payer by accident.
*/
export async function deploySplToken(input: {
resolved: ResolvedNetwork
@@ -48,6 +92,12 @@ export async function deploySplToken(input: {
supply: string
ctx: CallContext
}): Promise {
+ if (!SPL_DELIVERABLE) {
+ throw new Error(
+ 'Solana (SPL) deploys are suspended: the mint and freeze authority would be held by ForgeMint’s vault and could never be transferred to you, so the token would not be yours. EVM chains are unaffected. (CF-01)',
+ )
+ }
+
const { resolved, deployerAddress, ctx } = input
const connection = new Connection(resolved.rpcUrl, 'confirmed')
const payer = new PublicKey(deployerAddress)
diff --git a/services/forge-mint/src/db/migrate.ts b/services/forge-mint/src/db/migrate.ts
index df36d3c..b0df9b8 100644
--- a/services/forge-mint/src/db/migrate.ts
+++ b/services/forge-mint/src/db/migrate.ts
@@ -25,5 +25,18 @@ export async function migrate() {
// Deploy lease. NULL on every existing row, which claimDeploy() reads as
// "unclaimed" — the same thing those rows mean today.
await client`ALTER TABLE token_orders ADD COLUMN IF NOT EXISTS deploy_started_at TIMESTAMPTZ`
+ // CF-21. All three NULL on every existing row. NULL deploy_nonce reads as
+ // nonce 0, which is what a per-order deployer's first (and only) transaction
+ // uses, so an order stuck today settles correctly without a backfill.
+ await client`ALTER TABLE token_orders ADD COLUMN IF NOT EXISTS deploy_nonce INTEGER`
+ await client`ALTER TABLE token_orders ADD COLUMN IF NOT EXISTS last_failed_tx_hash TEXT`
+ await client`ALTER TABLE token_orders ADD COLUMN IF NOT EXISTS last_failed_outcome TEXT`
+ // CF-01. NULL on every row created before the customer was asked where the
+ // token should go. Those orders are not deployable — the deploy refuses
+ // rather than minting to the vault-held deployer again — but they are not
+ // stranded either: `POST /tokens/:id/owner` fills it in. Deliberately NOT
+ // backfilled with `deployer_address`, which would write the defect into the
+ // column that exists to end it.
+ await client`ALTER TABLE token_orders ADD COLUMN IF NOT EXISTS owner_address TEXT`
await client`CREATE INDEX IF NOT EXISTS token_orders_user_id_idx ON token_orders (user_id)`
}
diff --git a/services/forge-mint/src/db/schema.ts b/services/forge-mint/src/db/schema.ts
index 442c8fb..a58413e 100644
--- a/services/forge-mint/src/db/schema.ts
+++ b/services/forge-mint/src/db/schema.ts
@@ -18,6 +18,18 @@ export const tokenOrders = pgTable('token_orders', {
supply: text('supply').notNull(),
cap: text('cap'),
status: text('status').notNull().default('awaiting_payment'),
+ /**
+ * CF-01 — the customer's own address, which receives the supply and, on the
+ * tiers that have one, the contract ownership. Collected on `POST /tokens`
+ * and required there; nullable only because every row written before this
+ * column existed has none, and those rows must be able to acquire one
+ * (`POST /tokens/:id/owner`) rather than be undeployable forever.
+ *
+ * NOT the deployer. `deployer_address` is vault-custodied and may sign one
+ * contract creation; this one is a wallet the customer holds the key to and
+ * this service never asks anything of.
+ */
+ ownerAddress: text('owner_address'),
deployerAddress: text('deployer_address'),
contractAddress: text('contract_address'),
txHash: text('tx_hash'),
@@ -25,9 +37,30 @@ export const tokenOrders = pgTable('token_orders', {
/**
* When the current deploy attempt claimed this order. It is the lease that
* keeps two concurrent /deploy calls from both broadcasting and both paying
- * gas — see claimDeploy() in store.ts.
+ * gas — see claimDeploy() in store.ts. It is also the clock the dropped-
+ * transaction settlement measures against (CF-21).
*/
deployStartedAt: timestamp('deploy_started_at', { withTimezone: true }),
+ /**
+ * CF-21 — the account nonce the current `txHash` occupies.
+ *
+ * A transaction that never mined can only be declared dead if nothing has
+ * consumed its nonce, and that is a comparison against a number we otherwise
+ * throw away the moment we sign. NULL means "not recorded": either nothing is
+ * in flight, or the row was broadcast before this column existed, in which
+ * case settleEvmDeploy() falls back to nonce 0 — correct, because the vault
+ * mints a fresh deployer per order and the deploy is the only thing it signs.
+ */
+ deployNonce: integer('deploy_nonce'),
+ /**
+ * CF-21 — the hash of the last deploy transaction resolved as dead (reverted,
+ * or dropped from the mempool and never mined), cleared out of `txHash` so a
+ * retry is possible again. Kept because it is the customer's only link to what
+ * happened to the gas they sent, and support's only handle on it.
+ */
+ lastFailedTxHash: text('last_failed_tx_hash'),
+ /** CF-21 — 'reverted' (gas was spent) or 'dropped' (it was not). */
+ lastFailedOutcome: text('last_failed_outcome'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
diff --git a/services/forge-mint/src/env.ts b/services/forge-mint/src/env.ts
index 9422db8..c320371 100644
--- a/services/forge-mint/src/env.ts
+++ b/services/forge-mint/src/env.ts
@@ -99,8 +99,14 @@ export const env = (() => {
payApiUrl: process.env.PAY_API_URL ?? 'http://localhost:4003',
keyvaultUrl: process.env.KEYVAULT_URL ?? 'http://localhost:4005',
keyvaultServiceToken: requireSecret('KEYVAULT_SERVICE_TOKEN'),
- evmRpcUrl: process.env.EVM_RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com',
- solanaRpcUrl: process.env.SOLANA_RPC_URL ?? 'https://api.devnet.solana.com',
+ // No generic RPC URL lives here. `EVM_RPC_URL`/`SOLANA_RPC_URL` used to,
+ // and could never take effect (CF-43): every chain in SUPPORTED_CHAINS
+ // bakes its own default, so the fallback that read them was unreachable.
+ // The RPC an operator can actually change is the per-chain
+ // `RPC__`, read straight from process.env by
+ // `resolveNetwork` — deliberately not surfaced through this object,
+ // because there is one variable per chain per network and enumerating
+ // them here would go stale the next time SUPPORTED_CHAINS grows.
// A mainnet deploy spends the customer's real gas on an irreversible
// contract, and `confirmMainnet` is a checkbox rather than an authorisation
// — every signed-up user can tick it. So the capability itself ships OFF
diff --git a/services/forge-mint/src/index.ts b/services/forge-mint/src/index.ts
index e5c575d..715301d 100644
--- a/services/forge-mint/src/index.ts
+++ b/services/forge-mint/src/index.ts
@@ -11,6 +11,7 @@ import { fastifyOptions, installErrorHandling, installProcessHandlers, step } fr
import { migrate } from './db/migrate.js'
import { requireAuth } from './auth.js'
import { mainnetAllowed } from './mainnet.js'
+import { chainSuspension, isChainSuspended, offerSuspension } from './suspended.js'
import { tokenRoutes } from './routes/tokens.js'
const app = Fastify(fastifyOptions('forge-mint'))
@@ -53,10 +54,54 @@ function warnIfCatalogOversellsChains() {
}
warnIfCatalogOversellsChains()
+/**
+ * CF-01 — say at boot which chains are closed and why. An operator who sees
+ * ForgeMint refuse a whole chain should find the reason in the service's first
+ * ten log lines, not by reading route code. This is a warning and not a fatal on
+ * purpose: the service still serves the catalog, every EVM order, every
+ * already-paid order and every status poll. See suspended.ts.
+ */
+function warnChainsSuspended() {
+ const suspended = SUPPORTED_CHAINS.filter((c) => isChainSuspended(c.id))
+ if (suspended.length === 0) return
+ app.log.warn(
+ { chains: suspended.map((c) => c.id), ticket: 'CF-01' },
+ 'no order can be taken on these chains: the token’s mint authority would stay with the vault-custodied deployer and cannot be handed to the customer',
+ )
+}
+warnChainsSuspended()
+
// Public routes.
app.get('/health', async () => ({ ok: true, service: 'forge-mint' }))
-app.get('/chains', async () => SUPPORTED_CHAINS)
-app.get('/offers', async () => MINT_OFFERS)
+// CF-01 — every chain is served, carrying whether an order may be placed on it.
+// Solana cannot deliver the mint authority to the customer, so it is refused at
+// POST /tokens; a chain that silently disappeared from the picker would read as
+// an outage, and the storefront needs the sentence to show next to it.
+// Presentation only — the route refuses whatever the form sends.
+app.get('/chains', async () =>
+ SUPPORTED_CHAINS.map((chain) => {
+ const suspension = chainSuspension(chain.id)
+ return {
+ ...chain,
+ suspended: suspension !== null,
+ suspendedReason: suspension?.error ?? null,
+ }
+ }),
+)
+// The catalog carries `suspended` per tier for the same reason, but a tier is
+// suspended only when EVERY chain it could target is — which is none of them
+// today, because all three sell ERC-20. Presentation only — POST /tokens refuses
+// the order whatever the form sends.
+app.get('/offers', async () =>
+ MINT_OFFERS.map((offer) => {
+ const suspension = offerSuspension(offer.id)
+ return {
+ ...offer,
+ suspended: suspension !== null,
+ suspendedReason: suspension?.error ?? null,
+ }
+ }),
+)
// What this caller may actually do, so the UI offers mainnet only to accounts
// that can use it rather than presenting it and failing at the last step.
diff --git a/services/forge-mint/src/owner.ts b/services/forge-mint/src/owner.ts
new file mode 100644
index 0000000..17a1332
--- /dev/null
+++ b/services/forge-mint/src/owner.ts
@@ -0,0 +1,97 @@
+import { ethers } from 'ethers'
+import { PublicKey } from '@solana/web3.js'
+import type { SupportedChain } from '@cloudsforge/shared'
+
+/**
+ * CF-01 — the address the token is actually for.
+ *
+ * Every deploy used to mint the supply, and on two tiers the ownership, to
+ * `deployerAddress`: a keyvault-held EOA under the `creation` transaction shape,
+ * which may sign a contract creation and nothing else. The customer funded it
+ * with gas and could never move a token out of it. The order now carries the
+ * customer's own address and the constructor receives that instead, so the
+ * deployer signs the creation, pays the gas, and holds nothing worth holding —
+ * which is the only thing the `creation` shape was ever safe for.
+ *
+ * This is a customer-supplied string that ends up permanently encoded in
+ * on-chain constructor arguments, so it is validated as hard as it can be at the
+ * only moment anyone can still fix a typo. What that can and cannot catch is
+ * worth being honest about: an address is well-formed or not, and no amount of
+ * checking here can tell whether the customer holds its key. The checksum is
+ * what does the real work — a mistyped character in an EIP-55 address fails it
+ * — and it is why a *mixed-case* address is required to checksum correctly
+ * rather than being lowercased and accepted.
+ */
+
+export type OwnerAddressResult = { ok: true; address: string } | { ok: false; error: string }
+
+/**
+ * The one address on every EVM chain from which nothing can ever be recovered.
+ * Sending a token's whole supply there is not a preference this service has to
+ * respect; it is indistinguishable from a mistake and it is unfixable.
+ */
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+
+/**
+ * Validate and normalise the customer's owner address for a chain.
+ *
+ * Returns the address in the form the chain writes it — EIP-55 checksummed for
+ * EVM, base58 for Solana — so the row, the constructor argument and what the
+ * customer is shown back all agree byte for byte.
+ */
+export function normalizeOwnerAddress(chain: SupportedChain, raw: unknown): OwnerAddressResult {
+ if (typeof raw !== 'string' || raw.trim() === '') {
+ return {
+ ok: false,
+ error: `ownerAddress is required: give the ${chain.name} address that should receive the token`,
+ }
+ }
+ const value = raw.trim()
+
+ if (chain.family === 'evm') {
+ // isAddress() before getAddress(): getAddress throws on a bad checksum and
+ // the thrown message is ethers' own, which names an internal error code the
+ // customer has no use for.
+ if (!ethers.isAddress(value)) {
+ return {
+ ok: false,
+ error:
+ 'ownerAddress is not a valid EVM address — it must be 40 hex characters, and a mixed-case one must have a valid EIP-55 checksum (check you copied all of it)',
+ }
+ }
+ const address = ethers.getAddress(value)
+ if (address === ZERO_ADDRESS) {
+ return {
+ ok: false,
+ error:
+ 'ownerAddress must not be the zero address — the token’s entire supply would be sent somewhere no one can ever recover it from',
+ }
+ }
+ return { ok: true, address }
+ }
+
+ // Solana. Unreachable through `POST /tokens` while SPL orders are suspended
+ // (suspended.ts), and written anyway: the suspension is a business gate that
+ // one keyvault ticket lifts, and an address rule that only exists once the
+ // gate is open is an address rule nobody has tested.
+ let key: PublicKey
+ try {
+ key = new PublicKey(value)
+ } catch {
+ return { ok: false, error: 'ownerAddress is not a valid Solana address (base58, 32 bytes)' }
+ }
+ // A public key that is NOT on the ed25519 curve is a program-derived address:
+ // no private key exists for it, by construction. As the recipient of an SPL
+ // balance that is the same total loss the deployer was, so it is refused —
+ // even though a PDA owned by a program the customer controls is a legitimate
+ // thing to exist, it is not a thing this form can tell apart from a paste of
+ // the wrong field.
+ if (!PublicKey.isOnCurve(key.toBytes())) {
+ return {
+ ok: false,
+ error:
+ 'ownerAddress is a program-derived address, which has no private key — give a wallet address you can sign with',
+ }
+ }
+ return { ok: true, address: key.toBase58() }
+}
diff --git a/services/forge-mint/src/routes/tokens.ts b/services/forge-mint/src/routes/tokens.ts
index b4cc24b..016a1c9 100644
--- a/services/forge-mint/src/routes/tokens.ts
+++ b/services/forge-mint/src/routes/tokens.ts
@@ -1,14 +1,25 @@
-import type { FastifyInstance } from 'fastify'
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
import { ethers } from 'ethers'
-import { MINT_OFFERS, createTokenOrderSchema, type TokenOrder } from '@cloudsforge/shared'
+import { MINT_OFFERS, createTokenOrderSchema } from '@cloudsforge/shared'
import { requireAuth } from '../auth.js'
import { spendShards } from '../clients/pay.js'
import { createAddress, VaultRefused } from '../clients/keyvault.js'
-import { findChain, resolveNetwork, txExplorerUrl } from '../chain/networks.js'
+import { findChain, resolveNetwork, txExplorerUrl, type ResolvedNetwork } from '../chain/networks.js'
import { deployErc20Evm, getEvmBalanceWei, settleEvmDeploy } from '../chain/evm.js'
import { deploySplToken, getSolanaBalanceLamports } from '../chain/solana.js'
-import { claimDeploy, getOwnedOrder, insertOrder, listOrders, updateOrder } from '../store.js'
+import {
+ applyDeploySettlement,
+ claimDeploy,
+ getDeployAttempt,
+ getOwnedOrder,
+ insertOrder,
+ listOrders,
+ updateOrder,
+ type MintTokenOrder,
+} from '../store.js'
import { mainnetDenial } from '../mainnet.js'
+import { normalizeOwnerAddress } from '../owner.js'
+import { chainSuspension } from '../suspended.js'
import { safeUrl } from '../obs.js'
// Testnet faucet hints returned after provisioning so users can fund the deployer.
@@ -22,7 +33,7 @@ const TESTNET_FAUCETS: Record = {
}
/** Is the deployer funded enough to pay gas? Reads the order's network RPC. */
-async function readBalance(order: TokenOrder): Promise<{ funded: boolean; display: string }> {
+async function readBalance(order: MintTokenOrder): Promise<{ funded: boolean; display: string }> {
const chain = findChain(order.chain)!
const resolved = resolveNetwork(chain, order.network)
if (!order.deployerAddress) return { funded: false, display: '0' }
@@ -34,6 +45,88 @@ async function readBalance(order: TokenOrder): Promise<{ funded: boolean; displa
return { funded: lamports > 0, display: `${lamports / 1e9} SOL` }
}
+/**
+ * Resolve an order that carries a broadcast we never saw settle, and return it
+ * as the settlement left it.
+ *
+ * CF-21 — this runs on BOTH read and retry. On `GET /status` it is what turns a
+ * mined deploy into `deployed`; on `POST /deploy` it is what stops the retry
+ * being refused forever, because the two dead outcomes clear `txHash` and the
+ * `deploy_in_flight` guard immediately below the call no longer matches.
+ *
+ * Never throws. An RPC that will not answer means we do not know yet, which is
+ * the same answer as "still pending" and leaves the order untouched.
+ */
+async function settleInFlightDeploy(
+ order: MintTokenOrder,
+ resolved: ResolvedNetwork,
+ ctx: { log: FastifyBaseLogger; requestId: string },
+): Promise {
+ if (!order.txHash || order.contractAddress || resolved.chain.family !== 'evm') return order
+ try {
+ const attempt = await getDeployAttempt(order.id)
+ const settled = await settleEvmDeploy(resolved, {
+ txHash: order.txHash,
+ deployerAddress: order.deployerAddress,
+ nonce: attempt?.nonce ?? null,
+ startedAt: attempt?.startedAt ?? null,
+ })
+ if (!settled) return order
+ // Conditioned on the hash we settled, never a bare updateOrder: this
+ // function is reached from a polled GET and a clicked POST at the same
+ // time, and it decided what to write several RPC round-trips ago. If the
+ // row has moved on since — another request settled the same broadcast, or
+ // claimed the order and put a new one on the wire — this conclusion is
+ // about a transaction the order no longer has, and applying it would undo
+ // their claim or erase their txHash. See applyDeploySettlement().
+ const applied = await applyDeploySettlement(order.id, order.txHash, settled.patch)
+ if (!applied) {
+ ctx.log.warn(
+ { orderId: order.id, txHash: order.txHash, outcome: settled.outcome },
+ 'in-flight deploy settled a transaction the order no longer carries — discarding',
+ )
+ // Re-read rather than returning the snapshot: the caller is about to judge
+ // this row, and whatever the winner wrote is the truth to judge it by.
+ return (await getOwnedOrder(order.id, order.userId)) ?? order
+ }
+ const updated = applied
+ // 'deployed' is the happy path and needs no line of its own — the request
+ // log has it. The other three each mean a customer is now looking at an
+ // order that went nowhere, and 'dropped' in particular means this service
+ // just cleared a transaction hash it had recorded as broadcast, which is the
+ // one write here that would be alarming to find unexplained.
+ if (settled.outcome !== 'deployed') {
+ ctx.log.warn(
+ {
+ orderId: order.id,
+ userId: order.userId,
+ chain: order.chain,
+ network: order.network,
+ rpcUrl: safeUrl(resolved.rpcUrl),
+ deployerAddress: order.deployerAddress,
+ txHash: order.txHash,
+ nonce: attempt?.nonce ?? null,
+ outcome: settled.outcome,
+ retryable: updated.txHash === null,
+ realFunds: resolved.isMainnet,
+ },
+ `in-flight deploy settled as ${settled.outcome}`,
+ )
+ }
+ return updated
+ } catch (err) {
+ // Still pending or RPC down — leave the order as it is; the next poll
+ // settles it. Warn rather than error: this is the expected path while a
+ // deploy is in flight, but an order that never leaves it is a broadcast
+ // transaction we have permanently lost track of.
+ ctx.log.warn(
+ { err, orderId: order.id, txHash: order.txHash, network: order.network, rpcUrl: safeUrl(resolved.rpcUrl) },
+ 'could not settle in-flight deploy',
+ )
+ return order
+ }
+}
+
export async function tokenRoutes(app: FastifyInstance) {
// POST /tokens — validate offer allows chain/standard, create awaiting_payment order.
app.post('/tokens', { preHandler: requireAuth }, async (request, reply) => {
@@ -70,6 +163,34 @@ export async function tokenRoutes(app: FastifyInstance) {
// second chain is a second order; assertOneChainPerOrder() in index.ts
// shouts at boot if the catalog ever goes back to selling more than that.
+ // CF-01 — an SPL mint cannot be handed to the customer: its mint authority
+ // is fixed at creation and the vault refuses the only instruction that could
+ // move it. Refused before the order row exists, for the same reason the
+ // mainnet gate below is here rather than at /deploy — an order that can only
+ // end in non-delivery should never reach the screen with the Pay button on
+ // it. The EVM chains are not suspended: `ownerAddress` below is what makes
+ // them deliverable. See suspended.ts.
+ const suspension = chainSuspension(chain.id)
+ if (suspension) {
+ return reply.code(403).send({ ...suspension, requestId: request.id })
+ }
+
+ // CF-01 — where the token goes: the customer's own address, which becomes
+ // the constructor's `recipient_`/`owner_`. Checked last of all, on purpose:
+ // a caller whose chain is refused outright should hear that, rather than a
+ // complaint about a field on a form they are never going to be shown.
+ //
+ // Validated here rather than in the shared zod schema for two reasons: the
+ // rule depends on the chain's family, which is only known once `chain`
+ // resolves; and `createTokenOrderSchema` lives in @cloudsforge/shared, so
+ // requiring it there is a published-package change this service must not
+ // wait on. The schema strips unknown keys, so the field is read off the raw
+ // body.
+ const owner = normalizeOwnerAddress(chain, (request.body as { ownerAddress?: unknown })?.ownerAddress)
+ if (!owner.ok) {
+ return reply.code(400).send({ error: owner.error, code: 'validation', requestId: request.id })
+ }
+
// Mainnet is gated before the order exists, not just before the deploy: the
// customer pays Shards at /pay, two steps earlier, and taking money for an
// order that will be refused at the last step is the worst possible place to
@@ -91,6 +212,7 @@ export async function tokenRoutes(app: FastifyInstance) {
decimals,
supply,
cap: cap ?? null,
+ ownerAddress: owner.address,
})
return reply.code(201).send(order)
})
@@ -121,6 +243,16 @@ export async function tokenRoutes(app: FastifyInstance) {
if (order.status !== 'awaiting_payment') {
return reply.code(409).send({ error: `order is '${order.status}', not awaiting_payment`, code: 'conflict', requestId: request.id })
}
+ // CF-01 — every order created before the suspension shipped is still
+ // sitting in awaiting_payment with a live Pay button in front of it, and
+ // the gate on POST /tokens does nothing for those. This is the line that
+ // actually stops the Shards moving. Read from the order's own chain, so a
+ // Solana order created while SPL was still on sale is caught here too.
+ // See suspended.ts.
+ const suspension = chainSuspension(order.chain)
+ if (suspension) {
+ return reply.code(403).send({ ...suspension, requestId: request.id })
+ }
const offer = MINT_OFFERS.find((o) => o.id === order.offerId)!
const result = await spendShards(
@@ -142,6 +274,69 @@ export async function tokenRoutes(app: FastifyInstance) {
},
)
+ // POST /tokens/:id/owner — set (or correct) where the token should be sent.
+ //
+ // CF-01. Two callers. The first is every order created before `ownerAddress`
+ // existed: they carry NULL, the deploy refuses them rather than minting to the
+ // vault-held deployer again, and without this route the ones that were already
+ // paid for would be exactly the "row in a state no route can leave" shape the
+ // suspension was written to avoid. The second is a customer who mistyped, or
+ // who has since moved wallets — the address is only consequential at the
+ // moment of the deploy, and it is encoded in the contract forever afterwards,
+ // so before that moment it should be as easy to change as possible.
+ //
+ // It closes for good once the deploy is on the wire: `txHash` set (a broadcast
+ // whose constructor args are already signed) or status `deployed`/`deploying`
+ // all mean the address the contract will carry is decided.
+ app.post<{ Params: { id: string }; Body: { ownerAddress?: unknown } }>(
+ '/tokens/:id/owner',
+ { preHandler: requireAuth },
+ async (request, reply) => {
+ const order = await getOwnedOrder(request.params.id, request.user!.sub)
+ if (!order) return reply.code(404).send({ error: 'order not found', code: 'not_found', requestId: request.id })
+ if (order.status === 'deployed' || order.contractAddress) {
+ return reply.code(409).send({
+ error:
+ 'this token is already deployed — the owner address is a constructor argument and is fixed in the contract. Transfer it from the wallet that holds it.',
+ code: 'conflict',
+ requestId: request.id,
+ })
+ }
+ if (order.status === 'deploying' || order.txHash) {
+ return reply.code(409).send({
+ error:
+ 'a deploy for this order is in flight and its owner address is already signed into the transaction — wait for it to settle',
+ code: 'deploy_in_flight',
+ requestId: request.id,
+ })
+ }
+ const chain = findChain(order.chain)
+ if (!chain) return reply.code(404).send({ error: 'unknown chain', code: 'not_found', requestId: request.id })
+
+ const owner = normalizeOwnerAddress(chain, request.body?.ownerAddress)
+ if (!owner.ok) {
+ return reply.code(400).send({ error: owner.error, code: 'validation', requestId: request.id })
+ }
+ const updated = await updateOrder(order.id, { ownerAddress: owner.address })
+ request.log.info(
+ {
+ orderId: order.id,
+ userId: order.userId,
+ chain: order.chain,
+ network: order.network,
+ status: order.status,
+ // The old value, because this is the one field whose being wrong costs
+ // the customer the entire token, and "it used to say what?" is the
+ // first question support will have.
+ previousOwnerAddress: order.ownerAddress,
+ ownerAddress: owner.address,
+ },
+ 'order owner address set',
+ )
+ return updated
+ },
+ )
+
// POST /tokens/:id/provision — mint a per-address deployer via the vault.
app.post<{ Params: { id: string } }>(
'/tokens/:id/provision',
@@ -152,6 +347,32 @@ export async function tokenRoutes(app: FastifyInstance) {
if (order.status !== 'paid') {
return reply.code(409).send({ error: `order is '${order.status}', not paid`, code: 'conflict', requestId: request.id })
}
+
+ // CF-01 — refuse to provision a deployer for a chain whose deploy is
+ // permanently refused. This route is the only one of the three that takes
+ // no money and delivers nothing, and it is the one that hands out a
+ // funding address together with "send gas here, then call /deploy". For a
+ // suspended chain that instruction is the harm itself: gas sent to a
+ // deployer never comes back — the vault signs contract creations and
+ // nothing else, so no transaction can move it out — and the deploy it
+ // would pay for returns a permanent 409 below.
+ //
+ // This does NOT confiscate anything. The order is already `paid` and stays
+ // `paid`; the money was spent before the gate existed and there is no
+ // refund path back to forge-pay (MAP.md §8) either way. What changes is
+ // only that the customer is not walked one step further into spending real
+ // native currency on a deploy that cannot happen. The SPA hides the step
+ // (Order.tsx renders the paused panel instead), and this is the same
+ // refusal at the API, so a direct caller lands where the screen does.
+ const provisionSuspension = chainSuspension(order.chain)
+ if (provisionSuspension) {
+ request.log.warn(
+ { orderId: order.id, userId: order.userId, chain: order.chain, network: order.network },
+ 'provision refused: this chain cannot deliver the token to the customer (CF-01)',
+ )
+ return reply.code(409).send({ ...provisionSuspension, requestId: request.id })
+ }
+
await updateOrder(order.id, { status: 'provisioning' })
try {
const { address } = await createAddress(
@@ -199,30 +420,85 @@ export async function tokenRoutes(app: FastifyInstance) {
'/tokens/:id/deploy',
{ preHandler: requireAuth },
async (request, reply) => {
- const order = await getOwnedOrder(request.params.id, request.user!.sub)
+ let order = await getOwnedOrder(request.params.id, request.user!.sub)
if (!order) return reply.code(404).send({ error: 'order not found', code: 'not_found', requestId: request.id })
if (!order.deployerAddress) {
return reply.code(409).send({ error: 'order not provisioned yet', code: 'conflict', requestId: request.id })
}
+ // Held separately because `order` is reassigned by the settlement below and
+ // the narrowing does not survive that. Nothing in this handler re-provisions,
+ // so the address is fixed for the life of the request either way.
+ const deployerAddress = order.deployerAddress
+
+ const chain = findChain(order.chain)!
+ const resolved = resolveNetwork(chain, order.network)
+
+ // CF-21 — settle before judging. The retry path used to read the row and
+ // refuse on `txHash`, which made the 409 permanent for the two outcomes
+ // that can never clear it on their own: a revert, and a transaction the
+ // mempool dropped. Resolving it here costs one receipt lookup on the only
+ // requests that carry an unresolved broadcast, and it is what makes
+ // "check its status before retrying" advice the retry can act on rather
+ // than a loop. `/status` runs the identical call for the same reason.
+ order = await settleInFlightDeploy(order, resolved, { log: request.log, requestId: request.id })
+
if (order.status === 'deployed') return order
if (!['awaiting_funds', 'failed', 'deploying'].includes(order.status)) {
return reply.code(409).send({ error: `order is '${order.status}', cannot deploy`, code: 'conflict', requestId: request.id })
}
- // A previous attempt already put a deploy on the wire. Broadcasting another
- // would pay gas twice and orphan whichever contract loses, so make the
- // customer resolve the first one via /status instead.
+ // A previous attempt already put a deploy on the wire and the settlement
+ // above could not call it dead. Broadcasting another would pay gas twice
+ // and orphan whichever contract loses, so make the customer resolve the
+ // first one via /status instead.
if (order.txHash && !order.contractAddress) {
return reply.code(409).send({
- error: `a deploy transaction (${order.txHash}) was already broadcast for this order — check its status before retrying`,
+ error: `a deploy transaction (${order.txHash}) was already broadcast for this order and has not settled yet — watch it on the explorer, and this retry will be accepted once it mines, reverts, or is dropped`,
code: 'deploy_in_flight',
requestId: request.id,
txHash: order.txHash,
+ txExplorerUrl: txExplorerUrl(resolved, order.txHash),
network: order.network,
})
}
- const chain = findChain(order.chain)!
- const resolved = resolveNetwork(chain, order.network)
+ // CF-01, and deliberately after the settlement and the in-flight guard
+ // above: a legacy order whose deploy is still on the wire has to be
+ // resolved (CF-21) before it is refused for anything, or refusing it here
+ // is how it becomes permanently unsettled.
+ //
+ // A non-EVM order cannot be delivered — an SPL mint's authority is fixed
+ // at creation and the vault will not sign the instruction that moves it —
+ // and chain/solana.ts refuses the same thing at the point of danger. This
+ // is unreachable for any order created since, because /tokens refuses one;
+ // it exists for the paid Solana orders that predate the gate.
+ if (chain.family !== 'evm') {
+ const suspension = chainSuspension(order.chain)
+ if (suspension) {
+ request.log.error(
+ { orderId: order.id, userId: order.userId, chain: order.chain, network: order.network },
+ 'deploy refused: this chain cannot deliver the token to the customer (CF-01)',
+ )
+ return reply.code(409).send({ ...suspension, requestId: request.id })
+ }
+ }
+ // No owner address means this order predates CF-01's fix. Deploying it
+ // would mint the whole supply to `deployerAddress` — the defect itself —
+ // so it is refused, and the refusal names the route that fixes it rather
+ // than leaving the customer somewhere they cannot leave.
+ //
+ // This is the fast refusal on a snapshot, so a legacy order is turned away
+ // without claiming the row or spending an RPC call. It is NOT the value
+ // that reaches the constructor: that is read back off the claimed row
+ // below, for the reason written there.
+ if (!order.ownerAddress) {
+ return reply.code(409).send({
+ error:
+ 'this order has no owner address: it was created before ForgeMint asked where your token should go, and deploying it would mint the entire supply to the vault-held deployer, which cannot send it on. POST /tokens/:id/owner with the wallet address that should receive it, then deploy.',
+ code: 'owner_address_required',
+ requestId: request.id,
+ network: order.network,
+ })
+ }
// Mainnet authorisation, then mainnet confirmation. They are different
// things and the second was standing in for the first: `confirmMainnet`
@@ -300,12 +576,53 @@ export async function tokenRoutes(app: FastifyInstance) {
})
}
+ // CF-01 — the address that goes into the constructor comes off the CLAIMED
+ // row, never off the snapshot read at the top of this handler.
+ //
+ // `POST /tokens/:id/owner` accepts an update for any order that is not
+ // 'deploying' and carries no `txHash`, and everything between that first
+ // read and this claim — the settlement, the mainnet checks, a full RPC
+ // balance read — runs with the owner route still open. A customer who
+ // clicks Deploy and then immediately corrects a mistyped address lands in
+ // that window: the route would 200 and write B to the row while this
+ // request encoded A into the constructor and broadcast it. The contract
+ // would hold the whole supply at A forever while the row, `/status` and
+ // the "Token deployed" screen all told them it was at B — "your wallet".
+ //
+ // `claimDeploy` sets status='deploying' in the same UPDATE that returns
+ // the row, and that status is what closes the owner route, so its return
+ // value is the first and only read of this field that cannot be raced.
+ const ownerAddress = claimed.ownerAddress
+ if (!ownerAddress) {
+ // Unreachable: the snapshot above carried one and nothing clears the
+ // column. Handled rather than asserted because the cost of being wrong
+ // is minting a customer's entire supply to the vault-held deployer, and
+ // the claim has to be given back or the row is stuck in 'deploying'
+ // until the lease expires.
+ await updateOrder(order.id, { status: 'awaiting_funds' })
+ request.log.error(
+ { orderId: order.id, userId: order.userId, chain: order.chain, network: order.network },
+ 'deploy claimed an order whose owner address vanished between read and claim (CF-01)',
+ )
+ return reply.code(409).send({
+ error:
+ 'this order has no owner address: POST /tokens/:id/owner with the wallet address that should receive it, then deploy.',
+ code: 'owner_address_required',
+ requestId: request.id,
+ network: order.network,
+ })
+ }
+
try {
const result =
chain.family === 'evm'
? await deployErc20Evm({
resolved,
- deployerAddress: order.deployerAddress,
+ deployerAddress: deployerAddress,
+ // CF-01 — the customer's address, off the claimed row. It used
+ // to be `deployerAddress`, which is the defect; then it was the
+ // pre-claim snapshot, which is the race documented above.
+ ownerAddress,
orderId: order.id,
offerId: order.offerId,
name: order.name,
@@ -313,14 +630,14 @@ export async function tokenRoutes(app: FastifyInstance) {
decimals: order.decimals,
supply: order.supply,
cap: order.cap,
- onBroadcast: async (txHash) => {
- await updateOrder(order.id, { txHash })
+ onBroadcast: async (txHash, nonce) => {
+ await updateOrder(order.id, { txHash, deployNonce: nonce })
},
ctx: { log: request.log, requestId: request.id },
})
: await deploySplToken({
resolved,
- deployerAddress: order.deployerAddress,
+ deployerAddress: deployerAddress,
orderId: order.id,
decimals: order.decimals,
supply: order.supply,
@@ -351,7 +668,14 @@ export async function tokenRoutes(app: FastifyInstance) {
// transaction may already be on-chain and real gas already spent, on a
// contract no order row claims. `txHash` is read back from the row rather
// than held locally because onBroadcast is what wrote it — if it is set,
- // funds left the deployer and this needs a human, not a retry.
+ // funds left the deployer.
+ //
+ // That used to end "and this needs a human, not a retry", which was true
+ // of every broadcast failure and is now true of only one. CF-21 — the
+ // settler on the next /status or /deploy resolves this row: mined, and
+ // it becomes 'deployed'; reverted or dropped, and `txHash` is cleared so
+ // the retry is admitted. Only a receipt that mined and named no contract
+ // still stops here for a person to look at.
request.log.error(
{
err,
@@ -403,24 +727,10 @@ export async function tokenRoutes(app: FastifyInstance) {
// Settle a deploy whose broadcast outlived its receipt wait. This is the
// recovery path for the 'deploy_in_flight' conflict — without it an order
- // whose tx mined after the timeout would never reach 'deployed'.
- if (order.txHash && !order.contractAddress && chain.family === 'evm') {
- try {
- const settled = await settleEvmDeploy(resolved, order.txHash)
- if (settled) {
- order = (await updateOrder(order.id, settled)) ?? order
- }
- } catch (err) {
- // Still pending or RPC down — leave the order as it is; the next poll
- // settles it. Warn rather than error: this is the expected path while a
- // deploy is in flight, but an order that never leaves it is a broadcast
- // transaction we have permanently lost track of.
- request.log.warn(
- { err, orderId: order.id, txHash: order.txHash, network: order.network, rpcUrl: safeUrl(resolved.rpcUrl) },
- 'could not settle in-flight deploy',
- )
- }
- }
+ // whose tx mined after the timeout would never reach 'deployed', and (CF-21)
+ // one whose tx reverted or was dropped would never become retryable.
+ order = await settleInFlightDeploy(order, resolved, { log: request.log, requestId: request.id })
+ const attempt = await getDeployAttempt(order.id)
let balance = 'unknown'
let funded = false
@@ -440,9 +750,17 @@ export async function tokenRoutes(app: FastifyInstance) {
)
}
}
+ // CF-01 — the order screen has to be able to say why the Pay button is
+ // gone, and it is the only screen an already-created order is reachable
+ // from. Keyed on the order's chain, which is what /pay refuses on, so the
+ // answer comes from the same service and the same rule that would refuse
+ // the payment.
+ const suspension = chainSuspension(order.chain)
return {
order,
network: order.network,
+ suspended: suspension !== null,
+ suspendedReason: suspension?.error ?? null,
// Same reason as above: this is a diagnostic, and the raw URL may carry a
// provider API key. Origin + first path segment is all it was ever read for.
rpcUrl: safeUrl(resolved.rpcUrl),
@@ -456,6 +774,17 @@ export async function tokenRoutes(app: FastifyInstance) {
// reverted, or one still in the mempool, has a tx to look at and no
// contract — which is precisely when a customer wants the link most.
txExplorerUrl: order.txHash ? txExplorerUrl(resolved, order.txHash) : null,
+ // CF-21 — a deploy that reverted or was dropped has had its hash cleared
+ // out of the row so the order can be retried, and without these the
+ // customer's only record of where their gas went disappears with it.
+ // `lastFailedOutcome` is the difference between "that gas is spent" and
+ // "that gas was never touched", which is not a distinction to make them
+ // guess at.
+ lastFailedTxHash: attempt?.lastFailedTxHash ?? null,
+ lastFailedOutcome: attempt?.lastFailedOutcome ?? null,
+ lastFailedTxExplorerUrl: attempt?.lastFailedTxHash
+ ? txExplorerUrl(resolved, attempt.lastFailedTxHash)
+ : null,
}
},
)
diff --git a/services/forge-mint/src/store.ts b/services/forge-mint/src/store.ts
index 51fd4a8..54ea601 100644
--- a/services/forge-mint/src/store.ts
+++ b/services/forge-mint/src/store.ts
@@ -4,7 +4,27 @@ import type { ChainNetwork, TokenOrder, TokenOrderStatus } from '@cloudsforge/sh
import { db } from './db/client.js'
import { tokenOrders, type TokenOrderRow } from './db/schema.js'
-export function toTokenOrder(row: TokenOrderRow): TokenOrder {
+/**
+ * CF-01 — `TokenOrder` plus the address the token is for.
+ *
+ * `@cloudsforge/shared`'s `TokenOrder` is the published wire shape and does not
+ * carry `ownerAddress` yet; adding it there is shared-libs' change to make and
+ * this service cannot wait on another repo to stop minting customers' tokens to
+ * a key they will never hold. Widened here instead, structurally, so every
+ * caller that already types an order keeps working and the field is required
+ * reading for the two that matter — the deploy, and the order screen.
+ *
+ * Note the difference from `DeployAttempt` below, which is internal bookkeeping
+ * deliberately kept OFF the wire: this one is the opposite. It is the single
+ * most important thing on the row from the customer's point of view and it is
+ * sent to them.
+ */
+export interface MintTokenOrder extends TokenOrder {
+ /** The customer's address. NULL only on rows predating CF-01's fix. */
+ ownerAddress: string | null
+}
+
+export function toTokenOrder(row: TokenOrderRow): MintTokenOrder {
return {
id: row.id,
userId: row.userId,
@@ -17,6 +37,7 @@ export function toTokenOrder(row: TokenOrderRow): TokenOrder {
supply: row.supply,
cap: row.cap,
status: row.status as TokenOrderStatus,
+ ownerAddress: row.ownerAddress,
deployerAddress: row.deployerAddress,
contractAddress: row.contractAddress,
txHash: row.txHash,
@@ -35,7 +56,9 @@ export async function insertOrder(input: {
decimals: number
supply: string
cap?: string | null
-}): Promise {
+ /** CF-01 — required here, so no order is created without somewhere to send the token. */
+ ownerAddress: string
+}): Promise {
const id = randomUUID()
const [row] = await db
.insert(tokenOrders)
@@ -44,7 +67,7 @@ export async function insertOrder(input: {
return toTokenOrder(row!)
}
-export async function listOrders(userId: string): Promise {
+export async function listOrders(userId: string): Promise {
const rows = await db
.select()
.from(tokenOrders)
@@ -53,20 +76,64 @@ export async function listOrders(userId: string): Promise {
return rows.map(toTokenOrder)
}
-export async function updateOrder(
- id: string,
- patch: Partial<{
- status: TokenOrderStatus
- deployerAddress: string | null
- contractAddress: string | null
- txHash: string | null
- explorerUrl: string | null
- }>,
-): Promise {
+export type OrderPatch = Partial<{
+ status: TokenOrderStatus
+ ownerAddress: string
+ deployerAddress: string | null
+ contractAddress: string | null
+ txHash: string | null
+ explorerUrl: string | null
+ deployNonce: number | null
+ lastFailedTxHash: string | null
+ lastFailedOutcome: string | null
+}>
+
+export async function updateOrder(id: string, patch: OrderPatch): Promise {
const [row] = await db.update(tokenOrders).set(patch).where(eq(tokenOrders.id, id)).returning()
return row ? toTokenOrder(row) : null
}
+/**
+ * Apply a deploy settlement — but only while the row still carries the exact
+ * `txHash` that settlement is about.
+ *
+ * CF-21. The settler runs on `GET /status` and on `POST /deploy`, both of which
+ * are polled and clicked concurrently, and it reads the hash from a snapshot
+ * taken several RPC round-trips earlier. An unconditional write therefore lets a
+ * stale conclusion land on a row that has since moved:
+ *
+ * A: settles T1 as dropped, clears txHash, claimDeploy() -> 'deploying',
+ * and is now inside keyvault /sign about to broadcast T2.
+ * B: (its receipt lookups were slower) settles the same T1 as dropped and
+ * writes {status:'failed', txHash:null} over the top.
+ *
+ * That write undoes A's claim, and `'failed'` is re-claimable immediately, so B
+ * — or the customer's next click — takes the lease A is still holding and
+ * broadcasts a second creation. Worse, if it lands after A's `onBroadcast` it
+ * erases T2 from the row, which is the one thing that write exists to prevent:
+ * the replacement then takes the *next* nonce (deployErc20Evm counts 'pending'),
+ * so the two are no longer mutually exclusive and the customer pays gas twice
+ * for two contracts, one of which no order references.
+ *
+ * Conditioning on the hash makes the settlement idempotent in the same statement
+ * that performs it — the same shape as claimDeploy() one function down. Exactly
+ * one writer resolves any given broadcast; every later conclusion about it finds
+ * no row and is discarded, which is correct, because a row that no longer holds
+ * that hash has already been resolved by someone who knew more.
+ */
+export async function applyDeploySettlement(
+ id: string,
+ settledTxHash: string,
+ patch: OrderPatch,
+): Promise {
+ const [row] = await db
+ .update(tokenOrders)
+ .set(patch)
+ .where(and(eq(tokenOrders.id, id), eq(tokenOrders.txHash, settledTxHash)))
+ .returning()
+ return row ? toTokenOrder(row) : null
+}
+
/** Statuses a deploy may legitimately start from. */
const DEPLOYABLE: TokenOrderStatus[] = ['awaiting_funds', 'failed', 'deploying']
@@ -94,11 +161,16 @@ const DEPLOY_LEASE_MS = 300_000
* `txHash IS NULL` mirrors the in-flight guard on the read path: an order with a
* broadcast we have not yet resolved is never re-broadcast, lease or no lease.
*/
-export async function claimDeploy(id: string, userId: string): Promise {
+export async function claimDeploy(id: string, userId: string): Promise {
const staleBefore = new Date(Date.now() - DEPLOY_LEASE_MS)
const [row] = await db
.update(tokenOrders)
- .set({ status: 'deploying', deployStartedAt: new Date() })
+ // `deployNonce: null` clears the nonce of whatever attempt held this row
+ // before. It belongs to the previous `txHash`, which is NULL by the time we
+ // match here, and leaving it set would let a settlement of the NEXT
+ // broadcast — if that one somehow failed before onBroadcast ran — compare
+ // against a stale number. The new attempt writes its own in onBroadcast.
+ .set({ status: 'deploying', deployStartedAt: new Date(), deployNonce: null })
.where(
and(
eq(tokenOrders.id, id),
@@ -119,10 +191,42 @@ export async function claimDeploy(id: string, userId: string): Promise {
+export async function getOwnedOrder(id: string, userId: string): Promise {
const [row] = await db
.select()
.from(tokenOrders)
.where(and(eq(tokenOrders.id, id), eq(tokenOrders.userId, userId)))
return row ? toTokenOrder(row) : null
}
+
+/**
+ * The deploy-attempt columns, which `TokenOrder` does not carry.
+ *
+ * `TokenOrder` is `@cloudsforge/shared`'s wire shape and is what the browser
+ * gets; the nonce and the settlement clock are internal bookkeeping that nothing
+ * outside this service has any use for. Rather than widen a published type, the
+ * one caller that needs them — the deploy settler (CF-21) — reads them here.
+ */
+export interface DeployAttempt {
+ txHash: string | null
+ deployerAddress: string | null
+ /** The account nonce `txHash` occupies. NULL on rows written before it was recorded. */
+ nonce: number | null
+ /** When the attempt that produced `txHash` claimed the order. */
+ startedAt: Date | null
+ lastFailedTxHash: string | null
+ lastFailedOutcome: string | null
+}
+
+export async function getDeployAttempt(id: string): Promise {
+ const [row] = await db.select().from(tokenOrders).where(eq(tokenOrders.id, id))
+ if (!row) return null
+ return {
+ txHash: row.txHash,
+ deployerAddress: row.deployerAddress,
+ nonce: row.deployNonce,
+ startedAt: row.deployStartedAt,
+ lastFailedTxHash: row.lastFailedTxHash,
+ lastFailedOutcome: row.lastFailedOutcome,
+ }
+}
diff --git a/services/forge-mint/src/suspended.ts b/services/forge-mint/src/suspended.ts
new file mode 100644
index 0000000..4b73bfe
--- /dev/null
+++ b/services/forge-mint/src/suspended.ts
@@ -0,0 +1,146 @@
+import { MINT_OFFERS, SUPPORTED_CHAINS } from '@cloudsforge/shared'
+
+/**
+ * CF-01 — what ForgeMint can and cannot hand to the customer.
+ *
+ * THE HISTORY, BECAUSE THIS FILE INVERTED ITSELF ONCE. It began as an empty
+ * allowlist of deliverable *tiers*: every deploy minted the token's whole supply
+ * to `deployerAddress` and, on Forge and Foundry, passed that same address to
+ * `Ownable`, and `deployerAddress` is a keyvault row whose `purpose: 'deployer'`
+ * selects the vault's `creation` transaction shape — it may sign a contract
+ * creation and nothing else. No `transfer`, no `transferOwnership`, no `mint`,
+ * no `pause`, not even a native send of its own leftover gas. The thing being
+ * sold did not exist in any form the customer could reach, on any tier, so no
+ * tier was sold.
+ *
+ * The order now carries a customer-supplied `ownerAddress` (`src/owner.ts`) and
+ * the EVM deploy passes it as the constructor's `recipient_`/`owner_`
+ * (`chain/erc20.ts`, `chain/evm.ts`), so on an EVM chain the supply and the
+ * ownership land in a wallet the customer holds the key to and the deployer only
+ * ever pays gas — which is exactly what the `creation` shape is safe for. Every
+ * EVM tier is therefore back on sale.
+ *
+ * THE SUSPENSION THAT REMAINS IS SOLANA'S, AND IT IS NOT A TIER. An SPL mint
+ * cannot be handed over the same way, and the reason is one line in another
+ * repo: `signSolana` allows exactly `InitializeMint2` (20) and `MintTo` (7) and
+ * refuses `SetAuthority` (6) (`repos/forge-keyvault/.../signing.ts:483-492`).
+ * That leaves two shapes and both fail:
+ *
+ * - Initialize with the CUSTOMER as mint authority, and the deploy cannot mint
+ * the supply it was paid to mint — `MintTo` needs the authority's signature
+ * and the vault does not hold the customer's key.
+ * - Initialize with the DEPLOYER as mint authority, mint the supply into the
+ * customer's associated token account, and the vault is left holding a live,
+ * permanent, unrevocable power to inflate that customer's token. The freeze
+ * authority can be dropped at initialization; the mint authority cannot, and
+ * nothing can ever move it, because moving it *is* `SetAuthority`.
+ *
+ * So delivering SPL needs a keyvault change — a bounded `SetAuthority` that may
+ * only move the mint and freeze authority from the deployer to the order's own
+ * `ownerAddress` — and that is a second repo's ticket, not something this
+ * service can vote itself. Until it exists, ForgeMint does not take money for an
+ * SPL mint. `chain/solana.ts` refuses to deploy one for the same reason, so the
+ * two halves cannot drift apart.
+ *
+ * Two deliberate non-decisions, carried over unchanged:
+ *
+ * - Not an environment flag. An operator cannot make an SPL mint deliverable
+ * by setting a variable, so a variable would only be a way to turn the sale
+ * back on without fixing it. The mainnet kill switch is a flag because
+ * mainnet works and is merely dangerous; this does not work.
+ * - Not a refund, and not a confiscation. There is no refund path back to
+ * forge-pay (MAP.md §8), so nothing here can give a pre-gate customer their
+ * Shards back; what it can do is stop them spending anything FURTHER. So
+ * the gate is applied to all four routes that could take or cost money —
+ * `POST /tokens` and `/pay` refuse to take Shards, and `/provision` and
+ * `/deploy` refuse to hand out a funding address or broadcast, because gas
+ * sent to a deployer is unrecoverable by design and the deploy it would pay
+ * for is refused anyway. A paid order on a suspended chain therefore rests
+ * at `paid` and costs its owner nothing more.
+ */
+
+/**
+ * Chain families ForgeMint can actually hand to the customer.
+ *
+ * A family and not a list of chain ids, because the property being asserted is a
+ * property of the family: an EVM deploy takes the owner as a constructor
+ * argument, so a sixth EVM chain added to `SUPPORTED_CHAINS` is deliverable by
+ * construction. Anything that is NOT an EVM chain inherits SPL's problem —
+ * authority granted at creation and movable only by an instruction the vault
+ * refuses — so it is refused without anyone having to remember to list it. That
+ * is the fail-closed direction: new chain, no sale, until someone reads this.
+ */
+const DELIVERABLE_FAMILIES = new Set(['evm'])
+
+export interface Suspension {
+ error: string
+ code: 'chain_suspended'
+ chainId: string
+}
+
+/**
+ * Said to the customer, in the storefront and in the API error. It names what
+ * they would not get rather than saying "unavailable", because "unavailable"
+ * reads as an outage they should wait out.
+ */
+export const SOLANA_SUSPENSION_REASON =
+ 'Solana orders are suspended. An SPL mint’s supply and its mint authority are set when the mint is created, and ForgeMint’s hardened deployer cannot afterwards hand either to a wallet you control — so the token would be minted under a key that is not yours. EVM chains are unaffected: there the supply and the contract ownership go straight to the address you give us. ForgeMint will not take payment for a token it cannot give you.'
+
+/** The gate. Null means an order on this chain may be sold. */
+export function chainSuspension(chainId: string): Suspension | null {
+ const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId)
+ // An id no catalog entry claims is refused rather than defaulted: the caller
+ // is about to be told 'unknown chain' anyway, and a gate that answers "not
+ // suspended" for something it cannot identify is the wrong shape of gate.
+ if (!chain) return { error: SOLANA_SUSPENSION_REASON, code: 'chain_suspended', chainId }
+ if (DELIVERABLE_FAMILIES.has(chain.family)) return null
+ return { error: SOLANA_SUSPENSION_REASON, code: 'chain_suspended', chainId }
+}
+
+export function isChainSuspended(chainId: string): boolean {
+ return chainSuspension(chainId) !== null
+}
+
+/**
+ * Chains this tier could target, per the standard-inclusion rule `POST /tokens`
+ * enforces. Foundry is the only tier that reaches SPL.
+ */
+export function chainsForOffer(offerId: string): string[] {
+ const offer = MINT_OFFERS.find((o) => o.id === offerId)
+ if (!offer) return []
+ return SUPPORTED_CHAINS.filter((c) => offer.standardsIncluded.includes(c.standard)).map(
+ (c) => c.id,
+ )
+}
+
+export interface OfferSuspension {
+ error: string
+ code: 'offer_suspended'
+ offerId: string
+}
+
+/**
+ * A tier is suspended only when EVERY chain it can target is. That is false for
+ * all three today — Foundry sells SPL and ERC-20, and its ERC-20 half is
+ * deliverable — so the storefront shows three live packages and pauses one chain
+ * inside them, which is the truth. It stays here rather than being deleted
+ * because "no chain of this tier can be sold" is a state the catalog can reach
+ * again, and a storefront that can only express it per chain would show a tier
+ * with nothing behind it.
+ */
+export function offerSuspension(offerId: string): OfferSuspension | null {
+ const chains = chainsForOffer(offerId)
+ if (chains.length > 0 && chains.some((id) => !isChainSuspended(id))) return null
+ return {
+ error:
+ chains.length === 0
+ ? 'This package cannot be ordered: no supported chain carries its token standard.'
+ : SOLANA_SUSPENSION_REASON,
+ code: 'offer_suspended',
+ offerId,
+ }
+}
+
+export function isOfferSuspended(offerId: string): boolean {
+ return offerSuspension(offerId) !== null
+}
diff --git a/services/forge-mint/test/networks.test.ts b/services/forge-mint/test/networks.test.ts
new file mode 100644
index 0000000..eda8ce6
--- /dev/null
+++ b/services/forge-mint/test/networks.test.ts
@@ -0,0 +1,127 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { SUPPORTED_CHAINS } from '@cloudsforge/shared'
+import { findChain, resolveNetwork } from '../src/chain/networks.js'
+
+/**
+ * CF-43. `resolveNetwork` used to end in a third arm — a generic `EVM_RPC_URL`
+ * or `SOLANA_RPC_URL`, consulted when the chain's baked default was empty. No
+ * chain's baked default has ever been empty, so the arm could not fire, and the
+ * two variables it read were the only RPC knobs `.env.example` offered. An
+ * operator being rate-limited by a public testnet node therefore set the one
+ * setting the file showed them, restarted, and got the identical endpoint back
+ * with nothing to say why.
+ *
+ * No unit test can catch "the documentation offers a knob that does nothing" —
+ * that half of CF-43 is the `.env.example` and MAP.md edits. What these tests
+ * hold is the pair of facts underneath it. The first asserts the invariant that
+ * made the arm unreachable, so if a chain ever ships without a baked RPC this
+ * file says so instead of the service quietly handing ethers an empty string.
+ * The rest pin the knob that does work, per chain and per network, which is the
+ * knob a reader of `.env.example` is now sent to.
+ *
+ * Note what this file does *not* do: set `FORGE_MINT_DATABASE_URL` and friends
+ * before importing. `chain/networks.ts` no longer reaches `env.ts` at all now
+ * that the generic fallback is gone, which is why the import at the top is a
+ * plain static one and not the `await import` dance `settle-deploy.test.ts`
+ * still needs for `chain/evm.ts`.
+ */
+
+const NETWORKS = ['mainnet', 'testnet'] as const
+
+/** Restore process.env exactly, including "was not set at all". */
+function withEnv(vars: Record, fn: () => void): void {
+ const before = new Map(Object.keys(vars).map((k) => [k, process.env[k]]))
+ Object.assign(process.env, vars)
+ try {
+ fn()
+ } finally {
+ for (const [k, v] of before) {
+ if (v === undefined) delete process.env[k]
+ else process.env[k] = v
+ }
+ }
+}
+
+/**
+ * The reason the deleted fallback was dead code. It is asserted rather than
+ * commented because it is load-bearing: with it, `baked` is always a usable
+ * URL and no fallback is needed; without it, `resolveNetwork` hands ethers an
+ * empty string and the failure surfaces as an unrelated connection error.
+ */
+test('every chain bakes a non-empty RPC for both networks', () => {
+ assert.ok(SUPPORTED_CHAINS.length > 0, 'no chains — this test would prove nothing')
+ for (const chain of SUPPORTED_CHAINS) {
+ assert.ok(chain.mainnetRpc.trim(), `${chain.id} has no baked mainnetRpc`)
+ assert.ok(chain.testnetRpc.trim(), `${chain.id} has no baked testnetRpc`)
+ }
+})
+
+test('resolveNetwork never returns an empty RPC URL', () => {
+ for (const chain of SUPPORTED_CHAINS) {
+ for (const network of NETWORKS) {
+ const resolved = resolveNetwork(chain, network)
+ assert.ok(resolved.rpcUrl.trim(), `${chain.id}/${network} resolved to an empty RPC URL`)
+ assert.doesNotThrow(() => new URL(resolved.rpcUrl), `${chain.id}/${network} is not a URL`)
+ }
+ }
+})
+
+/**
+ * The knob an operator actually has. One variable per chain per network, which
+ * is the whole reason a single `EVM_RPC_URL` could not have been wired up
+ * instead: it would have pointed a Polygon or BSC deploy at whatever endpoint
+ * the Ethereum-shaped example URL named.
+ */
+test('RPC__ overrides the baked default, per chain and per network', () => {
+ for (const chain of SUPPORTED_CHAINS) {
+ for (const network of NETWORKS) {
+ const key = `RPC_${chain.id.toUpperCase()}_${network.toUpperCase()}`
+ const mine = `https://${chain.id}-${network}.operator.invalid/v3/key`
+ withEnv({ [key]: mine }, () => {
+ assert.equal(resolveNetwork(chain, network).rpcUrl, mine, `${key} did not take effect`)
+ // and it moves nothing else
+ const other = network === 'mainnet' ? 'testnet' : 'mainnet'
+ assert.notEqual(resolveNetwork(chain, other).rpcUrl, mine, `${key} leaked into ${other}`)
+ for (const sibling of SUPPORTED_CHAINS) {
+ if (sibling.id === chain.id) continue
+ assert.notEqual(
+ resolveNetwork(sibling, network).rpcUrl,
+ mine,
+ `${key} leaked into ${sibling.id}`,
+ )
+ }
+ })
+ }
+ }
+})
+
+/** A blank or whitespace-only override is not an instruction; fall back. */
+test('an empty override falls back to the baked default rather than resolving to nothing', () => {
+ const ethereum = findChain('ethereum')!
+ withEnv({ RPC_ETHEREUM_TESTNET: ' ' }, () => {
+ assert.equal(resolveNetwork(ethereum, 'testnet').rpcUrl, ethereum.testnetRpc)
+ })
+})
+
+/**
+ * A regression guard against the *wrong* fix. The tempting reading of CF-43 is
+ * "wire the variables up"; this asserts they stay unwired, because a single
+ * `EVM_RPC_URL` given precedence would send four of the five EVM chains to an
+ * endpoint for a chain they are not on — on mainnet, with real gas. If someone
+ * makes this test fail, the thing to reach for is `RPC__`.
+ */
+test('the generic EVM_RPC_URL/SOLANA_RPC_URL have no influence on any resolution', () => {
+ const sentinel = 'https://generic.operator.invalid/should-never-be-used'
+ withEnv({ EVM_RPC_URL: sentinel, SOLANA_RPC_URL: sentinel }, () => {
+ for (const chain of SUPPORTED_CHAINS) {
+ for (const network of NETWORKS) {
+ assert.notEqual(
+ resolveNetwork(chain, network).rpcUrl,
+ sentinel,
+ `${chain.id}/${network} consulted a generic RPC URL`,
+ )
+ }
+ }
+ })
+})
diff --git a/services/forge-mint/test/owner-address.test.ts b/services/forge-mint/test/owner-address.test.ts
new file mode 100644
index 0000000..27a4120
--- /dev/null
+++ b/services/forge-mint/test/owner-address.test.ts
@@ -0,0 +1,285 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { ethers } from 'ethers'
+import { Keypair, PublicKey } from '@solana/web3.js'
+import { SUPPORTED_CHAINS, type SupportedChain } from '@cloudsforge/shared'
+import { encodeDeployData, variantForOffer } from '../src/chain/erc20.js'
+import {
+ FIXEDSUPPLYTOKEN_BYTECODE,
+ FOUNDRYTOKEN_BYTECODE,
+ MINTABLETOKEN_BYTECODE,
+} from '../src/chain/erc20.generated.js'
+import { normalizeOwnerAddress } from '../src/owner.js'
+
+/**
+ * CF-01. The defect was one identifier used for two jobs: `deployerAddress` —
+ * a vault-held key that may sign a contract creation and nothing else — was
+ * passed as the constructor's `recipient_`/`owner_`, so every token ever
+ * deployed had its whole supply, and on two tiers its ownership, at an address
+ * no customer could ever sign for. Nothing failed. The deploy succeeded, the
+ * explorer showed a real contract, and the customer had nothing.
+ *
+ * The first test below is the one that would have caught it: it decodes the
+ * constructor arguments this service actually broadcasts and asserts the
+ * deployer is not in them. Neither this nor anything else in the tree can check
+ * that the customer holds the key to the address they gave us, which is why
+ * `normalizeOwnerAddress` is as strict as it is about the address being
+ * well-formed — the tests after it are about the only line of defence that
+ * exists at the moment a typo is still fixable.
+ */
+
+const ETHEREUM = SUPPORTED_CHAINS.find((c) => c.id === 'ethereum')!
+const SOLANA = SUPPORTED_CHAINS.find((c) => c.id === 'solana')!
+
+// Two distinct, valid addresses standing in for the two roles.
+const CUSTOMER = ethers.getAddress('0x' + 'ab'.repeat(20))
+const DEPLOYER = ethers.getAddress('0x' + 'cd'.repeat(20))
+
+const BYTECODE = {
+ fixed: FIXEDSUPPLYTOKEN_BYTECODE,
+ mintable: MINTABLETOKEN_BYTECODE,
+ foundry: FOUNDRYTOKEN_BYTECODE,
+} as const
+
+/** The ABI-encoded constructor tail, i.e. everything after the creation bytecode. */
+function constructorArgs(variant: keyof typeof BYTECODE, data: string): string {
+ const bytecode = BYTECODE[variant]
+ assert.ok(data.startsWith(bytecode), `${variant} deploy data does not start with its bytecode`)
+ return '0x' + data.slice(bytecode.length)
+}
+
+// --------------------------------------------------- the constructor argument
+
+test('every tier deploys with the customer as recipient and owner, not the deployer', () => {
+ const coder = ethers.AbiCoder.defaultAbiCoder()
+ const params = {
+ name: 'Ember Coin',
+ symbol: 'EMBER',
+ decimals: 18,
+ supply: '1000000',
+ cap: '2000000',
+ owner: CUSTOMER,
+ }
+
+ for (const offerId of ['spark', 'forge', 'foundry']) {
+ const variant = variantForOffer(offerId)
+ const data = encodeDeployData(variant, params)
+ const tail = constructorArgs(variant, data)
+ const types =
+ variant === 'foundry'
+ ? ['string', 'string', 'uint8', 'uint256', 'uint256', 'address']
+ : ['string', 'string', 'uint8', 'uint256', 'address']
+ const decoded = coder.decode(types, tail)
+ const owner = decoded[types.length - 1] as string
+
+ assert.equal(owner, CUSTOMER, `${offerId} does not mint to the customer`)
+ assert.notEqual(
+ owner.toLowerCase(),
+ DEPLOYER.toLowerCase(),
+ `${offerId} minted to the deployer — that address can only sign this creation (CF-01)`,
+ )
+ // The supply is scaled by decimals and is the thing being handed over; a
+ // correct owner holding the wrong number would be the same defect wearing a
+ // different hat.
+ assert.equal(decoded[3], 1_000_000n * 10n ** 18n, `${offerId} minted the wrong supply`)
+ }
+})
+
+/**
+ * Spark is the tier that looked exempt: it has no `Ownable`, so a reading of
+ * CF-01 as an ownership bug leaves it on sale. Its whole supply goes to the same
+ * single constructor address, which makes its loss total rather than partial.
+ */
+test('spark has no owner to strand and still hands its entire supply to the customer', () => {
+ const data = encodeDeployData('fixed', {
+ name: 'Spark',
+ symbol: 'SPRK',
+ decimals: 6,
+ supply: '21000000',
+ cap: null,
+ owner: CUSTOMER,
+ })
+ const [, , , supply, recipient] = ethers.AbiCoder.defaultAbiCoder().decode(
+ ['string', 'string', 'uint8', 'uint256', 'address'],
+ constructorArgs('fixed', data),
+ )
+ assert.equal(recipient, CUSTOMER)
+ assert.equal(supply, 21_000_000n * 10n ** 6n)
+})
+
+// ------------------------------------------------------------ address parsing
+
+test('a checksummed EVM address is accepted and returned unchanged', () => {
+ const result = normalizeOwnerAddress(ETHEREUM, CUSTOMER)
+ assert.ok(result.ok)
+ assert.equal(result.address, CUSTOMER)
+})
+
+test('a lowercase EVM address is accepted and normalised to EIP-55', () => {
+ const wallet = ethers.Wallet.createRandom()
+ const result = normalizeOwnerAddress(ETHEREUM, wallet.address.toLowerCase())
+ assert.ok(result.ok)
+ assert.equal(result.address, wallet.address)
+ assert.notEqual(result.address, wallet.address.toLowerCase(), 'the checksum was not restored')
+})
+
+test('surrounding whitespace is trimmed — it is the commonest thing a paste carries', () => {
+ const result = normalizeOwnerAddress(ETHEREUM, ` ${CUSTOMER}\n`)
+ assert.ok(result.ok)
+ assert.equal(result.address, CUSTOMER)
+})
+
+/**
+ * The whole point of demanding a checksummed address rather than lowercasing
+ * whatever arrives: a single mistyped character in a mixed-case address is
+ * caught here, and caught nowhere else ever again.
+ */
+test('a mixed-case address with one character wrong is refused', () => {
+ const good = ethers.Wallet.createRandom().address
+ const bad = good.slice(0, -1) + (good.endsWith('a') ? 'b' : 'a')
+ const result = normalizeOwnerAddress(ETHEREUM, bad)
+ assert.equal(result.ok, false)
+})
+
+test('the zero address is refused', () => {
+ const result = normalizeOwnerAddress(ETHEREUM, '0x0000000000000000000000000000000000000000')
+ assert.equal(result.ok, false)
+ assert.ok(!result.ok && /recover/i.test(result.error))
+})
+
+test('a missing, empty or non-string owner address is refused', () => {
+ for (const raw of [undefined, null, '', ' ', 42, {}, ['0x0']]) {
+ const result = normalizeOwnerAddress(ETHEREUM, raw)
+ assert.equal(result.ok, false, `${JSON.stringify(raw) ?? 'undefined'} was accepted`)
+ }
+})
+
+test('an address of the wrong length or with non-hex characters is refused', () => {
+ for (const raw of [
+ '0xabc',
+ '0x' + 'ab'.repeat(21),
+ '0x' + 'zz'.repeat(20),
+ ethers.Wallet.createRandom().address.slice(0, -1),
+ ]) {
+ assert.equal(normalizeOwnerAddress(ETHEREUM, raw).ok, false, `${raw} was accepted`)
+ }
+})
+
+/**
+ * 40 hex characters with no `0x` are accepted and normalised, because that is
+ * what several block explorers put on the clipboard and there is nothing else it
+ * could be. What comes back still carries the prefix and the checksum, so the
+ * row and the constructor argument are in the canonical form either way.
+ */
+test('an unprefixed but otherwise valid address is accepted and normalised', () => {
+ const result = normalizeOwnerAddress(ETHEREUM, CUSTOMER.slice(2).toLowerCase())
+ assert.ok(result.ok)
+ assert.equal(result.address, CUSTOMER)
+})
+
+/**
+ * A Solana address is not an EVM one and vice versa, and the pair are exactly
+ * the confusion a multi-chain order form invites. `POST /tokens` refuses Solana
+ * orders outright today (CF-01, suspended.ts) — these hold the rule so it is not
+ * being written for the first time on the day that gate lifts.
+ */
+test('a Solana wallet address is accepted on Solana and refused on an EVM chain', () => {
+ const wallet = Keypair.generate().publicKey.toBase58()
+ const onSolana = normalizeOwnerAddress(SOLANA, wallet)
+ assert.ok(onSolana.ok)
+ assert.equal(onSolana.address, wallet)
+ assert.equal(normalizeOwnerAddress(ETHEREUM, wallet).ok, false)
+})
+
+test('an EVM address is refused on Solana', () => {
+ assert.equal(normalizeOwnerAddress(SOLANA, CUSTOMER).ok, false)
+})
+
+test('a program-derived Solana address is refused — nobody holds its key', () => {
+ // The ATA of a random wallet: a real, valid, useful address that is off-curve
+ // and therefore cannot sign. As a mint authority it would be the deployer's
+ // defect again, with better paperwork.
+ const [pda] = PublicKey.findProgramAddressSync(
+ [Buffer.from('cf-01')],
+ new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'),
+ )
+ const result = normalizeOwnerAddress(SOLANA, pda.toBase58())
+ assert.equal(result.ok, false)
+ assert.ok(!result.ok && /private key/i.test(result.error))
+})
+
+/**
+ * Not a formality: `chain.family` is what selects the rule, and a chain added to
+ * `SUPPORTED_CHAINS` with a family this function does not know would silently
+ * take the Solana branch. Every chain the catalog sells is checked to accept
+ * something and refuse the other family's addresses.
+ */
+test('every supported chain validates addresses in its own family', () => {
+ const evmSample = CUSTOMER
+ const solanaSample = Keypair.generate().publicKey.toBase58()
+ for (const chain of SUPPORTED_CHAINS as SupportedChain[]) {
+ const mine = chain.family === 'evm' ? evmSample : solanaSample
+ const theirs = chain.family === 'evm' ? solanaSample : evmSample
+ assert.equal(normalizeOwnerAddress(chain, mine).ok, true, `${chain.id} refused its own family`)
+ assert.equal(
+ normalizeOwnerAddress(chain, theirs).ok,
+ false,
+ `${chain.id} accepted an address from another family`,
+ )
+ }
+})
+
+// ------------------------------------------------ where the constructor reads
+
+/**
+ * CF-01, the last narrowing. Getting the right address into `encodeDeployData`
+ * is only half of it — the other half is reading it at a moment nobody can still
+ * change it.
+ *
+ * `POST /tokens/:id/owner` deliberately stays open for as long as the address is
+ * still changeable: it closes on status 'deploying'/'deployed' or a set
+ * `txHash`, and nothing else. `POST /tokens/:id/deploy` reads the order once at
+ * the top of the handler and then runs the CF-21 settlement, the mainnet checks
+ * and a full RPC balance read before it claims the row — and it is `claimDeploy`
+ * setting status='deploying' that closes the owner route. Taking the address off
+ * that first snapshot leaves a window, seconds wide over a network call, in
+ * which a customer correcting a typo gets a 200 writing address B while this
+ * request encodes address A into the constructor and broadcasts it. The contract
+ * then holds the entire supply at A forever while the row, `GET /tokens/:id/status`
+ * and the "Token deployed" screen all say B — "your wallet". Nothing errors.
+ *
+ * The fix is one word: read `claimed.ownerAddress`, not `order.ownerAddress`.
+ * `claimDeploy` returns the row from the same UPDATE that closes the owner
+ * route, so its value is the first read of the field that cannot be raced.
+ *
+ * This asserts it over the route's source rather than by driving the route,
+ * because the race is between two database statements and this service's tests
+ * deliberately open no database connection (see test/settle-deploy.test.ts).
+ * A source assertion is worth having anyway: the defect is invisible in review —
+ * both spellings typecheck, both are non-null, and they differ only in which
+ * read wins — so what has to be defended is precisely the identifier.
+ */
+test('the deploy encodes the owner address off the claimed row, not the pre-claim snapshot', async () => {
+ const { readFile } = await import('node:fs/promises')
+ const source = await readFile(new URL('../src/routes/tokens.ts', import.meta.url), 'utf8')
+
+ const start = source.indexOf("'/tokens/:id/deploy'")
+ assert.ok(start > 0, 'the deploy route was renamed — this test is reading the wrong handler')
+ const handler = source.slice(start)
+
+ const claim = handler.indexOf('await claimDeploy(')
+ assert.ok(claim > 0, 'the deploy no longer claims the row before broadcasting')
+
+ const read = handler.indexOf('const ownerAddress = claimed.ownerAddress')
+ assert.ok(
+ read > 0,
+ 'the deploy does not read its owner address off the row claimDeploy returned (CF-01)',
+ )
+ assert.ok(read > claim, 'the owner address is read before the claim that closes /owner (CF-01)')
+
+ // The two spellings this has already been, either of which silently reopens
+ // the defect: the deployer itself, and the pre-claim snapshot.
+ for (const wrong of ['ownerAddress: deployerAddress', 'ownerAddress: order.ownerAddress']) {
+ assert.ok(!handler.includes(wrong), `the deploy passes \`${wrong}\` to the encoder (CF-01)`)
+ }
+})
diff --git a/services/forge-mint/test/settle-deploy.test.ts b/services/forge-mint/test/settle-deploy.test.ts
new file mode 100644
index 0000000..6117e6d
--- /dev/null
+++ b/services/forge-mint/test/settle-deploy.test.ts
@@ -0,0 +1,304 @@
+import assert from 'node:assert/strict'
+import { createServer, type Server } from 'node:http'
+import test, { after, before } from 'node:test'
+import type { ResolvedNetwork } from '../src/chain/networks.js'
+
+// `chain/evm.ts` reaches `clients/keyvault.ts`, which reads `env.ts` at import,
+// and `env.ts` deliberately refuses to load without the service's real
+// configuration — a startup guard, not something to relax for a test. So set
+// placeholders first and import the module under test afterwards. Nothing here
+// opens a database connection or calls a vault: the only network this file
+// touches is the fake JSON-RPC node it starts itself.
+process.env.FORGE_MINT_DATABASE_URL ??= 'postgres://unused:unused@127.0.0.1:1/unused'
+process.env.NIMBUS_JWKS_URL ??= 'http://unused.invalid/.well-known/jwks.json'
+process.env.KEYVAULT_SERVICE_TOKEN ??= 'test-placeholder-not-a-real-service-token'
+
+const { DEPLOY_DROP_AFTER_MS, settleEvmDeploy } = await import('../src/chain/evm.js')
+
+/**
+ * CF-21. `settleEvmDeploy` is the only writer that can clear an order's
+ * `txHash`, and `txHash` being set is what `POST /deploy` refuses with
+ * `deploy_in_flight` and what `claimDeploy()` refuses with `txHash IS NULL`. It
+ * used to clear it on success only, so the two outcomes that actually strand a
+ * customer — a revert, and a transaction dropped from the mempool and never
+ * mined — bricked the order permanently: Shards spent, gas sent to the deployer,
+ * no token, and no route back short of an operator running
+ * `UPDATE token_orders SET tx_hash = NULL` by hand.
+ *
+ * These drive the real settler against a scripted JSON-RPC node, because the
+ * defect is entirely in what it concludes from a node's answers — and because
+ * the two safety conditions on the dropped case (the node no longer holds the
+ * transaction; nothing has consumed its nonce) can only be got wrong against a
+ * node that says so.
+ */
+
+// ------------------------------------------------------------------ scaffold
+
+/** Answers the settler's calls. Each test sets exactly the ones it cares about. */
+let script: Record = {}
+let server: Server
+let rpcUrl = ''
+
+const SEPOLIA = '0xaa36a7'
+const TX = '0x' + 'ab'.repeat(32)
+const BLOCK = '0x' + 'cd'.repeat(32)
+const DEPLOYER = '0x' + '11'.repeat(20)
+const CONTRACT = '0x' + '22'.repeat(20)
+
+/**
+ * ethers parses these strictly and rejects a partial object, so both fixtures
+ * are whole. That strictness is the point: what the settler sees here is the
+ * shape a node really returns.
+ */
+const receipt = (status: '0x0' | '0x1', contractAddress: string | null) => ({
+ transactionHash: TX,
+ transactionIndex: '0x0',
+ blockHash: BLOCK,
+ blockNumber: '0x10',
+ from: DEPLOYER,
+ to: null,
+ contractAddress,
+ cumulativeGasUsed: '0x5208',
+ gasUsed: '0x5208',
+ effectiveGasPrice: '0x7',
+ logs: [],
+ logsBloom: '0x' + '00'.repeat(256),
+ status,
+ type: '0x2',
+})
+
+/** A transaction the node still holds in its mempool: known, unmined. */
+const mempoolTx = () => ({
+ hash: TX,
+ nonce: '0x0',
+ from: DEPLOYER,
+ to: null,
+ value: '0x0',
+ gas: '0x100000',
+ gasPrice: '0x7',
+ input: '0x60006000f3',
+ blockHash: null,
+ blockNumber: null,
+ transactionIndex: null,
+ type: '0x0',
+ chainId: SEPOLIA,
+ v: '0x1b',
+ r: '0x' + '01'.repeat(32),
+ s: '0x' + '02'.repeat(32),
+})
+
+before(async () => {
+ server = createServer((req, res) => {
+ let body = ''
+ req.on('data', (c) => (body += c))
+ req.on('end', () => {
+ const parsed = JSON.parse(body || '{}') as { id: number; method: string }[] | { id: number; method: string }
+ const one = (r: { id: number; method: string }) => ({
+ jsonrpc: '2.0',
+ id: r.id,
+ // `?? null` and not `??` a throw: an unscripted method is one the settler
+ // is not supposed to be asking about, and a null answer lets the
+ // assertion below say which conclusion it reached rather than surfacing
+ // as a transport error.
+ result: r.method === 'eth_chainId' ? SEPOLIA : (script[r.method] ?? null),
+ })
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end(JSON.stringify(Array.isArray(parsed) ? parsed.map(one) : one(parsed)))
+ })
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const addr = server.address()
+ if (typeof addr === 'string' || addr === null) throw new Error('no port')
+ rpcUrl = `http://127.0.0.1:${addr.port}`
+})
+
+after(() => server.close())
+
+function resolved(): ResolvedNetwork {
+ return {
+ chain: {
+ id: 'ethereum',
+ family: 'evm',
+ testnetExplorer: 'https://sepolia.etherscan.io',
+ mainnetExplorer: 'https://etherscan.io',
+ } as unknown as ResolvedNetwork['chain'],
+ network: 'testnet',
+ rpcUrl,
+ chainId: 11155111,
+ explorerBase: 'https://sepolia.etherscan.io',
+ isMainnet: false,
+ }
+}
+
+/** Old enough for the dropped case to be considered at all. */
+const LONG_AGO = () => new Date(Date.now() - DEPLOY_DROP_AFTER_MS - 60_000)
+
+const attempt = (over: Partial[1]> = {}) => ({
+ txHash: TX,
+ deployerAddress: DEPLOYER,
+ nonce: 0,
+ startedAt: LONG_AGO(),
+ ...over,
+})
+
+// --------------------------------------------------------------------- tests
+
+test('a mined deploy settles to deployed with its contract address', async () => {
+ script = { eth_getTransactionReceipt: receipt('0x1', CONTRACT) }
+ const settled = await settleEvmDeploy(resolved(), attempt())
+ assert.ok(settled)
+ assert.equal(settled.outcome, 'deployed')
+ assert.equal(settled.patch.status, 'deployed')
+ assert.equal(settled.patch.contractAddress?.toLowerCase(), CONTRACT)
+ // Not cleared: this hash is the deploy, and the order keeps it.
+ assert.equal(settled.patch.txHash, undefined)
+})
+
+test('a reverted deploy clears txHash so the order can be deployed again', async () => {
+ script = { eth_getTransactionReceipt: receipt('0x0', null) }
+ const settled = await settleEvmDeploy(resolved(), attempt())
+ assert.ok(settled)
+ assert.equal(settled.outcome, 'reverted')
+ assert.equal(settled.patch.status, 'failed')
+ // The line the whole ticket is about: 'failed' with txHash still set is a 409
+ // forever, because claimDeploy() requires txHash IS NULL.
+ assert.equal(settled.patch.txHash, null)
+ assert.equal(settled.patch.lastFailedTxHash, TX)
+ assert.equal(settled.patch.lastFailedOutcome, 'reverted')
+ assert.match(settled.detail, /gas/i)
+})
+
+test('a dropped deploy clears txHash once the node has forgotten it and the nonce is free', async () => {
+ script = {
+ eth_getTransactionReceipt: null, // never mined
+ eth_getTransactionByHash: null, // evicted from the mempool
+ eth_getTransactionCount: '0x0', // nonce 0 still unused
+ }
+ const settled = await settleEvmDeploy(resolved(), attempt())
+ assert.ok(settled, 'a dropped transaction left the order in deploy_in_flight forever')
+ assert.equal(settled.outcome, 'dropped')
+ assert.equal(settled.patch.status, 'failed')
+ assert.equal(settled.patch.txHash, null)
+ assert.equal(settled.patch.lastFailedTxHash, TX)
+ assert.equal(settled.patch.lastFailedOutcome, 'dropped')
+})
+
+/**
+ * The three refusals. Each is a way of being wrong that costs a second
+ * broadcast, so each is checked on its own rather than trusting that the
+ * combination happens to hold.
+ */
+
+test('a transaction still in the mempool is pending, however long it has been there', async () => {
+ script = {
+ eth_getTransactionReceipt: null,
+ // A fee spike is exactly the scenario, and a transaction waiting one out is
+ // alive. Clearing txHash here is what would produce a duplicate.
+ eth_getTransactionByHash: mempoolTx(),
+ eth_getTransactionCount: '0x0',
+ }
+ assert.equal(await settleEvmDeploy(resolved(), attempt()), null)
+})
+
+test('a consumed nonce means something mined and we must not assume it was not this', async () => {
+ script = {
+ eth_getTransactionReceipt: null,
+ eth_getTransactionByHash: null,
+ // The deployer's `latest` count has moved past the nonce this transaction
+ // holds — an RPC that cannot find the receipt is not evidence enough against
+ // a chain that says the slot is used.
+ eth_getTransactionCount: '0x1',
+ }
+ assert.equal(await settleEvmDeploy(resolved(), attempt({ nonce: 0 })), null)
+})
+
+test('a young broadcast is left alone even when it looks dropped', async () => {
+ script = {
+ eth_getTransactionReceipt: null,
+ eth_getTransactionByHash: null,
+ eth_getTransactionCount: '0x0',
+ }
+ assert.equal(await settleEvmDeploy(resolved(), attempt({ startedAt: new Date() })), null)
+})
+
+/**
+ * Rows broadcast before `deploy_nonce` and `deploy_started_at` existed carry
+ * NULL in both. They are the orders most likely to be stuck right now, so the
+ * settlement has to reach them without a backfill: NULL age reads as old (which
+ * it is), and NULL nonce reads as 0, which is the nonce a per-order deployer's
+ * first and only transaction occupies.
+ */
+test('a legacy row with no recorded nonce or start time still settles', async () => {
+ script = {
+ eth_getTransactionReceipt: null,
+ eth_getTransactionByHash: null,
+ eth_getTransactionCount: '0x0',
+ }
+ const settled = await settleEvmDeploy(resolved(), attempt({ nonce: null, startedAt: null }))
+ assert.ok(settled)
+ assert.equal(settled.outcome, 'dropped')
+ assert.equal(settled.patch.txHash, null)
+})
+
+/**
+ * A successful receipt that names no contract is not a deploy we can account
+ * for. It is the one outcome that must NOT clear txHash: unlike a revert we
+ * cannot tell the customer they got nothing, and a retry would put a second
+ * contract next to a first one nobody has identified.
+ */
+test('a mined transaction with no contract address is held for a human', async () => {
+ script = { eth_getTransactionReceipt: receipt('0x1', null) }
+ const settled = await settleEvmDeploy(resolved(), attempt())
+ assert.ok(settled)
+ assert.equal(settled.outcome, 'mined_unresolved')
+ assert.equal(settled.patch.status, 'failed')
+ assert.equal(settled.patch.txHash, undefined, 'clearing this would allow a second deploy')
+})
+
+/**
+ * Where the settlement is WRITTEN, which is the other half of CF-21 and the half
+ * that can undo the deploy lease.
+ *
+ * The settler runs on a polled GET and a clicked POST, and it decides what to
+ * write several RPC round-trips after reading the row. Applied unconditionally,
+ * a conclusion about a hash the order no longer carries lands on top of whatever
+ * resolved it first: `{status:'failed', txHash:null}` over an order another
+ * request has just claimed and is broadcasting for, which is re-claimable
+ * immediately — so a second creation goes out at the next nonce and the customer
+ * pays gas twice for two contracts, one of which no order references. Verified
+ * against the dev database, where the unguarded write let a second claim through
+ * and the guarded one did not.
+ *
+ * These read the source because the guard is a WHERE clause and a call site: no
+ * value the settler returns can express it, and both are single lines that would
+ * survive any amount of behavioural testing of `settleEvmDeploy` itself.
+ */
+const readSource = async (rel: string) =>
+ (await import('node:fs/promises')).readFile(new URL(rel, import.meta.url), 'utf8')
+
+test('the settlement is only applied while the row still carries the hash it settled', async () => {
+ const source = await readSource('../src/routes/tokens.ts')
+ const start = source.indexOf('async function settleInFlightDeploy')
+ assert.ok(start > 0, 'settleInFlightDeploy was renamed — this test reads the wrong function')
+ const body = source.slice(start, source.indexOf('export async function tokenRoutes', start))
+
+ assert.match(
+ body,
+ /applyDeploySettlement\(\s*order\.id,\s*order\.txHash,/,
+ 'the settlement is written without the txHash it concluded about (CF-21)',
+ )
+ assert.ok(
+ !/\bupdateOrder\(/.test(body),
+ 'an unconditional updateOrder() here can overwrite a claim another attempt already holds (CF-21)',
+ )
+})
+
+test('the guarded write matches on the settled txHash, not the id alone', async () => {
+ const source = await readSource('../src/store.ts')
+ const start = source.indexOf('export async function applyDeploySettlement')
+ assert.ok(start > 0, 'applyDeploySettlement is gone — the settlement write is unguarded again (CF-21)')
+ const body = source.slice(start, source.indexOf('\n}', start))
+
+ assert.match(body, /eq\(tokenOrders\.txHash,\s*settledTxHash\)/, 'the compare-and-set no longer compares')
+ assert.match(body, /eq\(tokenOrders\.id,\s*id\)/, 'the update is no longer scoped to one order')
+})
diff --git a/services/forge-mint/test/suspended.test.ts b/services/forge-mint/test/suspended.test.ts
new file mode 100644
index 0000000..ceb0797
--- /dev/null
+++ b/services/forge-mint/test/suspended.test.ts
@@ -0,0 +1,167 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { MINT_OFFERS, SUPPORTED_CHAINS } from '@cloudsforge/shared'
+import {
+ chainSuspension,
+ chainsForOffer,
+ isChainSuspended,
+ isOfferSuspended,
+ offerSuspension,
+} from '../src/suspended.js'
+
+/**
+ * CF-01. The invariant is not "these ids are suspended" — that list has already
+ * changed once and will change again. It is: **ForgeMint does not take money for
+ * a token it cannot hand to the customer**, and the only thing that makes a
+ * token handable is the deploy putting an address the customer controls where
+ * the supply and the authority land.
+ *
+ * On an EVM chain that address is a constructor argument, so every EVM chain is
+ * sellable (see test/owner-address.test.ts, which holds the constructor itself).
+ * On Solana it is fixed when the mint is created and moving it requires
+ * `SetAuthority`, which keyvault refuses by design — so the supply and the power
+ * to inflate it would stay with the vault. That is the whole rule, and these
+ * tests state it in that direction rather than by listing ids.
+ *
+ * This file previously asserted that every tier was suspended. That was true and
+ * is not any more; the assertion that replaced it is the one that was meant all
+ * along.
+ */
+
+const SOLANA_CHAINS = SUPPORTED_CHAINS.filter((c) => c.family === 'solana').map((c) => c.id)
+const EVM_CHAINS = SUPPORTED_CHAINS.filter((c) => c.family === 'evm').map((c) => c.id)
+
+test('a chain is sellable exactly when the deploy can name the customer as owner', () => {
+ assert.ok(EVM_CHAINS.length > 0, 'no EVM chains — this test would prove nothing')
+ assert.ok(SOLANA_CHAINS.length > 0, 'no Solana chains — this test would prove nothing')
+
+ for (const id of EVM_CHAINS) {
+ assert.equal(
+ chainSuspension(id),
+ null,
+ `${id} is suspended, but an EVM deploy takes the owner as a constructor argument`,
+ )
+ }
+ for (const id of SOLANA_CHAINS) {
+ const suspension = chainSuspension(id)
+ assert.ok(suspension, `${id} is on sale and its mint authority cannot be handed over (CF-01)`)
+ assert.equal(suspension.code, 'chain_suspended')
+ assert.equal(suspension.chainId, id)
+ }
+})
+
+/**
+ * The gate is an allowlist of families, not a denylist of chains, so a chain
+ * whose family nobody has thought about is refused rather than sold. A denylist
+ * would ship the next non-EVM chain open by default — which is precisely how the
+ * original defect reached three tiers at once.
+ */
+test('a chain nobody has listed is suspended', () => {
+ assert.equal(isChainSuspended('a-chain-added-after-this-was-written'), true)
+})
+
+/**
+ * The refusal is read by a customer standing in front of a package they wanted
+ * to buy. "Unavailable" reads as an outage to wait out; this has to say what
+ * they would not get, and that the alternative exists.
+ */
+test('the refusal names what the customer would not get, and points at what works', () => {
+ const suspension = chainSuspension('solana')!
+ const said = suspension.error.toLowerCase()
+ for (const word of ['mint authority', 'wallet you control', 'evm']) {
+ assert.ok(said.includes(word), `the refusal does not mention "${word}"`)
+ }
+})
+
+/**
+ * A tier is suspended only when every chain it could target is. All three sell
+ * ERC-20, so none of them is — the storefront shows three live packages with one
+ * paused chain inside the one that reaches it, which is the truth. Deriving it
+ * rather than listing it is what keeps the catalog and the gate from disagreeing.
+ */
+test('no tier is suspended, because every tier can still be sold on an EVM chain', () => {
+ assert.ok(MINT_OFFERS.length > 0, 'the catalog is empty — this test would prove nothing')
+ for (const offer of MINT_OFFERS) {
+ assert.equal(
+ offerSuspension(offer.id),
+ null,
+ `${offer.id} is paused, but it sells at least one chain that can be delivered`,
+ )
+ const chains = chainsForOffer(offer.id)
+ assert.ok(chains.length > 0, `${offer.id} targets no chain at all`)
+ assert.ok(
+ chains.some((id) => !isChainSuspended(id)),
+ `${offer.id} would be sold with nothing behind it`,
+ )
+ }
+})
+
+test('the Foundry tier is the one that reaches SPL, and it is still sellable on EVM', () => {
+ const foundry = chainsForOffer('foundry')
+ assert.ok(
+ foundry.some((id) => SOLANA_CHAINS.includes(id)),
+ 'foundry no longer includes Solana — this test is describing the wrong catalog',
+ )
+ assert.ok(foundry.some((id) => !isChainSuspended(id)))
+ assert.equal(isOfferSuspended('foundry'), false)
+})
+
+/** An offer id no catalog entry claims targets no chain, so it cannot be sold. */
+test('an offer id nobody has listed is suspended', () => {
+ assert.equal(isOfferSuspended('a-tier-added-after-this-was-written'), true)
+})
+
+// ------------------------------------------------------ where the gate is read
+
+/**
+ * CF-01. A correct gate that a route forgets to ask is not a gate, and the one
+ * route that forgot was the expensive one to forget.
+ *
+ * `/tokens` and `/pay` were gated first, because they are where Shards move.
+ * `/provision` was left open on purpose, under the reasoning that it runs only
+ * on an order that has already been debited and there is no refund path back to
+ * forge-pay — so refusing it would keep the money and hand back nothing. That
+ * reasoning is right about refunds and wrong about this route. `/provision`
+ * takes nothing and delivers nothing; what it does is answer with a funding
+ * address and the sentence "send gas to the funding address, then call
+ * /deploy". On a suspended chain that is an instruction to spend real native
+ * currency that can never come back — the vault signs contract creations and
+ * nothing else, so no transaction can move it out of the deployer again — for a
+ * deploy `/deploy` refuses permanently. The order was going to rest at `paid`
+ * either way; the only thing the open route added was the loss.
+ *
+ * Asserted over the source for the same reason as
+ * test/owner-address.test.ts's last case: these routes read a database and this
+ * service's tests deliberately open no connection. What is being defended is
+ * that each handler asks, and — for `/provision` — that it asks BEFORE it
+ * writes a status or mints an address, because a gate after `createAddress` has
+ * already produced the thing that does the harm.
+ */
+test('every route that can take money or cost gas asks the gate', async () => {
+ const { readFile } = await import('node:fs/promises')
+ const source = await readFile(new URL('../src/routes/tokens.ts', import.meta.url), 'utf8')
+
+ // Each handler runs from its own path literal to the next route registration,
+ // so slicing between them is the whole of one handler and none of another.
+ const bounds = (path: string) => {
+ const start = source.indexOf(`'${path}'`)
+ assert.ok(start > 0, `${path} was renamed or removed — this test reads the wrong handler`)
+ const next = source.indexOf(' app.post', start + 1)
+ return source.slice(start, next > 0 ? next : undefined)
+ }
+
+ for (const path of ['/tokens', '/tokens/:id/pay', '/tokens/:id/provision', '/tokens/:id/deploy']) {
+ assert.ok(
+ bounds(path).includes('chainSuspension('),
+ `${path} never asks chainSuspension — it can be driven on a chain that cannot deliver (CF-01)`,
+ )
+ }
+
+ const provision = bounds('/tokens/:id/provision')
+ const gate = provision.indexOf('chainSuspension(')
+ for (const after of ['await updateOrder(', 'await createAddress(']) {
+ const at = provision.indexOf(after)
+ assert.ok(at > 0, `/provision no longer calls ${after} — this test is describing the wrong route`)
+ assert.ok(at > gate, `/provision reaches ${after} before it asks the gate (CF-01)`)
+ }
+})
diff --git a/services/forge-mint/tsconfig.json b/services/forge-mint/tsconfig.json
index 564a599..7680f99 100644
--- a/services/forge-mint/tsconfig.json
+++ b/services/forge-mint/tsconfig.json
@@ -1,4 +1,4 @@
{
"extends": "../../tsconfig.base.json",
- "include": ["src"]
+ "include": ["src", "test"]
}