Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
446 changes: 334 additions & 112 deletions MAP.md

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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
Expand Down
81 changes: 71 additions & 10 deletions apps/forge-mint/src/lib/forgemint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,67 @@ 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
note?: string
}

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
Expand All @@ -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. */
Expand All @@ -46,20 +98,29 @@ export interface Capabilities {

// ---- catalog (public) -----------------------------------------------------

export const getChains = () => mint<SupportedChain[]>('/chains', { auth: false })
export const getOffers = () => mint<MintOffer[]>('/offers', { auth: false })
export const getChains = () => mint<CatalogChain[]>('/chains', { auth: false })
export const getOffers = () => mint<CatalogOffer[]>('/offers', { auth: false })

// ---- orders (authed) ------------------------------------------------------

export const getCapabilities = () => mint<Capabilities>('/capabilities')

export const listTokens = () => mint<TokenOrder[]>('/tokens')
export const getToken = (id: string) => mint<TokenOrder>(`/tokens/${id}`)
export const listTokens = () => mint<MintTokenOrder[]>('/tokens')
export const getToken = (id: string) => mint<MintTokenOrder>(`/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<MintTokenOrder>('/tokens', { method: 'POST', body: input })

export const createToken = (input: CreateTokenOrderInput) =>
mint<TokenOrder>('/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<MintTokenOrder>(`/tokens/${id}/owner`, { method: 'POST', body: { ownerAddress } })

export const payToken = (id: string) => mint<TokenOrder>(`/tokens/${id}/pay`, { method: 'POST' })
export const payToken = (id: string) => mint<MintTokenOrder>(`/tokens/${id}/pay`, { method: 'POST' })

export const provisionToken = (id: string) =>
mint<ProvisionResult>(`/tokens/${id}/provision`, { method: 'POST' })
Expand Down
Loading
Loading