Summary
The Multi-Asset mint credits the creator's account under owner = Poseidon(creator_pubkey) (hash_bytes), but the normative spec defines the account address/owner as H(Pk₀) = SHA-256(pubkey₀). The mint owner is the only Poseidon-derived address in an otherwise fully SHA-256 address ecosystem (SDK, username-claim, SMT commitment key, receive path, docs).
Result: a minted asset is invisible and unspendable to any spec-conformant (SHA-256-addressed) wallet — the balance lands under a Poseidon key the wallet never queries.
Impact
GET /api/balance?address=<sha256(pubkey)>&asset_id=<aid> returns balance: 0 after a successful mint — the credit sits under Poseidon(pubkey).
- The creator cannot spend the minted supply: a send from the minted (Poseidon) account fails the prove leg, while sends between SHA-256-keyed accounts succeed.
- Affects every creator using the SDK / spec-conformant address derivation. User-facing, core mint flow.
Root cause
The in-circuit issuer-mint gate hard-binds the owner to the Poseidon hash of the creator pubkey, and the off-circuit code matches it:
program-plonky2/src/circuit/main.rs:665-666 — let owner_from_creator = builder.hash_n_to_hash_no_pad::<PoseidonHash>(creator_pubkey_limbs.to_vec());
program-plonky2/src/circuit/main.rs:678-679 — let owner_eq = builder.is_equal(owner_from_creator.elements[i], owner.elements[i]); is_minting = builder.and(is_minting, owner_eq);
main.rs:701-705 — is_minting is mandatory for any minted balance > 0 (assert_zero(not_condition * not_minting * balance)), so a mint is only provable when owner == Poseidon(creator_pubkey).
- Off-circuit:
node/src/account_node.rs:966 — let owner = hash_bytes(creator_pubkey); and program-plonky2/src/types.rs AccountState::new derives owner = hash_bytes(initial_public_key).
hash_bytes is Poseidon: program-plonky2/src/hash.rs:42 — PoseidonHash::hash_no_pad(&elements).
The circuit comment at main.rs:661 even reads "owner_from_creator = H(creator_pubkey) == hash_bytes(pubkey)" — i.e. it interprets the protocol hash H as Poseidon. The normative spec says H (for addresses) is SHA-256.
What the normative spec says (SHA-256)
Per zk-coins/research README, zk-coins/docs is the target-design specification (research = archived drafts). The docs are unambiguous:
docs/specification.md:188 — "General hash (addresses, off-circuit ids) → SHA-256" (in-circuit hash → Poseidon, :187).
docs/specification.md:281 — "Address = H(Pk₀) — SHA-256 of the initial spend public key … the protocol's only identity".
docs/specification.md:248 — "an account A has exactly one address, address = H(Pk₀)".
docs/architecture/addressing.md:16 — "The first 8 hex characters of the account's SHA-256 hash become the address: sha256(pubkey_0)".
Everything else already follows SHA-256 — SDK (zk-coins/sdk src/derivation.ts address = sha256(pk0)), username-claim gate (router.rs:3148 sha256(pubkey) == address), the SMT commitment key (state.rs sha256(commitment_pubkey)), and the receive path (keys by the wire recipient). The mint owner is the lone outlier.
Reproduction (against DEV, dev-api.zkcoins.app)
- Two-phase creator-signed mint into a fresh wallet completes server-side (
Mint prove: ok → mint_commit_flow: state.update persisted).
GET /api/balance?address=<sha256(pubkey)>&asset_id=<aid> → {"balance":0,...} (HTTP 200) — credit not visible at the SHA-256 address.
GET /api/balance?address=<poseidon(pubkey)>&asset_id=<aid> → shows the minted balance — confirming the credit is under the Poseidon key.
- A send from the (Poseidon) minted account → job ends
failed / "prove failed" (500, prove_account_update_with_in_and_out_coins_and_sources failed), while unrelated SHA-256-account sends complete successfully in the same window.
Note: the raw prover error for (4) is mapped to the generic "prove failed" and not logged verbatim, so the exact internal cause of the spend failure was not isolated; it is consistent with the minted account living on the wrong (Poseidon) key relative to the SHA-256-based send/commitment machinery, but should be re-validated after the fix.
Proposed fix
Derive the mint owner as SHA-256(creator_pubkey) to match the spec and the rest of the address ecosystem:
- In-circuit mint gate (
circuit/main.rs:665) — compute owner_from_creator via the in-circuit SHA-256 of the pubkey instead of Poseidon. The spec explicitly budgets one in-circuit SHA-256 per mint for this binding.
- Off-circuit owner derivation —
account_node.rs:966 (prepare_mint) and AccountState::new (types.rs) → SHA-256.
This makes minted accounts consistent with received accounts, the SDK, username claims, and the SMT — i.e. visible and spendable by spec-conformant wallets.
Blast radius / open points
- Requires a circuit change → verifier-key / proof regeneration; coordinate with any deployed proving artifacts.
- Existing DEV state may hold Poseidon-keyed minted accounts (orphaned after the fix) → consider a reset/migration on DEV.
- Cross-check the
asset_id derivation against the spec while here: docs/specification.md:281 includes issuance_version in the AssetId preimage; program-plonky2/src/types.rs::calculate_asset_id does not appear to — likely a separate divergence to confirm.
How it surfaced
The node/tests/api_remote.rs E2E suite (correctly using the SHA-256 address_hex() per spec) fails its mint roundtrips with balance never reached mint amount; got 0 against DEV — directly exposing this bug.
Summary
The Multi-Asset mint credits the creator's account under
owner = Poseidon(creator_pubkey)(hash_bytes), but the normative spec defines the account address/owner asH(Pk₀) = SHA-256(pubkey₀). The mint owner is the only Poseidon-derived address in an otherwise fully SHA-256 address ecosystem (SDK, username-claim, SMT commitment key, receive path, docs).Result: a minted asset is invisible and unspendable to any spec-conformant (SHA-256-addressed) wallet — the balance lands under a Poseidon key the wallet never queries.
Impact
GET /api/balance?address=<sha256(pubkey)>&asset_id=<aid>returnsbalance: 0after a successful mint — the credit sits underPoseidon(pubkey).Root cause
The in-circuit issuer-mint gate hard-binds the owner to the Poseidon hash of the creator pubkey, and the off-circuit code matches it:
program-plonky2/src/circuit/main.rs:665-666—let owner_from_creator = builder.hash_n_to_hash_no_pad::<PoseidonHash>(creator_pubkey_limbs.to_vec());program-plonky2/src/circuit/main.rs:678-679—let owner_eq = builder.is_equal(owner_from_creator.elements[i], owner.elements[i]); is_minting = builder.and(is_minting, owner_eq);main.rs:701-705—is_mintingis mandatory for any minted balance > 0 (assert_zero(not_condition * not_minting * balance)), so a mint is only provable whenowner == Poseidon(creator_pubkey).node/src/account_node.rs:966—let owner = hash_bytes(creator_pubkey);andprogram-plonky2/src/types.rsAccountState::newderivesowner = hash_bytes(initial_public_key).hash_bytesis Poseidon:program-plonky2/src/hash.rs:42—PoseidonHash::hash_no_pad(&elements).The circuit comment at
main.rs:661even reads "owner_from_creator = H(creator_pubkey) == hash_bytes(pubkey)" — i.e. it interprets the protocol hashHas Poseidon. The normative spec saysH(for addresses) is SHA-256.What the normative spec says (SHA-256)
Per
zk-coins/researchREADME,zk-coins/docsis the target-design specification (research = archived drafts). The docs are unambiguous:docs/specification.md:188— "General hash (addresses, off-circuit ids) → SHA-256" (in-circuit hash → Poseidon,:187).docs/specification.md:281— "Address =H(Pk₀)— SHA-256 of the initial spend public key … the protocol's only identity".docs/specification.md:248— "an accountAhas exactly one address,address = H(Pk₀)".docs/architecture/addressing.md:16— "The first 8 hex characters of the account's SHA-256 hash become the address:sha256(pubkey_0)".Everything else already follows SHA-256 — SDK (
zk-coins/sdksrc/derivation.tsaddress = sha256(pk0)), username-claim gate (router.rs:3148sha256(pubkey) == address), the SMT commitment key (state.rssha256(commitment_pubkey)), and the receive path (keys by the wirerecipient). The mint owner is the lone outlier.Reproduction (against DEV, dev-api.zkcoins.app)
Mint prove: ok→mint_commit_flow: state.update persisted).GET /api/balance?address=<sha256(pubkey)>&asset_id=<aid>→{"balance":0,...}(HTTP 200) — credit not visible at the SHA-256 address.GET /api/balance?address=<poseidon(pubkey)>&asset_id=<aid>→ shows the minted balance — confirming the credit is under the Poseidon key.failed/"prove failed"(500,prove_account_update_with_in_and_out_coins_and_sources failed), while unrelated SHA-256-account sends complete successfully in the same window.Note: the raw prover error for (4) is mapped to the generic
"prove failed"and not logged verbatim, so the exact internal cause of the spend failure was not isolated; it is consistent with the minted account living on the wrong (Poseidon) key relative to the SHA-256-based send/commitment machinery, but should be re-validated after the fix.Proposed fix
Derive the mint owner as
SHA-256(creator_pubkey)to match the spec and the rest of the address ecosystem:circuit/main.rs:665) — computeowner_from_creatorvia the in-circuit SHA-256 of the pubkey instead of Poseidon. The spec explicitly budgets one in-circuit SHA-256 per mint for this binding.account_node.rs:966(prepare_mint) andAccountState::new(types.rs) → SHA-256.This makes minted accounts consistent with received accounts, the SDK, username claims, and the SMT — i.e. visible and spendable by spec-conformant wallets.
Blast radius / open points
asset_idderivation against the spec while here:docs/specification.md:281includesissuance_versionin theAssetIdpreimage;program-plonky2/src/types.rs::calculate_asset_iddoes not appear to — likely a separate divergence to confirm.How it surfaced
The
node/tests/api_remote.rsE2E suite (correctly using the SHA-256address_hex()per spec) fails its mint roundtrips withbalance never reached mint amount; got 0against DEV — directly exposing this bug.