Skip to content

Mint credits owner under Poseidon(creator_pubkey); spec mandates SHA-256 address — minted assets invisible & unspendable to wallets #226

Description

@TaprootFreak

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-666let owner_from_creator = builder.hash_n_to_hash_no_pad::<PoseidonHash>(creator_pubkey_limbs.to_vec());
  • program-plonky2/src/circuit/main.rs:678-679let owner_eq = builder.is_equal(owner_from_creator.elements[i], owner.elements[i]); is_minting = builder.and(is_minting, owner_eq);
  • main.rs:701-705is_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:966let 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:42PoseidonHash::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)

  1. Two-phase creator-signed mint into a fresh wallet completes server-side (Mint prove: okmint_commit_flow: state.update persisted).
  2. GET /api/balance?address=<sha256(pubkey)>&asset_id=<aid>{"balance":0,...} (HTTP 200) — credit not visible at the SHA-256 address.
  3. GET /api/balance?address=<poseidon(pubkey)>&asset_id=<aid> → shows the minted balance — confirming the credit is under the Poseidon key.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingprotocolCore protocol / circuit changes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions