diff --git a/docs/LEZ testnet v0.1 tutorials/token-transfer.md b/docs/LEZ testnet v0.1 tutorials/token-transfer.md index b10e72d7..23562fbe 100644 --- a/docs/LEZ testnet v0.1 tutorials/token-transfer.md +++ b/docs/LEZ testnet v0.1 tutorials/token-transfer.md @@ -5,6 +5,7 @@ This tutorial walks through native token transfers between public and private ac 4. Private account creation. 5. Native token transfer from a public account to a private account. 6. Native token transfer from a public account to a private account owned by someone else. +7. Sending to a private accounts key from multiple independent senders. --- @@ -12,7 +13,7 @@ The CLI provides commands to manage accounts. Run `wallet account` to see the op ```bash Commands: get Get account data - new Create a new public account or private accounts key + new Produce new public or private account sync-private Sync private accounts help Print this message or the help of the given subcommand(s) ``` @@ -136,69 +137,62 @@ Account owned by authenticated-transfer program {"balance":37} ``` -## 4. Private accounts key creation +## 4. Private account creation > [!Important] > Private accounts are structurally identical to public accounts, but their values are stored off-chain. On-chain, only a 32-byte commitment is recorded. > Transactions include encrypted private values so the owner can recover them, and the decryption keys are never shared. > Private accounts use two keypairs: nullifier keys for privacy-preserving executions and viewing keys for encrypting and decrypting values. -> A private account ID is derived from the nullifier public key and an identifier chosen by the sender at the time of the first transfer. +> The private account ID is derived from the nullifier public key. > Private accounts can be initialized by anyone, but once initialized they can only be modified by the owner’s keys. > Updates include a new commitment and a nullifier for the old state, which prevents linkage between versions. -### a. Create a private accounts key +### a. Create a private account ```bash -wallet account new private-accounts-key +wallet account new private # Output: -Generated new private accounts key at path /0 +Generated new account with account_id Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL With npk e6366f79d026c8bd64ae6b3d601f0506832ec682ab54897f205fffe64ec0d951 With vpk 02ddc96d0eb56e00ce14994cfdaec5ae1f76244180a919545983156e3519940a17 ``` > [!Tip] -> Share `npk` and `vpk` with anyone who wants to send you tokens. The account ID for a given payment is determined by the sender when they create the transaction. Run `wallet account sync-private` after receiving a transfer to discover new account IDs under your key. +> Save this account ID. You will use it in later commands. + +### b. Check the account status + +Just like public accounts, new private accounts start out uninitialized: + +```bash +wallet account get --account-id Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL + +# Output: +Account is Uninitialized +``` > [!Important] -> Private account data is never visible to the network. It exists only in your local wallet storage. +> Private accounts are never visible to the network. They exist only in your local wallet storage. ## 5. Native token transfer from a public account to a private account > [!Important] > Sending tokens to an uninitialized private account causes the authenticated-transfer program to claim it, just like with public accounts. Program logic is the same regardless of account type. -> When sending to a private account, use the recipient’s `npk` and `vpk`. The sender chooses an identifier for the payment; the recipient’s account ID is derived from `(npk, identifier)` and is only known after the recipient syncs. ### a. Send 17 tokens to the private account > [!Note] -> The syntax matches public-to-public transfers, but the recipient is identified by `npk` and `vpk`. This runs locally, generates a proof, and submits it to the sequencer. It may take 30 seconds to 4 minutes. +> The syntax matches public-to-public transfers, but the recipient is a private ID. This runs locally, generates a proof, and submits it to the sequencer. It may take 30 seconds to 4 minutes. ```bash wallet auth-transfer send \ --from Public/Ev1JprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPAWS \ - --to-npk e6366f79d026c8bd64ae6b3d601f0506832ec682ab54897f205fffe64ec0d951 \ - --to-vpk 02ddc96d0eb56e00ce14994cfdaec5ae1f76244180a919545983156e3519940a17 \ + --to Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL \ --amount 17 ``` -### b. Sync to discover the private account ID - -```bash -wallet account sync-private -``` - -```bash -wallet account list - -# Output (private account entry): -/0 Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL -``` - -> [!Tip] -> Save this account ID. You will use it in later commands. - -### c. Check both accounts +### b. Check both accounts ```bash # Public sender account @@ -210,7 +204,7 @@ Account owned by authenticated-transfer program ``` ```bash -# Private recipient account (use the ID discovered after sync) +# Private recipient account wallet account get --account-id Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL # Output: @@ -227,7 +221,7 @@ Account owned by authenticated-transfer program ## 6. Native token transfer from a public account to a private account owned by someone else > [!Important] -> When the recipient is someone else, you only have their `npk` and `vpk` — never their account ID. The flow is identical to section 5. +> We’ll simulate transferring to someone else by creating a new private accounts key and treating it as if it belonged to another user. When the recipient is someone else, you only have their `npk` and `vpk` — not an account ID. ### a. Create a new private accounts key to simulate a foreign recipient @@ -240,6 +234,9 @@ With npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e With vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72 ``` +> [!Tip] +> Ignore the account ID here and use the `npk` and `vpk` values to send to a foreign private account. + ### b. Send 3 tokens using the recipient’s npk and vpk ```bash @@ -256,3 +253,65 @@ wallet auth-transfer send \ > [!Note] > You have seen transfers between two public accounts and from a public sender to a private recipient. Transfers from a private sender, whether to a public account or to another private account, follow the same pattern. + +## 7. Sending to a private accounts key from multiple independent senders + +> [!Important] +> A private accounts key (`npk` + `vpk`) can be shared with multiple senders. Each sender independently chooses an identifier; the recipient's account ID is derived from `(npk, identifier)`. Two senders using different identifiers produce two separate private accounts under the same key. + +### a. Alice creates a private accounts key + +```bash +wallet account new private-accounts-key + +# Output: +Generated new private accounts key at path /2 +With npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 +With vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c +``` + +Alice shares the `npk` and `vpk` values with Bob and Charlie out of band. + +### b. Bob sends 10 tokens to Alice using identifier 1 + +```bash +wallet auth-transfer send \ + --from Public/BobXqJprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPA \ + --to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \ + --to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \ + --to-identifier 1 \ + --amount 10 +``` + +### c. Charlie sends 5 tokens to Alice using identifier 2 + +```bash +wallet auth-transfer send \ + --from Public/CharlieYrP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPB \ + --to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \ + --to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \ + --to-identifier 2 \ + --amount 5 +``` + +> [!Note] +> Bob and Charlie each chose a different identifier. They do not need to coordinate — any two distinct values work. + +### d. Alice syncs to discover the new accounts + +```bash +wallet account sync-private +``` + +```bash +wallet account list + +# Output (private account entries under key /2): +/2 Private/AliceBobAcctXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +/2 Private/AliceCharlieAcctXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Alice now has two separate private accounts, one funded by Bob and one by Charlie, both controlled by the same key at path `/2`. + +> [!Tip] +> Alice can check each account balance with `wallet account get --account-id Private/...`. Neither balance is visible on-chain. diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index a0d40381..c28f47de 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -43,8 +43,9 @@ async fn new_public_account(ctx: &mut TestContext) -> Result { async fn new_private_account(ctx: &mut TestContext) -> Result { let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 908a6e28..f3e70c09 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -159,8 +159,9 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; // Create a new private account - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })); let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -326,8 +327,9 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; // Create a new private account - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })); let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -390,8 +392,9 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { async fn initialize_private_account() -> Result<()> { let mut ctx = TestContext::new().await?; - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let SubcommandReturnValue::RegisterAccount { account_id } = result else { @@ -489,8 +492,9 @@ async fn initialize_private_account_using_label() -> Result<()> { // Create a new private account with a label let label = "init-private-label".to_owned(); - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: Some(label.clone()), })); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let SubcommandReturnValue::RegisterAccount { account_id } = result else { diff --git a/integration_tests/tests/keys_restoration.rs b/integration_tests/tests/keys_restoration.rs index e5c92843..2588e7d9 100644 --- a/integration_tests/tests/keys_restoration.rs +++ b/integration_tests/tests/keys_restoration.rs @@ -29,8 +29,9 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; // Create a new private account - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })); for _ in 0..3 { @@ -38,8 +39,9 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { // This way we have account with child index > 0. let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -116,8 +118,9 @@ async fn restore_keys_from_seed() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; // Create first private account at root - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: Some(ChainIndex::root()), + label: None, })); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let SubcommandReturnValue::RegisterAccount { @@ -128,8 +131,9 @@ async fn restore_keys_from_seed() -> Result<()> { }; // Create second private account at /0 - let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: Some(ChainIndex::from_str("/0")?), + label: None, })); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let SubcommandReturnValue::RegisterAccount { diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs index ed81037d..77c4a646 100644 --- a/integration_tests/tests/pinata.rs +++ b/integration_tests/tests/pinata.rs @@ -84,8 +84,9 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<() let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -227,8 +228,9 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { // Create new private account let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 355f8810..5ee25a75 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -296,8 +296,9 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { // Create new account for the token supply holder (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -311,8 +312,9 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { // Create new account for receiving a token transaction (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -457,8 +459,9 @@ async fn create_token_with_private_definition() -> Result<()> { // Create token definition account (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: Some(ChainIndex::root()), + label: None, })), ) .await?; @@ -528,8 +531,9 @@ async fn create_token_with_private_definition() -> Result<()> { // Create private recipient account let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -657,8 +661,9 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { // Create token definition account (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -672,8 +677,9 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { // Create supply account (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -733,8 +739,9 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { // Create recipient account let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -847,8 +854,9 @@ async fn shielded_token_transfer() -> Result<()> { // Create recipient account (private) for shielded transfer let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -957,8 +965,9 @@ async fn deshielded_token_transfer() -> Result<()> { // Create supply account (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -1067,8 +1076,9 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { // Create token definition account (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -1082,8 +1092,9 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { // Create supply account (private) let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; @@ -1114,8 +1125,9 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { // Create new private account for claiming path let result = wallet::cli::execute_subcommand( ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::PrivateAccountsKey { + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None, + label: None, })), ) .await?; diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 6c7cd75a..2739be75 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -82,6 +82,15 @@ pub enum NewSubcommand { /// Label to assign to the new account. label: Option, }, + /// Register new private account with a random identifier. + Private { + #[arg(long)] + /// Chain index of a parent node. + cci: Option, + #[arg(short, long)] + /// Label to assign to the new account. + label: Option, + }, /// Create a new receiving key (npk + vpk) to share with senders. PrivateAccountsKey { #[arg(long)] @@ -133,6 +142,48 @@ impl WalletSubcommand for NewSubcommand { Ok(SubcommandReturnValue::RegisterAccount { account_id }) } + Self::Private { cci, label } => { + if let Some(label) = &label + && wallet_core + .storage + .labels + .values() + .any(|l| l.to_string() == *label) + { + anyhow::bail!("Label '{label}' is already in use by another account"); + } + + let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + + let node = wallet_core + .storage + .user_data + .private_key_tree + .key_map + .get(&chain_index) + .expect("Node was just inserted"); + let key = &node.value.0; + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + println!( + "Generated new account with account_id Private/{account_id} at path {chain_index}" + ); + println!("With npk {}", hex::encode(key.nullifier_public_key.0)); + println!( + "With vpk {}", + hex::encode(key.viewing_public_key.to_bytes()) + ); + + wallet_core.store_persistent_data().await?; + + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } Self::PrivateAccountsKey { cci } => { let chain_index = wallet_core.create_private_accounts_key(cci); diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 9ec361e3..62b40b61 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -262,6 +262,32 @@ impl WalletCore { .create_private_accounts_key(chain_index) } + pub fn create_new_account_private( + &mut self, + chain_index: Option, + ) -> (AccountId, ChainIndex) { + let cci = self + .storage + .user_data + .create_private_accounts_key(chain_index); + let identifier: nssa_core::Identifier = rand::random(); + let npk = self + .storage + .user_data + .private_key_tree + .key_map + .get(&cci) + .expect("Node was just inserted") + .value + .0 + .nullifier_public_key + .clone(); + let account_id = AccountId::from((&npk, identifier)); + self.storage + .insert_private_account_data(account_id, identifier, Account::default()); + (account_id, cci) + } + /// Get account balance. pub async fn get_account_balance(&self, acc: AccountId) -> Result { Ok(self.sequencer_client.get_account_balance(acc).await?)