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} +

+ ) : null} {chain ? ( @@ -418,7 +502,39 @@ export function Create() {

{fieldErrors.supply}

) : (

- 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. */} +
+ + setOwnerAddress(e.target.value)} + /> + {fieldErrors.ownerAddress ? ( +

{fieldErrors.ownerAddress}

+ ) : ( +

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

- View packages
+ {/* 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() {
))} @@ -198,15 +223,28 @@ export function Home() { {chains ? (
{chains.map((c) => ( -
+

{c.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} + )}
Testnet · {c.testnet} Mainnet
+ {c.suspended && c.suspendedReason ? ( +

{c.suspendedReason}

+ ) : null}
))}
@@ -217,11 +255,20 @@ export function Home() { {/* ---- Closing CTA ---- */}
-

Ready to forge your token?

+

+ {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.'}

-
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 + )} + +
{notice ? ( @@ -205,8 +239,87 @@ export function Order() {
) : 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)} + /> + +
+ ) : 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. +

+ )} +
+ ) : null} + {/* ---- Step 1: Pay ---- */} - {status === 'awaiting_payment' ? ( + {!chainPaused && status === 'awaiting_payment' ? (

1 · Pay with Shards

@@ -228,12 +341,31 @@ export function Order() { ) : null} {/* ---- Step 2: Provision ---- */} - {status === 'paid' || status === 'provisioning' ? ( + {!chainPaused && (status === 'paid' || status === 'provisioning') ? (

2 · Provision the deployer

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.

+ {/* 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"] }