Skip to content
8 changes: 6 additions & 2 deletions docs/offline-audit-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The online creation flow also detects proposals another owner already created an
Initial verified-store mismatches show “Vault data isn’t up to date” with **Retry / Cancel**. Retry restarts online preparation or offline request export from current state and indexed stores; it never bypasses root validation or retries automatically. Network and unrelated errors are not classified as store mismatches.


**Pre-broadcast state checks:** signed uploads retain their existing binding and policy checks, then compare their actual app-state preconditions with current node state. Stale approval/execution files offer an eligibility-checked fresh export. Imported propose files require review of the original form. Requests and signed responses remain version 1. See [design and boundaries](transaction-coordination-design.md).
**Pre-broadcast state checks:** signed uploads retain their existing binding and policy checks, then compare their actual app-state preconditions with current node state. A CREATE_CHILD response that deploys into a bare child account (one a payment created, which the node reports with `zkappState: null`) is checked against all-zero state, as the chain checks it; an answer that omits the field blocks the broadcast. Stale approval/execution files offer an eligibility-checked fresh export. Imported propose files require review of the original form. Requests and signed responses remain version 1. See [design and boundaries](transaction-coordination-design.md).

This document describes the **air-gapped signing path**: the bundle
export/import UI inside the web app (`ui/lib/offline-signing.ts`,
Expand Down Expand Up @@ -97,7 +97,10 @@ Propose bundles add the form's `NewProposalInput` and a freshly re-fetched
`configNonce`; execute bundles add per-receiver existence
(`receiverAccountExists`) and, for child actions, the child's address and
event history. Field-by-field details are in the bundle format reference
below.
below. The export fails when the node answers an account query with an error,
or with an account that lacks `nonce`, `balance` or `zkappState`: the CLI
decides funding and deploy targets from these snapshots, so a guess here would
produce a transaction that fails on chain.

After building, the UI surfaces **pre-transfer warnings** from data already in
the bundle (missing fee-payer account, balance under 1 MINA, account-creation
Expand Down Expand Up @@ -293,6 +296,7 @@ the offline CLI restores the resulting leaves without replaying events.
| `input.childMultiSigEnable` | `boolean` | enableChildMultiSig |
| `input.createChildConfigHash` | `string` | createChild: Poseidon(ownersCommitment, threshold, numOwners) |
| `input.childPrivateKey` | `string` | createChild only — signs the child's deploy update (see threat model) |
| `accounts[input.childAccount]` | `FetchedAccount?` | createChild: present when the child's bare account already exists; the CLI then deploys into it without `fundNewAccount` (`deployTargetFromSnapshot`). A snapshot with a verification key or app state is refused |
| `input.childOwners` / `input.childThreshold` | `string[]` / `number` | createChild config |
| `input.expirySlot` | `number` | UInt32 inclusion deadline for propose, approve, and execute; 0 = no expiry |
| `input.memo` | `string` | Plaintext memo — hashed into the proposal **and** attached as the broadcast memo |
Expand Down
1 change: 1 addition & 0 deletions docs/security-audit-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ attest an independently built deployment artifact.
| 7 | **F-2026-19133: a root vault has no key-loss recovery path.** If fewer independently held owner keys remain available than the threshold, no proposal can lower the threshold, replace an owner, or move funds. Operators must choose an owner count above the threshold by a redundancy margin they accept, keep those keys independently controlled and recoverable, and understand that losing too many keys can permanently strand the root vault's funds. A child vault has parent-authorized reclaim/destroy paths; a root vault does not. | Accepted product design; no emergency recovery key or timelocked bypass is provided |
| 8 | **Vault events are unauthenticated.** Events need no permission, so any account update can attach events to a vault. The indexer keeps only events whose emitting account update is `Proof`-authorized (the archive API's `authorizationKind`): those come from the vault's own methods, proved against its verification key, which admission already checks is canonical. Undecodable events from its own updates, and events that fail to apply, are recorded with `applyError` and no state change, so none of these can halt indexing or vanish silently. The signature-authorized `deployed` event is not indexed; nothing reads it. Residual: the filter relies on the network verifying proofs (a dev chain with proof verification disabled would accept a dummy proof), and an archive without `authorizationKind` (older than Archive-Node-API v0.0.8) makes indexing fail closed. Clients still check rebuilt stores against the chain before proving. | Mitigated; residual accepted |
| 9 | **The verification-key pin is scoped to a transaction version.** `setVerificationKey` is `impossibleDuringCurrentVersion()`: an `Impossible` permission paired with the transaction version o1js stamps at deploy (`4`). Mina RFC 0052 reads `Impossible` and `Proof` verification-key permissions as `Signature` once the network's transaction version is higher, so a zkApp whose proofs no longer verify can be given a new key by its account key. Two consequences for a vault. First, the supported creation flow generates that account key in the browser and discards it, so after a proof-system fork such a vault cannot be migrated by anyone and its proof-only `send` and `editState` leave the funds unreachable; a creator who kept the key out of band is then the one party able to migrate it, or to hijack it. Second, `setPermissions: impossible()` carries no version, so the pin cannot be re-stamped in-protocol; only a hard-fork migration can restore it. The backend compares the stored VK hash and `txnVersion` with the canonical values at admission, and the online UI on each vault load, so a swapped key on an already-tracked vault is caught by the UI, not the backend. | Accepted; a fork that raises the transaction version needs a coordinated migration (new o1js, new canonical VK, re-pinning in the fork). The existing UI and backend builds compare a vault's stored `txnVersion` with their own build-time constant, not with the network's current version, so they do not notice the bump and keep accepting the old pin; builds updated to expect the new version reject every pre-fork pin |
| 10 | **A deployment can be griefed once per address.** The vault address is public once its deploy transaction reaches the mempool, and anyone can create the bare account first by paying into it; a deployment that declares a new account then fails with `Invalid_fee_excess`, costing the deployer the fee and the proving time (for an air-gapped CREATE_CHILD, a rebuilt bundle too). The attacker gains no control and pays the creation fee. Both clients deploy into an existing bare account without the creation fee, so the same address can be retried while its key is still at hand (the offline bundle keeps it; the web wizard keeps it only until the page is left), and refuse an address that already carries a verification key or app state; a failed SubVault creation lifts the browser's vault lock after 20 minutes instead of a day. | Accepted; the first attempt stays exposed. Creating the account in a separate, confirmed transaction before deploying would remove it |

Operational and UI-level quirks (state staleness windows, preview-cache behavior) are tracked in
[`KNOWN_ISSUES.md`](../KNOWN_ISSUES.md). Infrastructure risks and their mitigations live in the
Expand Down
6 changes: 5 additions & 1 deletion docs/transaction-coordination-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ state fields actually constrained by the transaction, including parent/child
accounts and token IDs. Null preconditions are ignored. Repeated account updates
are processed in order, applying their known state writes before checking later
updates. An unconstrained deployment update can initialize a missing account before
setup in the same transaction.
setup in the same transaction. The node reports an account that exists without
zkApp state (a plain payment created it) with `zkappState: null`; the check reads
it as all zeros, as the chain does, so a deployment into it is checked like any
other, and only an `isNew` precondition on it is stale. An answer that omits the
field is treated as unavailable.

- Current: continue sending, without an extra confirmation.
- Stale: do not send. Refresh eligibility and offer an explicit recovery action.
Expand Down
18 changes: 14 additions & 4 deletions docs/ui-audit-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Online creation checks whether the exact proposal already exists before requesti
Initial verified-store mismatches show “Vault data isn’t up to date” with **Retry / Cancel**. Retry restarts online preparation or offline request export from current state and indexed stores; it never bypasses root validation or retries automatically. Network and unrelated errors are not classified as store mismatches.


**Pre-broadcast state checks:** the actual transaction app-state preconditions are compared with fresh node state before sending (before Auro wallet handoff). Stale transactions require explicit, eligibility-checked recovery. Node failures block broadcast; a successful check does not guarantee inclusion. See [design and boundaries](transaction-coordination-design.md). Permission-loading notices on vault and proposal pages are delayed and neutral; action gates remain fail-closed.
**Pre-broadcast state checks:** the actual transaction app-state preconditions are compared with fresh node state before sending (before Auro wallet handoff). Stale transactions require explicit, eligibility-checked recovery. A deploy target that exists without zkApp state (the node answers `zkappState: null` for an account a payment created) is read as all zeros, as the chain reads it, so a deployment into it is checked like any other; an answer that omits the field blocks the broadcast. Node failures block broadcast; a successful check does not guarantee inclusion. See [design and boundaries](transaction-coordination-design.md). Permission-loading notices on vault and proposal pages are delayed and neutral; action gates remain fail-closed.

Online progress follows the submission boundary: Auro shows “Checking latest vault state…” after proving, then “Waiting for wallet confirmation…” only after the check passes. Ledger requests wallet confirmation first, checks the signed transaction, then shows “Broadcasting transaction…”. Early store/root mismatches offer explicit Retry / Cancel; the post-proof check uses the recovery panel.

Expand Down Expand Up @@ -268,7 +268,12 @@ A guard that is deployed but not yet configured could be controlled by whoever
calls `setup()` first.
- Top-level vaults use the atomic `deployAndSetupContract` — one tx doing
`fundNewAccount` + `deploy` + `setup`; the worker exposes no separate
deploy-only or setup-only API.
deploy-only or setup-only API. The creation fee is paid only when the node
has no account at the address (`classifyDeployTarget`, `lib/deploy-target.ts`):
anyone can create the bare account first by paying into it, which fails a
deployment that declares a new account, so a bare account is deployed into
as it is, and an address that already carries a verification key or app
state is refused. The same rule applies to the child in CREATE_CHILD.
- CREATE_CHILD spans two transactions by design: the propose tx does
`deploy(child)` + `reserveForParent(child)` + `propose(parent)` atomically
(`worker.ts:979-1003`); the later `executeSetupChild` is bound on-chain to
Expand All @@ -279,7 +284,9 @@ calls `setup()` first.
- Account-creation fees on execute are counted from the hash-bound
`proposalStruct.receivers`, never the raw backend array
(`worker.ts:1142-1153`), so indexer rows beyond `MAX_RECEIVERS` can't
inflate the executor-signed fee.
inflate the executor-signed fee. A receiver counts as new only when the
node answers "no such account" (`receiverExists`); any other node error
stops the build, since a wrong guess fails the transaction after proving.

**3. Indexer-supplied data feeding into signed transactions.**
The backend is untrusted (see threat model), yet its data rebuilds the Merkle
Expand Down Expand Up @@ -383,7 +390,10 @@ never reports the earlier tx's failure. An accepted report marks its record
`recorded`; reconciliation then marks it `untracked` as soon as the backend's
hash for that action is not this record's (`backendNoLongerTracks`). A report
the backend never answered within two minutes, say after a reload mid-report,
is marked the same way (`reportUnanswered`).
is marked the same way (`reportUnanswered`). SubVault creation records (`kind: 'create'` with a `createChild` summary) use
the same 20-minute window: a failed creation emits no proposal, and the
best-chain probe covers only recent blocks, so nothing else would ever clear
them.

**5. Ephemeral zkApp key lifecycle & local storage.**
The only private key the UI holds is the in-browser zkApp deploy key
Expand Down
2 changes: 1 addition & 1 deletion e2e/unit/offline-checkpoint-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('offline checkpoint export', () => {
if (String(input).includes('/events')) throw new Error('Unexpected full history request');
const address = JSON.parse(String(init?.body)).variables.publicKey;
calls.push(`account:${address}`);
return Response.json({ data: { account: { publicKey: address, zkappState: [] } } });
return Response.json({ data: { account: { publicKey: address, nonce: '0', balance: { total: '1000000000' }, zkappState: [] } } });
}) as typeof fetch;
const params = { contractAddress: 'vault', feePayerAddress: 'owner' };
const builders = [
Expand Down
28 changes: 27 additions & 1 deletion offline-cli/src/build-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,23 @@ export function countNewReceiverAccounts(
return count;
}

/**
* Decides how to deploy a child to an address from the bundle's snapshot of
* it. Anyone can create the bare Mina account first by paying into the
* address, and a deployment that declares a new account then fails. With a
* snapshot of a bare account the child is deployed into it without the
* creation fee. A snapshot that carries a verification key or app state is
* refused: only the address's own key could have put them there.
*/
export function deployTargetFromSnapshot(snapshot: BundleAccount | undefined): 'new' | 'existing' {
if (!snapshot) return 'new';
const hasState = (snapshot.zkappState ?? []).some((value) => String(value) !== '0');
if (snapshot.verificationKey || hasState) {
throw new Error('The child address already holds a zkApp. Create the SubVault with a fresh address.');
}
return 'existing';
}

function buildReceiversForProposal(input: NewProposalInput): InstanceType<typeof Receiver>[] {
if (input.txType === 'transfer' || input.txType === 'allocateChild') {
return buildTransferReceivers(input.receivers ?? []);
Expand Down Expand Up @@ -817,8 +834,17 @@ export async function handlePropose(
let childKey: InstanceType<typeof PrivateKey> | null = null;
let childOwnerStore: InstanceType<typeof OwnerStore> | null = null;
let childPaddedOwners: InstanceType<typeof PublicKey>[] | null = null;
// A snapshot of the child address means someone created its bare account
// already; the deploy then must not fund it again. The address comes from
// the key that signs the deploy, and must be the one the proposal names.
let childTarget: 'new' | 'existing' | null = null;
if (isCreateChild) {
childKey = PrivateKey.fromBase58(input.childPrivateKey!);
const childAddress = childKey.toPublicKey().toBase58();
if (childAddress !== input.childAccount) {
throw new Error('The createChild key does not match input.childAccount');
}
childTarget = deployTargetFromSnapshot(bundle.accounts[childAddress]);
childOwnerStore = new OwnerStore();
for (const addr of input.childOwners!) childOwnerStore.addSorted(PublicKey.fromBase58(addr));
childPaddedOwners = [...childOwnerStore.owners];
Expand All @@ -835,7 +861,7 @@ export async function handlePropose(
if (isCreateChild && childKey && childOwnerStore && childPaddedOwners) {
const childAddress = childKey.toPublicKey();
const childZkApp = new MinaGuard(childAddress);
AccountUpdate.fundNewAccount(proposer);
if (childTarget === 'new') AccountUpdate.fundNewAccount(proposer);
await childZkApp.deploy();
await childZkApp.reserveForParent(
contractAddress,
Expand Down
8 changes: 8 additions & 0 deletions offline-cli/src/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ function renderBody(
childAddress?: string | null;
childOwners?: string[] | null;
childThreshold?: string | number | null;
/** createChild propose: whether the bundle snapshots the child account, so the CLI skips the creation fee. */
childAccountExists?: boolean | null;
reclaimAmount?: string | null;
enableMultiSig?: boolean | null;
},
Expand All @@ -159,6 +161,9 @@ function renderBody(
for (const o of src.childOwners) out.push(` ${o}`);
}
out.push(line('Threshold', src.childThreshold ?? '(unknown)'));
if (src.childAccountExists != null) {
out.push(line('Child account', src.childAccountExists ? 'exists, no creation fee' : 'new, 1 MINA creation fee'));
}
return out;
}
case 'reclaimChild':
Expand Down Expand Up @@ -265,6 +270,9 @@ export function renderBundleSummary(bundle: OfflineBundle): string {
childAddress: input.childAccount ?? null,
childOwners: input.childOwners ?? null,
childThreshold: input.childThreshold ?? null,
childAccountExists: input.txType === 'createChild' && input.childAccount
? Boolean(b.accounts?.[input.childAccount])
: null,
reclaimAmount: input.reclaimAmount ?? null,
enableMultiSig: input.childMultiSigEnable ?? null,
}),
Expand Down
Loading
Loading