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:
- 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.
- 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
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
lee/state_machine/core/src/nullifier.rs,program.rs):for_regular_private_account(npk, identifier)andfor_private_pda(program_id, seed, npk, identifier)make the account idSHA256(prefix || npk || ... || identifier), so one key backs many accounts; commitment/nullifier/nonce formulas now key off the account id rather than the NPK.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.program.rs): a fixed-size ciphertext header (Regular(identifier)vsPda { program_id, seed, identifier }) the recipient decrypts to reconstruct exactly which account id an incoming transfer targets.lez/wallet/src/account_manager.rs): theAccountIdentityenum carriesPrivateForeignandPrivatePdaForeign(know the recipient key, not the NSK) for sending, andPrivateOwned/PrivatePdaOwnedfor spending after discovery.lez/wallet/src/cli/):account new private-accounts-keypublishes a reusable key,auth-transfer sendperforms the foreign transfer,account sync-privatescans and decrypts incoming funds.Repository
https://github.com/logos-blockchain/logos-execution-zone
Runtime target
testnet v0.2
Prerequisites
RISC0_DEV_MODE=1(skips real proving) on a normal dev machine; real proving needs substantial hardware.cargo, recipes in thejustfile.v0.2.0-rc4. The private-PDA extension (PR #464:PrivatePdaForeign,PrivateAccountKindheader) landed after rc4, so for the PDA path build frommain. PR #447 broke wallet storage; a pre-#447 wallet must be re-synced.account new private-accounts-keyoraccount show-keys.Commands and expected outputs
Success command
RISC0_DEV_MODE=1 cargo test --release -p integration_tests shielded_transfers_to_two_identifiers_same_npk
Expected result
Failure modes and limits
Top failure modes:
sync-privatenot run. The recipient must scan with the exact key, and reconstructs the account id from the decryptedPrivateAccountKindheader. Fix: confirm the sender used the published NPK/VPK and the agreed identifier, then re-runaccount sync-private.check_commitments_are_new/check_nullifiers_are_validinlee/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:
main.GitHub handle
@moudyellaz
Discord handle
moudyellaz
Existing docs or specs
PRs: #447 (regular-account diversification, reusable key), #464 (PDA diversification,
PrivateAccountKindheader,PrivatePdaForeign).Hardware requirements
No response
Estimated time to complete
No response
Security notes
No response