Skip to content

[Journey] Transfer native tokens between public and private states on LEZ inc Private Donations #322

Description

@moudyellaz

What the user achieves

A sender credits a private account (regular or PDA) they do not control, identifying the recipient only by the recipient's published (NPK, VPK) plus a chosen identifier. One reusable keypair backs up to 2^128 distinct accounts via that identifier, so a recipient can hand the same key to many senders and still receive into separate accounts. The recipient discovers and spends the funds later by scanning with wallet account sync-private.

Why it matters

This is the building block for real payment flows: you can pay or donate to a private recipient without an interactive setup and without the recipient pre-registering one account per sender. Before it, one (NPK, VPK) keypair mapped to exactly one account, so a recipient could not safely receive from multiple independent senders.

Key components

  • AccountId diversification (lee/state_machine/core/src/nullifier.rs, program.rs): for_regular_private_account(npk, identifier) and for_private_pda(program_id, seed, npk, identifier) make the account id SHA256(prefix || npk || ... || identifier), so one key backs many accounts; commitment/nullifier/nonce formulas now key off the account id rather than the NPK.
  • Privacy-preserving circuit (program_methods/guest/src/bin/privacy_preserving_circuit/): the sender's account enters at visibility mask 1 (NSK known), the recipient slot at mask 2 (unauthorized); the circuit initializes the recipient account with a deterministic nullifier and encrypts the post-state to a shared secret via ephemeral ECDH against the recipient VPK.
  • PrivateAccountKind header (program.rs): a fixed-size ciphertext header (Regular(identifier) vs Pda { program_id, seed, identifier }) the recipient decrypts to reconstruct exactly which account id an incoming transfer targets.
  • Wallet account manager (lez/wallet/src/account_manager.rs): the AccountIdentity enum carries PrivateForeign and PrivatePdaForeign (know the recipient key, not the NSK) for sending, and PrivateOwned/PrivatePdaOwned for spending after discovery.
  • Wallet CLI (lez/wallet/src/cli/): account new private-accounts-key publishes a reusable key, auth-transfer send performs the foreign transfer, account sync-private scans and decrypts incoming funds.

Repository

https://github.com/logos-blockchain/logos-execution-zone

Runtime target

testnet v0.2

Prerequisites

  • OS: Linux or macOS (macOS needs full Xcode with the Metal toolchain for the Risc0 guest build, not just command-line tools).
  • Hardware: tests run with RISC0_DEV_MODE=1 (skips real proving) on a normal dev machine; real proving needs substantial hardware.
  • Tools: Rust toolchain, cargo, recipes in the justfile.
  • Release/commit: regular-account diversification (PR #447) is in v0.2.0-rc4. The private-PDA extension (PR #464: PrivatePdaForeign, PrivateAccountKind header) landed after rc4, so for the PDA path build from main. PR #447 broke wallet storage; a pre-#447 wallet must be re-synced.
  • Accounts/keys: a funded sender account (public or private); the recipient must publish (NPK, VPK), e.g. via account new private-accounts-key or account show-keys.

Commands and expected outputs

The recipient publishes a reusable key, a sender credits it at a chosen identifier, the recipient discovers and (optionally) spends it.


# Recipient: publish a reusable keypair (no account bound); prints chain_index, NPK, VPK
wallet account new private-accounts-key

# Recipient: export the keys to share with senders
wallet account show-keys --account-id <recipient-account-or-label>
# prints NPK (hex) then VPK (hex); save to a file to use --to-keys

# Sender: credit the recipient's private account at a chosen identifier
wallet auth-transfer send \
  --from <sender-account-or-label> \
  --to-npk <recipient-npk-hex> \
  --to-vpk <recipient-vpk-hex> \
  --to-identifier <u128> \
  --amount 100
# (or --to-keys <file> instead of --to-npk/--to-vpk)

# Recipient: scan and decrypt incoming funds
wallet account sync-private

# Recipient: spend the discovered account (now PrivateOwned)
wallet auth-transfer send \
  --from <recipient-account> --to-npk <next-npk> --to-vpk <next-vpk> \
  --to-identifier <u128> --amount 50


Note: sending to an existing private account does not append to a balance. The circuit initializes a fresh account at `AccountId = SHA256(prefix || npk || identifier)`, emits its commitment and a deterministic nullifier, and encrypts the post-state to the recipient. Each foreign send is a separate account, distinguished by identifier.

Tests that prove it works:
- Foreign send (mask 1 sender, mask 2 recipient), both commitments land in state: `integration_tests/tests/auth_transfer/private.rs::private_transfer_to_foreign_account`.
- Full claim lifecycle (send to a foreign account, recipient `sync-private`, recipient sees balance 100): `integration_tests/tests/auth_transfer/private.rs::private_transfer_to_owned_account_using_claiming_path`.
- One NPK, two identifiers, two distinct discovered accounts (balances 100 and 200): `integration_tests/tests/auth_transfer/private.rs::shielded_transfers_to_two_identifiers_same_npk`.
- Private-PDA receive and spend across diversified identifiers: `integration_tests/tests/private_pda.rs::private_pda_family_members_receive_and_spend`.

Success command

RISC0_DEV_MODE=1 cargo test --release -p integration_tests shielded_transfers_to_two_identifiers_same_npk

Expected result

the test passes. The sender s post-spend commitment matches and both commitments are in sequencer state; after the recipient runs `sync-private`, the discovered account asserts `balance == 100` (`private.rs:228`).

Failure modes and limits

Top failure modes:

  1. Recipient never sees the funds after a send. Cause: wrong identifier, wrong NPK/VPK, or sync-private not run. The recipient must scan with the exact key, and reconstructs the account id from the decrypted PrivateAccountKind header. Fix: confirm the sender used the published NPK/VPK and the agreed identifier, then re-run account sync-private.
  2. Identifier collision: two senders independently pick the same identifier for the same NPK. Both target the same account id, so the second transfer collides at the commitment/nullifier layer and that transaction fails in the state machine (check_commitments_are_new / check_nullifiers_are_valid in lee/state_machine/src/state.rs). Workaround: senders should pick high-entropy identifiers (the reusable-key flow is designed for sender-chosen identifiers).

Out of scope / limits for this release:

  • No balance append. A recipient who gets N transfers holds N separate accounts; spending them all costs N inputs (proof/tx cost grows with N). There is no automatic consolidation path.
  • Sender-chosen identifiers, not shared-secret-derived. Collision and pre-initialization griefing surfaces remain open (see #447 future work on deterministic, ECDH-derived identifiers / stealth addresses).
  • The private-PDA path (#464) is not in a released tag yet; build from main.

GitHub handle

@moudyellaz

Discord handle

moudyellaz

Existing docs or specs

PRs: #447 (regular-account diversification, reusable key), #464 (PDA diversification, PrivateAccountKind header, PrivatePdaForeign).

Hardware requirements

No response

Estimated time to complete

No response

Security notes

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    type:journeyA user journey document (the primary deliverable).

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions