From 2faa3871ead23e6e0b12fd185493109994322ded Mon Sep 17 00:00:00 2001 From: musitdev Date: Thu, 11 Jun 2026 17:48:23 +0200 Subject: [PATCH 01/11] add a simple transfer example --- crates/confidential-assets/Cargo.toml | 6 +- .../examples/simple_ca_transfer.rs | 211 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 crates/confidential-assets/examples/simple_ca_transfer.rs diff --git a/crates/confidential-assets/Cargo.toml b/crates/confidential-assets/Cargo.toml index fc4ab55..9034a4f 100644 --- a/crates/confidential-assets/Cargo.toml +++ b/crates/confidential-assets/Cargo.toml @@ -14,8 +14,12 @@ readme = "README.md" [features] e2e = [] +[[example]] +name = "simple_ca_transfer" +required-features = ["e2e"] + [dependencies] -movement-sdk = { workspace = true, features = ["twisted_ed25519"] } +movement-sdk = { workspace = true, features = ["twisted_ed25519", "ed25519", "faucet"] } aptos-bcs = { workspace = true } tokio = { workspace = true } diff --git a/crates/confidential-assets/examples/simple_ca_transfer.rs b/crates/confidential-assets/examples/simple_ca_transfer.rs new file mode 100644 index 0000000..07ff53c --- /dev/null +++ b/crates/confidential-assets/examples/simple_ca_transfer.rs @@ -0,0 +1,211 @@ +// Example: Simple confidential asset transfer +// +// This example demonstrates how to: +// Init: +// create and fund 2 accounts +// CA transfer: +// 1. Initialize the confidential asset for the 2 accounts +// 2. Deposit some token on account 1 +// 3. Do a CA transfer from account 1 to account 2 +// 4. Verify the account balances +// +// Run with: +// 1. MOVEMENT_NETWORK=TESTNET|MAINNET|LOCAL — picks a preset +// 2. MOVEMENT_RPC_URL=http://... — custom endpoint (used when MOVEMENT_NETWORK is not set) +// 3. Falls back to http://127.0.0.1:8080/v1 (default local) +// 4. CONFIDENTIAL_MODULE_ADDRESS=0x (optional, defaults to 0x1) +// To run wih all defaul: +// cargo run --example simple_ca_transfer --features e2e + +use confidential_assets::api::ConfidentialAsset; +use confidential_assets::crypto::twisted_ed25519::{ + DECRYPTION_KEY_DERIVATION_MESSAGE, TwistedEd25519PrivateKey, +}; +use movement_sdk::{ + Movement, MovementConfig, MovementError, MovementResult, + account::Ed25519Account, + transaction::{EntryFunction, TransactionPayload}, + types::{AccountAddress, Identifier, MoveModuleId, TypeTag}, +}; +use std::env; + +// 2 MOVE — enough for the default gas budget (max_gas × gas_price = 200_000_000 octas) +// plus headroom for subsequent transactions. +const FUND_AMOUNT: u64 = 1_000_000_000; +const DEPOSIT_AMOUNT: u64 = 10; +const TRANSFER_AMOUNT: u64 = 4; + +// The FA token used in the example. Defaults to the well-known testnet/localnet address +// (0x…0a, the MOVE coin FA metadata), but can be overridden via TOKEN_ADDRESS. +const DEFAULT_TOKEN_ADDRESS: &str = + "0x000000000000000000000000000000000000000000000000000000000000000a"; + +#[tokio::main] +async fn main() -> MovementResult<()> { + println!("=== Simple Confidential Asset Transfer Example ===\n"); + + // ── 1. Build the Movement client ───────────────────────────────────────── + let config = match env::var("MOVEMENT_NETWORK") { + Ok(network) => match network.to_uppercase().as_str() { + "TESTNET" => MovementConfig::testnet(), + "MAINNET" => MovementConfig::mainnet(), + _ => MovementConfig::local(), + }, + Err(_) => match env::var("MOVEMENT_RPC_URL") { + Ok(url) => MovementConfig::custom(&url)?, + Err(_) => MovementConfig::local(), + }, + }; + let movement = Movement::new(config)?; + println!("Connected to Movement network.\n"); + + // ── 2. Resolve addresses ────────────────────────────────────────────────── + let module_address = + env::var("CONFIDENTIAL_MODULE_ADDRESS").unwrap_or_else(|_| "0x1".to_string()); + + let token_raw = env::var("TOKEN_ADDRESS").unwrap_or_else(|_| DEFAULT_TOKEN_ADDRESS.to_string()); + let token = + AccountAddress::from_hex(&token_raw).expect("TOKEN_ADDRESS must be a valid hex address"); + + let ca = ConfidentialAsset::new(&movement, Some(&module_address))?; + + // ── 3. Create and fund two accounts ────────────────────────────────────── + let alice = Ed25519Account::generate(); + let bob = Ed25519Account::generate(); + + println!("Alice address: {}", alice.address()); + println!("Bob address: {}", bob.address()); + println!(); + + // Derive the CA decryption key from the account signing key (same approach as + // `get_test_confidential_account` in the e2e helpers). + let alice_dk = { + let sig = alice.sign_message(DECRYPTION_KEY_DERIVATION_MESSAGE); + let bytes = sig.to_bytes(); + TwistedEd25519PrivateKey::from_bytes(&bytes[..32]).expect("signature has at least 32 bytes") + }; + let bob_dk = { + let sig = bob.sign_message(DECRYPTION_KEY_DERIVATION_MESSAGE); + let bytes = sig.to_bytes(); + TwistedEd25519PrivateKey::from_bytes(&bytes[..32]).expect("signature has at least 32 bytes") + }; + + println!("Funding Alice …"); + movement.fund_account(alice.address(), FUND_AMOUNT).await?; + println!("Funding Bob …"); + movement.fund_account(bob.address(), FUND_AMOUNT).await?; + + // Migrate native coin → FA store (required before any FA operation). + println!("Migrating Alice's coins to fungible store …"); + let migrate = migrate_payload()?; + movement.sign_submit_and_wait(&alice, migrate, None).await?; + + println!("Migrating Bob's coins to fungible store …"); + let migrate = migrate_payload()?; + movement.sign_submit_and_wait(&bob, migrate, None).await?; + println!(); + + // ── 4. Register confidential balances for both accounts ────────────────── + println!("Registering Alice's confidential balance …"); + let payload = ca + .register_balance(&alice.address(), &token, &alice_dk) + .await?; + movement.sign_submit_and_wait(&alice, payload, None).await?; + + println!("Registering Bob's confidential balance …"); + let payload = ca.register_balance(&bob.address(), &token, &bob_dk).await?; + movement.sign_submit_and_wait(&bob, payload, None).await?; + println!(); + + // ── 5. Deposit public FA tokens into Alice's confidential balance ───────── + println!("Depositing {DEPOSIT_AMOUNT} tokens into Alice's confidential balance …"); + let payload = ca.deposit(&alice.address(), &token, DEPOSIT_AMOUNT, None)?; + movement.sign_submit_and_wait(&alice, payload, None).await?; + + let bal = ca.get_balance(&alice.address(), &token, &alice_dk).await?; + println!( + " Alice after deposit — available: {}, pending: {}", + bal.available_balance(), + bal.pending_balance() + ); + + // ── 6. Rollover Alice's pending balance → available ─────────────────────── + println!("Rolling over Alice's pending balance …"); + let payloads = ca + .rollover_pending_balance(&alice.address(), &token, None, false) + .await?; + for p in payloads { + movement.sign_submit_and_wait(&alice, p, None).await?; + } + + let bal = ca.get_balance(&alice.address(), &token, &alice_dk).await?; + println!( + " Alice after rollover — available: {}, pending: {}", + bal.available_balance(), + bal.pending_balance() + ); + println!(); + + // ── 7. Confidential transfer: Alice → Bob ──────────────────────────────── + println!("Transferring {TRANSFER_AMOUNT} tokens from Alice to Bob (confidential) …"); + let payload = ca + .transfer( + &alice.address(), + &bob.address(), + &token, + TRANSFER_AMOUNT, + &alice_dk, + &[], // no additional auditors + &[], // no auditor hint + ) + .await?; + movement.sign_submit_and_wait(&alice, payload, None).await?; + println!(); + + // ── 8. Verify final balances ───────────────────────────────────────────── + let alice_bal = ca.get_balance(&alice.address(), &token, &alice_dk).await?; + let bob_bal = ca.get_balance(&bob.address(), &token, &bob_dk).await?; + + println!("=== Final balances ==="); + println!( + " Alice — available: {}, pending: {}", + alice_bal.available_balance(), + alice_bal.pending_balance() + ); + println!( + " Bob — available: {}, pending: {}", + bob_bal.available_balance(), + bob_bal.pending_balance() + ); + println!(); + + // The transfer amount leaves Alice's available and lands in Bob's pending. + assert_eq!( + alice_bal.available_balance(), + (DEPOSIT_AMOUNT - TRANSFER_AMOUNT) as u128, + "Alice's available balance should be DEPOSIT - TRANSFER" + ); + assert_eq!( + bob_bal.pending_balance(), + TRANSFER_AMOUNT as u128, + "Bob's pending balance should equal the transfer amount" + ); + + println!("All assertions passed. Example complete!"); + Ok(()) +} + +/// Build the `0x1::coin::migrate_to_fungible_store` payload. +fn migrate_payload() -> MovementResult { + let module = MoveModuleId::new( + AccountAddress::from_hex("0x1").map_err(|e| MovementError::Internal(e.to_string()))?, + Identifier::new("coin").map_err(|e| MovementError::Internal(e.to_string()))?, + ); + Ok(EntryFunction::new( + module, + "migrate_to_fungible_store", + vec![TypeTag::aptos_coin()], + vec![], + ) + .into()) +} From 955c456eedd4e61452c5aed6778a01f60bb99784 Mon Sep 17 00:00:00 2001 From: musitdev Date: Mon, 15 Jun 2026 19:27:25 +0200 Subject: [PATCH 02/11] Update chain and asset auditor --- .../src/api/confidential_asset.rs | 8 ++-- .../src/internal/transaction_builder.rs | 33 ++++++++++++---- .../src/internal/view_functions.rs | 39 ++++++++++++++++--- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/crates/confidential-assets/src/api/confidential_asset.rs b/crates/confidential-assets/src/api/confidential_asset.rs index a02cc7d..f97093c 100644 --- a/crates/confidential-assets/src/api/confidential_asset.rs +++ b/crates/confidential-assets/src/api/confidential_asset.rs @@ -11,12 +11,12 @@ use crate::crypto::{TwistedEd25519PrivateKey, TwistedEd25519PublicKey}; use crate::internal::transaction_builder::ConfidentialAssetTransactionBuilder; use crate::internal::view_functions::{ - ConfidentialBalance, get_balance, get_encryption_key, get_global_auditor_encryption_key, - is_balance_normalized, is_pending_balance_frozen, + get_asset_auditor_encryption_key, get_balance, get_encryption_key, is_balance_normalized, + is_pending_balance_frozen, ConfidentialBalance, }; use movement_sdk::account::Account; use movement_sdk::{ - Movement, MovementError, transaction::payload::TransactionPayload, types::AccountAddress, + transaction::payload::TransactionPayload, types::AccountAddress, Movement, MovementError, }; /// High-level API for confidential asset operations. @@ -394,7 +394,7 @@ impl<'a> ConfidentialAsset<'a> { &self, token_address: &AccountAddress, ) -> Result, MovementError> { - get_global_auditor_encryption_key( + get_asset_auditor_encryption_key( self.transaction.client, token_address, Some(&self.transaction.confidential_asset_module_address), diff --git a/crates/confidential-assets/src/internal/transaction_builder.rs b/crates/confidential-assets/src/internal/transaction_builder.rs index c5829c1..f0b0cec 100644 --- a/crates/confidential-assets/src/internal/transaction_builder.rs +++ b/crates/confidential-assets/src/internal/transaction_builder.rs @@ -8,8 +8,9 @@ //! use super::view_functions::{ - get_balance, get_chain_id_byte_for_proofs, get_encryption_key, - get_global_auditor_encryption_key, is_balance_normalized, is_pending_balance_frozen, + get_asset_auditor_encryption_key, get_balance, get_chain_auditor_encryption_key, + get_chain_id_byte_for_proofs, get_encryption_key, is_balance_normalized, + is_pending_balance_frozen, }; use crate::bcs::serialize_vector_u8; use crate::consts::{ @@ -17,15 +18,15 @@ use crate::consts::{ }; use crate::crypto::confidential_registration::gen_registration_proof; use crate::crypto::{ - TwistedEd25519PrivateKey, TwistedEd25519PublicKey, confidential_key_rotation::ConfidentialKeyRotation, confidential_normalization::ConfidentialNormalization, confidential_transfer::ConfidentialTransfer, confidential_withdraw::ConfidentialWithdraw, + TwistedEd25519PrivateKey, TwistedEd25519PublicKey, }; use movement_sdk::{ - Movement, MovementError, transaction::{EntryFunction, TransactionPayload}, types::{AccountAddress, Identifier, MoveModuleId}, + Movement, MovementError, }; /// Helper: BCS-encode an AccountAddress (fixed 32-byte serialization, infallible). @@ -262,8 +263,15 @@ impl<'a> ConfidentialAssetTransactionBuilder<'a> { let chain_id = get_chain_id_byte_for_proofs(self.client).await?; + // Get global chain auditor public key for the token + let chain_auditor_pub_key = get_chain_auditor_encryption_key( + self.client, + Some(&self.confidential_asset_module_address), + ) + .await?; + // Get auditor public key for the token - let global_auditor_pub_key = get_global_auditor_encryption_key( + let asset_auditor_pub_key = get_asset_auditor_encryption_key( self.client, token_address, Some(&self.confidential_asset_module_address), @@ -311,7 +319,10 @@ impl<'a> ConfidentialAssetTransactionBuilder<'a> { // Assemble auditor keys let mut auditor_keys: Vec = vec![]; - if let Some(auditor) = global_auditor_pub_key { + if let Some(auditor) = chain_auditor_pub_key { + auditor_keys.push(auditor); + } + if let Some(auditor) = asset_auditor_pub_key { auditor_keys.push(auditor); } auditor_keys.extend_from_slice(additional_auditor_encryption_keys); @@ -458,12 +469,20 @@ impl<'a> ConfidentialAssetTransactionBuilder<'a> { .into()) } + /// Get the global chain auditor encryption key for a token, if set. + pub async fn get_chain_auditor_encryption_key( + &self, + ) -> Result, MovementError> { + get_chain_auditor_encryption_key(self.client, Some(&self.confidential_asset_module_address)) + .await + } + /// Get the asset auditor encryption key for a token, if set. pub async fn get_asset_auditor_encryption_key( &self, token_address: &AccountAddress, ) -> Result, MovementError> { - get_global_auditor_encryption_key( + get_asset_auditor_encryption_key( self.client, token_address, Some(&self.confidential_asset_module_address), diff --git a/crates/confidential-assets/src/internal/view_functions.rs b/crates/confidential-assets/src/internal/view_functions.rs index 06fd31d..022ddc7 100644 --- a/crates/confidential-assets/src/internal/view_functions.rs +++ b/crates/confidential-assets/src/internal/view_functions.rs @@ -9,9 +9,9 @@ use crate::consts::{DEFAULT_CONFIDENTIAL_COIN_MODULE_ADDRESS, MODULE_NAME}; use crate::crypto::{ - TwistedEd25519PublicKey, TwistedElGamalCiphertext, encrypted_amount::EncryptedAmount, + encrypted_amount::EncryptedAmount, TwistedEd25519PublicKey, TwistedElGamalCiphertext, }; -use movement_sdk::{Movement, MovementError, types::AccountAddress}; +use movement_sdk::{types::AccountAddress, Movement, MovementError}; /// Represents a confidential balance with both available and pending encrypted amounts. #[derive(Debug, Clone)] @@ -171,8 +171,37 @@ pub async fn get_encryption_key( .map_err(|e| MovementError::Internal(format!("invalid encryption key: {}", e))) } -/// Get the global auditor encryption key for a token, if set. -pub async fn get_global_auditor_encryption_key( +/// Get the global chain auditor encryption key for a token, if set. +pub async fn get_chain_auditor_encryption_key( + client: &Movement, + module_address: Option<&str>, +) -> Result, MovementError> { + let mod_addr = module_address.unwrap_or(DEFAULT_CONFIDENTIAL_COIN_MODULE_ADDRESS); + + // `get_auditor` returns `Option` — BCS-encoded + let result: Option> = client + .view_bcs(&func_path(mod_addr, "get_chain_auditor"), vec![], vec![]) + .await?; + + match result { + Some(bytes) if !bytes.is_empty() => { + let res = { + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| MovementError::Internal("auditor key not 32 bytes".to_string()))?; + TwistedEd25519PublicKey::from_bytes(&arr) + }; + match res { + Ok(key) => Ok(Some(key)), + Err(_) => Ok(None), + } + } + _ => Ok(None), + } +} + +/// Get the asset auditor encryption key for a token, if set. +pub async fn get_asset_auditor_encryption_key( client: &Movement, token_address: &AccountAddress, module_address: Option<&str>, @@ -182,7 +211,7 @@ pub async fn get_global_auditor_encryption_key( // `get_auditor` returns `Option` — BCS-encoded let result: Option> = client .view_bcs( - &func_path(mod_addr, "get_auditor"), + &func_path(mod_addr, "get_asset_auditor"), vec![], vec![bcs_address(token_address)], ) From 1dd57f21767bea077e39141c503eac677323ca00 Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:45:57 -0400 Subject: [PATCH 03/11] update dst to match Move contract --- .../src/crypto/range_proof.rs | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/confidential-assets/src/crypto/range_proof.rs b/crates/confidential-assets/src/crypto/range_proof.rs index 0a3ee97..fae96ff 100644 --- a/crates/confidential-assets/src/crypto/range_proof.rs +++ b/crates/confidential-assets/src/crypto/range_proof.rs @@ -72,7 +72,7 @@ pub fn verify_range_batch( .iter() .map(|c| NgCompressed::from_slice(c.as_slice())) .collect(); - let dst: &[u8] = b"AptosConfidentialAsset/BulletproofRangeProof"; + let dst: &[u8] = b"MovementConfidentialAsset/BulletproofRangeProof"; let ok = proof .verify_multiple(&gens, &pg, &mut Transcript::new(dst), &comms, num_bits) .is_ok(); @@ -160,4 +160,64 @@ mod tests { .unwrap(); assert!(ok); } + + /// Guards against the on-chain `ERANGE_PROOF_VERIFICATION_FAILED` regression: a proof + /// produced by the prover (`movement_rp_wasm`) MUST verify under the contract's DST + /// `MovementConfidentialAsset/BulletproofRangeProof` (confidential_proof.move:34) and MUST + /// NOT verify under the legacy `AptosConfidentialAsset/...` label. The Fiat–Shamir + /// challenges are bound to the transcript's initial DST, so the prover and the contract + /// must use byte-identical labels. If this fails under `Movement`, the prover DST has + /// drifted from the contract again (see wasm-bindings range-proofs/src/lib.rs). + #[test] + fn range_proof_dst_matches_contract() { + use bulletproofs::{BulletproofGens, PedersenGens, RangeProof}; + use curve25519_dalek_ng::ristretto::CompressedRistretto as NgCompressed; + use merlin::Transcript; + + let vals: Vec = vec![100, 1, 0, 65535, 7, 0, 0, 0]; + let rs: Vec = (0..vals.len()).map(|_| ed25519_gen_random()).collect(); + let (proof_bytes, comms) = prove_range_batch( + &vals, + &rs, + &RISTRETTO_BASEPOINT_POINT, + &h_ristretto(), + CHUNK_BITS as usize, + ) + .unwrap(); + + let verify_under = |dst: &'static [u8]| -> bool { + let proof = RangeProof::from_bytes(&proof_bytes).unwrap(); + let pg = PedersenGens { + B: NgCompressed(RISTRETTO_BASEPOINT_POINT.compress().to_bytes()) + .decompress() + .unwrap(), + B_blinding: NgCompressed(h_ristretto().compress().to_bytes()) + .decompress() + .unwrap(), + }; + let gens = BulletproofGens::new(64, 16); + let c: Vec = comms + .iter() + .map(|c| NgCompressed::from_slice(c.as_slice())) + .collect(); + proof + .verify_multiple(&gens, &pg, &mut Transcript::new(dst), &c, CHUNK_BITS as usize) + .is_ok() + }; + + let aptos = verify_under(b"AptosConfidentialAsset/BulletproofRangeProof"); + let movement = verify_under(b"MovementConfidentialAsset/BulletproofRangeProof"); + + println!("verify under Aptos* DST = {aptos}"); + println!("verify under Movement* DST = {movement}"); + + assert!( + movement, + "prover DST must match the contract's MovementConfidentialAsset/... (confidential_proof.move:34)" + ); + assert!( + !aptos, + "prover must NOT still use the legacy AptosConfidentialAsset/... DST" + ); + } } From 02d4b1363d23adf9840ff06b83aa18a13b7202d3 Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:15:54 -0400 Subject: [PATCH 04/11] fix: call native _batch_verify_proof rather than hard-coding DST --- crates/confidential-assets/Cargo.toml | 10 +- crates/confidential-assets/README.md | 12 ++- .../src/crypto/range_proof.rs | 99 +++---------------- crates/confidential-assets/tests/README.md | 8 +- 4 files changed, 28 insertions(+), 101 deletions(-) diff --git a/crates/confidential-assets/Cargo.toml b/crates/confidential-assets/Cargo.toml index 9034a4f..b7ff11e 100644 --- a/crates/confidential-assets/Cargo.toml +++ b/crates/confidential-assets/Cargo.toml @@ -36,13 +36,11 @@ hex = "0.4" num-bigint = "0.4" num-traits = "0.2" -# Upstream crypto shared with the TS SDK (built as WASM there, linked as rlib here). +# Upstream range-proof prover/verifier shared with the TS SDK (built as WASM there, linked as +# rlib here). Both prove and verify go through this crate's native `rp` module. movement_rp_wasm = { git = "https://github.com/moveindustries/confidential-asset-wasm-bindings", branch = "main" } -movement_pollard_kangaroo_wasm = { git = "https://github.com/moveindustries/confidential-asset-wasm-bindings", branch = "main" } -# Same versions the upstream wrappers pin to; needed for the verify path -# (the upstream `_batch_range_proof` only exposes the prover natively). -bulletproofs = "4.0.0" -merlin = "3" +# curve25519-dalek-ng: kangaroo-decryption point conversion in `twisted_el_gamal`. +# (Range-proof prove + verify both go through `movement_rp_wasm`'s native `rp` module.) curve25519-dalek-ng = "4" pollard-kangaroo = { git = "https://github.com/distributed-lab/pollard-kangaroo.git", branch = "main", features = ["table48", "table32", "presets"] } diff --git a/crates/confidential-assets/README.md b/crates/confidential-assets/README.md index 0427674..f1c8888 100644 --- a/crates/confidential-assets/README.md +++ b/crates/confidential-assets/README.md @@ -6,8 +6,9 @@ encryption, ZK proofs, and confidential transfers on MoveVM. Functional parity w TypeScript SDK; same on-chain wire format, validated end-to-end against a localnet. This crate is part of the [Movement Rust SDK workspace](../../README.md). It depends on -`movement-sdk` for HTTP / transaction building and on the upstream Rust source that the TS SDK -ships as WASM (`movement_rp_wasm`, `movement_pollard_kangaroo_wasm`) — same code path, no port. +`movement-sdk` for HTTP / transaction building and on `movement_rp_wasm` — the upstream Rust +range-proof source the TS SDK ships as WASM, consumed here as an `rlib` (same code path, no +port). Kangaroo decryption uses the upstream `pollard-kangaroo` crate directly. ## Status @@ -277,9 +278,10 @@ Cryptography: Movement-specific (shared with the TS SDK): - [`movement_rp_wasm`](https://github.com/moveindustries/confidential-asset-wasm-bindings) — - bulletproofs prover. Same Rust source the TS SDK builds as WASM; consumed here as an `rlib`. -- [`movement_pollard_kangaroo_wasm`](https://github.com/moveindustries/confidential-asset-wasm-bindings) — - Pollard kangaroo DLP solver. Same source as the TS SDK's WASM, `Kangaroo32` preset. + bulletproofs prover and verifier (native `rp` module). Same Rust source the TS SDK builds as + WASM; consumed here as an `rlib`. +- [`pollard-kangaroo`](https://github.com/distributed-lab/pollard-kangaroo) — Pollard kangaroo + DLP solver for decryption (`Kangaroo32` preset). Native Rust; the TS SDK uses a WASM solver. Movement-SDK plumbing: diff --git a/crates/confidential-assets/src/crypto/range_proof.rs b/crates/confidential-assets/src/crypto/range_proof.rs index fae96ff..58b8a6b 100644 --- a/crates/confidential-assets/src/crypto/range_proof.rs +++ b/crates/confidential-assets/src/crypto/range_proof.rs @@ -41,7 +41,11 @@ pub fn prove_range_batch( Ok((out.proof(), out.comms())) } -/// Verify a batch range proof. +/// Verify a batch range proof. Delegates to the prover crate (`movement_rp_wasm`) so the +/// Bulletproofs transcript DST lives in exactly one place — `BULLETPROOF_DST` in the +/// wasm-bindings `range-proofs` crate — and the Rust verifier is byte-identical to the prover +/// by construction (no DST is re-declared here). This mirrors how the TS SDK verifies via the +/// same crate's wasm build. pub fn verify_range_batch( proof: &[u8], commitments: &[Vec], @@ -49,34 +53,17 @@ pub fn verify_range_batch( rand_base: &RistrettoPoint, num_bits: usize, ) -> Result { - use bulletproofs::{BulletproofGens, PedersenGens, RangeProof}; - use curve25519_dalek_ng::ristretto::CompressedRistretto as NgCompressed; - use merlin::Transcript; - if commitments.is_empty() { return Err("empty commitments".into()); } - let proof = RangeProof::from_bytes(proof).map_err(|e| format!("rp deser: {:?}", e))?; - let val_base_b: [u8; 32] = val_base.compress().to_bytes(); - let rand_base_b: [u8; 32] = rand_base.compress().to_bytes(); - let pg = PedersenGens { - B: NgCompressed(val_base_b) - .decompress() - .ok_or("val_base decompress")?, - B_blinding: NgCompressed(rand_base_b) - .decompress() - .ok_or("rand_base decompress")?, - }; - let gens = BulletproofGens::new(64, 16); - let comms: Vec = commitments - .iter() - .map(|c| NgCompressed::from_slice(c.as_slice())) - .collect(); - let dst: &[u8] = b"MovementConfidentialAsset/BulletproofRangeProof"; - let ok = proof - .verify_multiple(&gens, &pg, &mut Transcript::new(dst), &comms, num_bits) - .is_ok(); - Ok(ok) + movement_rp_wasm::rp::_batch_verify_proof( + proof.to_vec(), + commitments.to_vec(), + val_base.compress().to_bytes().to_vec(), + rand_base.compress().to_bytes().to_vec(), + num_bits, + ) + .map_err(|e| format!("batch verify: {}", e)) } /// Convenience for sigma-protocol callers: prove the chunked plaintext values @@ -160,64 +147,4 @@ mod tests { .unwrap(); assert!(ok); } - - /// Guards against the on-chain `ERANGE_PROOF_VERIFICATION_FAILED` regression: a proof - /// produced by the prover (`movement_rp_wasm`) MUST verify under the contract's DST - /// `MovementConfidentialAsset/BulletproofRangeProof` (confidential_proof.move:34) and MUST - /// NOT verify under the legacy `AptosConfidentialAsset/...` label. The Fiat–Shamir - /// challenges are bound to the transcript's initial DST, so the prover and the contract - /// must use byte-identical labels. If this fails under `Movement`, the prover DST has - /// drifted from the contract again (see wasm-bindings range-proofs/src/lib.rs). - #[test] - fn range_proof_dst_matches_contract() { - use bulletproofs::{BulletproofGens, PedersenGens, RangeProof}; - use curve25519_dalek_ng::ristretto::CompressedRistretto as NgCompressed; - use merlin::Transcript; - - let vals: Vec = vec![100, 1, 0, 65535, 7, 0, 0, 0]; - let rs: Vec = (0..vals.len()).map(|_| ed25519_gen_random()).collect(); - let (proof_bytes, comms) = prove_range_batch( - &vals, - &rs, - &RISTRETTO_BASEPOINT_POINT, - &h_ristretto(), - CHUNK_BITS as usize, - ) - .unwrap(); - - let verify_under = |dst: &'static [u8]| -> bool { - let proof = RangeProof::from_bytes(&proof_bytes).unwrap(); - let pg = PedersenGens { - B: NgCompressed(RISTRETTO_BASEPOINT_POINT.compress().to_bytes()) - .decompress() - .unwrap(), - B_blinding: NgCompressed(h_ristretto().compress().to_bytes()) - .decompress() - .unwrap(), - }; - let gens = BulletproofGens::new(64, 16); - let c: Vec = comms - .iter() - .map(|c| NgCompressed::from_slice(c.as_slice())) - .collect(); - proof - .verify_multiple(&gens, &pg, &mut Transcript::new(dst), &c, CHUNK_BITS as usize) - .is_ok() - }; - - let aptos = verify_under(b"AptosConfidentialAsset/BulletproofRangeProof"); - let movement = verify_under(b"MovementConfidentialAsset/BulletproofRangeProof"); - - println!("verify under Aptos* DST = {aptos}"); - println!("verify under Movement* DST = {movement}"); - - assert!( - movement, - "prover DST must match the contract's MovementConfidentialAsset/... (confidential_proof.move:34)" - ); - assert!( - !aptos, - "prover must NOT still use the legacy AptosConfidentialAsset/... DST" - ); - } } diff --git a/crates/confidential-assets/tests/README.md b/crates/confidential-assets/tests/README.md index 7a0e762..cec1210 100644 --- a/crates/confidential-assets/tests/README.md +++ b/crates/confidential-assets/tests/README.md @@ -87,7 +87,7 @@ accepted by the on-chain Move verifier, which is the same code path the TS SDK u exercises. Range proofs are delegated to the upstream `movement_rp_wasm` (same prover the TS SDK builds -as WASM); the verify path uses `bulletproofs::RangeProof::verify_multiple` against the -canonical DST `AptosConfidentialAsset/BulletproofRangeProof`. Pollard kangaroo decryption uses -the upstream `pollard-kangaroo` crate with the `Kangaroo32` preset, matching the TS WASM -module's secret-size assumption. +as WASM); both prove and verify go through its native `rp` module, so the Bulletproofs +transcript DST (`MovementConfidentialAsset/BulletproofRangeProof`) lives only in that crate. +Pollard kangaroo decryption uses the upstream `pollard-kangaroo` crate with the `Kangaroo32` +preset, matching the TS WASM module's secret-size assumption. From 3fcc72634068697bc53b2f405908085eef7c0441 Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:36:58 -0400 Subject: [PATCH 05/11] chore: bump movement_rp_wasm in Cargo.lock and run cargo fmt --- Cargo.lock | 16 +--------------- .../src/api/confidential_asset.rs | 6 +++--- .../src/internal/transaction_builder.rs | 4 ++-- .../src/internal/view_functions.rs | 4 ++-- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d73e81c..0133b24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,13 +424,10 @@ name = "confidential-assets" version = "0.1.0" dependencies = [ "aptos-bcs", - "bulletproofs", "curve25519-dalek", "curve25519-dalek-ng", "hex", - "merlin", "movement-sdk", - "movement_pollard_kangaroo_wasm", "movement_rp_wasm", "num-bigint", "num-traits", @@ -1579,21 +1576,10 @@ dependencies = [ "trybuild", ] -[[package]] -name = "movement_pollard_kangaroo_wasm" -version = "0.0.0" -source = "git+https://github.com/moveindustries/confidential-asset-wasm-bindings?branch=main#8dc068a8146def3dc53221e3267e0233119f99d3" -dependencies = [ - "curve25519-dalek-ng", - "getrandom 0.2.17", - "pollard-kangaroo", - "wasm-bindgen", -] - [[package]] name = "movement_rp_wasm" version = "0.0.0" -source = "git+https://github.com/moveindustries/confidential-asset-wasm-bindings?branch=main#8dc068a8146def3dc53221e3267e0233119f99d3" +source = "git+https://github.com/moveindustries/confidential-asset-wasm-bindings?branch=main#527d22312a71c10c53344bdd802ce1e1ce684d80" dependencies = [ "bulletproofs", "curve25519-dalek-ng", diff --git a/crates/confidential-assets/src/api/confidential_asset.rs b/crates/confidential-assets/src/api/confidential_asset.rs index f97093c..f6e9b03 100644 --- a/crates/confidential-assets/src/api/confidential_asset.rs +++ b/crates/confidential-assets/src/api/confidential_asset.rs @@ -11,12 +11,12 @@ use crate::crypto::{TwistedEd25519PrivateKey, TwistedEd25519PublicKey}; use crate::internal::transaction_builder::ConfidentialAssetTransactionBuilder; use crate::internal::view_functions::{ - get_asset_auditor_encryption_key, get_balance, get_encryption_key, is_balance_normalized, - is_pending_balance_frozen, ConfidentialBalance, + ConfidentialBalance, get_asset_auditor_encryption_key, get_balance, get_encryption_key, + is_balance_normalized, is_pending_balance_frozen, }; use movement_sdk::account::Account; use movement_sdk::{ - transaction::payload::TransactionPayload, types::AccountAddress, Movement, MovementError, + Movement, MovementError, transaction::payload::TransactionPayload, types::AccountAddress, }; /// High-level API for confidential asset operations. diff --git a/crates/confidential-assets/src/internal/transaction_builder.rs b/crates/confidential-assets/src/internal/transaction_builder.rs index f0b0cec..346e936 100644 --- a/crates/confidential-assets/src/internal/transaction_builder.rs +++ b/crates/confidential-assets/src/internal/transaction_builder.rs @@ -18,15 +18,15 @@ use crate::consts::{ }; use crate::crypto::confidential_registration::gen_registration_proof; use crate::crypto::{ + TwistedEd25519PrivateKey, TwistedEd25519PublicKey, confidential_key_rotation::ConfidentialKeyRotation, confidential_normalization::ConfidentialNormalization, confidential_transfer::ConfidentialTransfer, confidential_withdraw::ConfidentialWithdraw, - TwistedEd25519PrivateKey, TwistedEd25519PublicKey, }; use movement_sdk::{ + Movement, MovementError, transaction::{EntryFunction, TransactionPayload}, types::{AccountAddress, Identifier, MoveModuleId}, - Movement, MovementError, }; /// Helper: BCS-encode an AccountAddress (fixed 32-byte serialization, infallible). diff --git a/crates/confidential-assets/src/internal/view_functions.rs b/crates/confidential-assets/src/internal/view_functions.rs index 022ddc7..2719bcb 100644 --- a/crates/confidential-assets/src/internal/view_functions.rs +++ b/crates/confidential-assets/src/internal/view_functions.rs @@ -9,9 +9,9 @@ use crate::consts::{DEFAULT_CONFIDENTIAL_COIN_MODULE_ADDRESS, MODULE_NAME}; use crate::crypto::{ - encrypted_amount::EncryptedAmount, TwistedEd25519PublicKey, TwistedElGamalCiphertext, + TwistedEd25519PublicKey, TwistedElGamalCiphertext, encrypted_amount::EncryptedAmount, }; -use movement_sdk::{types::AccountAddress, Movement, MovementError}; +use movement_sdk::{Movement, MovementError, types::AccountAddress}; /// Represents a confidential balance with both available and pending encrypted amounts. #[derive(Debug, Clone)] From 8b3fc4e26925a526d88bdaacc60f24feefb9a54b Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:15:12 -0400 Subject: [PATCH 06/11] test(confidential-assets): align e2e with self-transfer ban + bump aptos-core pin --- .github/workflows/ca-e2e.yml | 10 +- .../tests/e2e/confidential_asset.rs | 210 ++++++++++++++---- .../tests/e2e/txn_builder.rs | 33 ++- 3 files changed, 195 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ca-e2e.yml b/.github/workflows/ca-e2e.yml index 6c7aeaa..0860926 100644 --- a/.github/workflows/ca-e2e.yml +++ b/.github/workflows/ca-e2e.yml @@ -17,10 +17,12 @@ env: # branch so CI is reproducible and won't drift if the branch advances. # Bump when intentional changes to the localnet/module setup are needed. # - # f496b6c1: bind token_address into the FS transcript for transfer/withdraw/ - # rotation/normalization sigma proofs. Wire-format-breaking change; ships - # together with the corresponding SDK updates in this repo. - APTOS_CORE_COMMIT: f496b6c1679e5b1da5983569c909d9c9fccd5e9e + # c9cc7d2a: confidential_asset on confidential-asset-prod with the Movement + # bulletproofs DST rename (MovementConfidentialAsset/...), the chain/asset + # auditor split, the self-transfer ban (ESELF_TRANSFER), and the updated + # start-localnet-confidential-assets.sh. Matches the SDK in this repo: native + # Movement-DST prover (movement_rp_wasm) and two-account transfer tests. + APTOS_CORE_COMMIT: c9cc7d2a47c464296bb40e5d7532907e95ae888f jobs: ca-e2e: diff --git a/crates/confidential-assets/tests/e2e/confidential_asset.rs b/crates/confidential-assets/tests/e2e/confidential_asset.rs index 960b76e..739c47d 100644 --- a/crates/confidential-assets/tests/e2e/confidential_asset.rs +++ b/crates/confidential-assets/tests/e2e/confidential_asset.rs @@ -19,15 +19,7 @@ async fn register_alice( alice: &Ed25519Account, alice_dk: &TwistedEd25519PrivateKey, ) { - let ca = make_confidential_asset(movement); - let token = token_address(); - let payload = ca - .register_balance(&alice.address(), &token, alice_dk) - .await - .expect("build register_balance"); - send_and_wait(movement, alice, payload) - .await - .expect("register_balance tx"); + register_account(movement, alice, alice_dk).await; } async fn deposit(movement: &movement_sdk::Movement, alice: &Ed25519Account, amount: u64) { @@ -41,6 +33,36 @@ async fn deposit(movement: &movement_sdk::Movement, alice: &Ed25519Account, amou .expect("deposit tx"); } +/// Register a confidential balance for any account (Alice, Bob, …). +async fn register_account( + movement: &movement_sdk::Movement, + account: &Ed25519Account, + dk: &TwistedEd25519PrivateKey, +) { + let ca = make_confidential_asset(movement); + let token = token_address(); + let payload = ca + .register_balance(&account.address(), &token, dk) + .await + .expect("build register_balance"); + send_and_wait(movement, account, payload) + .await + .expect("register_balance tx"); +} + +/// Create + fund + register a recipient account (e.g. Bob) so it can receive confidential +/// transfers. The contract rejects self-transfers (ESELF_TRANSFER), so transfer tests send to a +/// distinct registered account. Returns the account and its decryption key. +async fn setup_recipient( + movement: &movement_sdk::Movement, +) -> (Ed25519Account, TwistedEd25519PrivateKey) { + let bob = Ed25519Account::generate(); + let bob_dk = get_test_confidential_account(Some(&bob)); + fund_and_migrate(movement, &bob).await.expect("fund bob"); + register_account(movement, &bob, &bob_dk).await; + (bob, bob_dk) +} + /// Deposits a public-FA amount and checks it lands in `pending` (not `available`). #[tokio::test] #[ignore = "requires localnet — see tests/README.md"] @@ -302,11 +324,13 @@ async fn it_throws_when_transferring_more_than_available() { ); } -/// Self-transfer with no auditor: amount moves out of `available` and lands in the -/// sender's own `pending`. Smoke test for the basic transfer-σ + range-proof path. +/// The contract rejects confidential transfers where sender == recipient, aborting on-chain +/// with `ESELF_TRANSFER`. The SDK builds the payload fine (it special-cases the recipient key); +/// the abort happens at execution and leaves balances untouched. Mirrors the TS e2e test +/// "it should reject a self-transfer with ESELF_TRANSFER". #[tokio::test] #[ignore = "requires localnet — see tests/README.md"] -async fn it_transfers_alice_to_alice_pending_no_auditor() { +async fn it_rejects_self_transfer() { let movement = make_movement().expect("movement client"); let alice = get_test_account(); let alice_dk = get_test_confidential_account(Some(&alice)); @@ -324,15 +348,65 @@ async fn it_transfers_alice_to_alice_pending_no_auditor() { send_and_wait(&movement, &alice, p).await.expect("rollover"); } - let pre = ca + // A valid amount, so the build's balance/range checks pass — the only reason this fails is + // the self-transfer ban, not insufficient funds. + let payload = ca + .transfer( + &alice.address(), + &alice.address(), + &token_address(), + TRANSFER_AMOUNT, + &alice_dk, + &[], + &[], + ) + .await + .expect("self-transfer builds; the ban is enforced on-chain"); + let res = send_and_wait(&movement, &alice, payload).await; + assert!( + res.is_err(), + "self-transfer must be rejected on-chain with ESELF_TRANSFER" + ); +} + +/// Transfer Alice → Bob with no auditor: the amount leaves Alice's `available` and lands in +/// Bob's `pending`. Smoke test for the basic transfer-σ + range-proof path. (Self-transfers are +/// rejected on-chain with ESELF_TRANSFER, so the recipient is a distinct registered account.) +#[tokio::test] +#[ignore = "requires localnet — see tests/README.md"] +async fn it_transfers_alice_to_bob_pending_no_auditor() { + let movement = make_movement().expect("movement client"); + let alice = get_test_account(); + let alice_dk = get_test_confidential_account(Some(&alice)); + + fund_and_migrate(&movement, &alice).await.expect("fund"); + register_alice(&movement, &alice, &alice_dk).await; + deposit(&movement, &alice, DEPOSIT_AMOUNT).await; + + let (bob, bob_dk) = setup_recipient(&movement).await; + + let ca = make_confidential_asset(&movement); + let rollovers = ca + .rollover_pending_balance(&alice.address(), &token_address(), None, false) + .await + .expect("rollover build"); + for p in rollovers { + send_and_wait(&movement, &alice, p).await.expect("rollover"); + } + + let alice_pre = ca .get_balance(&alice.address(), &token_address(), &alice_dk) .await - .expect("get_balance"); + .expect("get_balance alice"); + let bob_pre = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); let payload = ca .transfer( &alice.address(), - &alice.address(), + &bob.address(), &token_address(), TRANSFER_AMOUNT, &alice_dk, @@ -345,17 +419,22 @@ async fn it_transfers_alice_to_alice_pending_no_auditor() { .await .expect("transfer tx"); - let post = ca + let alice_post = ca .get_balance(&alice.address(), &token_address(), &alice_dk) .await - .expect("get_balance"); + .expect("get_balance alice"); + let bob_post = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); + // Amount leaves Alice's available and lands in Bob's pending. assert_eq!( - post.available_balance(), - pre.available_balance() - TRANSFER_AMOUNT as u128 + alice_post.available_balance(), + alice_pre.available_balance() - TRANSFER_AMOUNT as u128 ); assert_eq!( - post.pending_balance(), - pre.pending_balance() + TRANSFER_AMOUNT as u128 + bob_post.pending_balance(), + bob_pre.pending_balance() + TRANSFER_AMOUNT as u128 ); } @@ -377,6 +456,8 @@ async fn it_transfers_twice_without_intervening_rollover() { register_alice(&movement, &alice, &alice_dk).await; deposit(&movement, &alice, DEPOSIT_AMOUNT).await; + let (bob, bob_dk) = setup_recipient(&movement).await; + let ca = make_confidential_asset(&movement); let rollovers = ca .rollover_pending_balance(&alice.address(), &token_address(), None, false) @@ -386,15 +467,19 @@ async fn it_transfers_twice_without_intervening_rollover() { send_and_wait(&movement, &alice, p).await.expect("rollover"); } - let pre = ca + let alice_pre = ca .get_balance(&alice.address(), &token_address(), &alice_dk) .await - .expect("get_balance"); + .expect("get_balance alice"); + let bob_pre = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); let payload1 = ca .transfer( &alice.address(), - &alice.address(), + &bob.address(), &token_address(), TRANSFER_AMOUNT, &alice_dk, @@ -411,7 +496,7 @@ async fn it_transfers_twice_without_intervening_rollover() { let payload2 = ca .transfer( &alice.address(), - &alice.address(), + &bob.address(), &token_address(), TRANSFER_AMOUNT, &alice_dk, @@ -424,25 +509,29 @@ async fn it_transfers_twice_without_intervening_rollover() { .await .expect("transfer 2 tx"); - let post = ca + let alice_post = ca .get_balance(&alice.address(), &token_address(), &alice_dk) .await - .expect("get_balance"); + .expect("get_balance alice"); + let bob_post = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); assert_eq!( - post.available_balance(), - pre.available_balance() - 2 * TRANSFER_AMOUNT as u128 + alice_post.available_balance(), + alice_pre.available_balance() - 2 * TRANSFER_AMOUNT as u128 ); assert_eq!( - post.pending_balance(), - pre.pending_balance() + 2 * TRANSFER_AMOUNT as u128 + bob_post.pending_balance(), + bob_pre.pending_balance() + 2 * TRANSFER_AMOUNT as u128 ); } -/// Transfer with one extra auditor key: the σ-proof's `x7` rows and the auditor-amount -/// ciphertexts must verify in the same MSM Move's verifier runs. +/// Transfer Alice → Bob with one extra auditor key: the σ-proof's `x7` rows and the +/// auditor-amount ciphertexts must verify in the same MSM Move's verifier runs. #[tokio::test] #[ignore = "requires localnet — see tests/README.md"] -async fn it_transfers_alice_to_alice_with_auditor() { +async fn it_transfers_alice_to_bob_with_auditor() { let movement = make_movement().expect("movement client"); let alice = get_test_account(); let alice_dk = get_test_confidential_account(Some(&alice)); @@ -452,6 +541,8 @@ async fn it_transfers_alice_to_alice_with_auditor() { register_alice(&movement, &alice, &alice_dk).await; deposit(&movement, &alice, DEPOSIT_AMOUNT).await; + let (bob, bob_dk) = setup_recipient(&movement).await; + let ca = make_confidential_asset(&movement); let rollovers = ca .rollover_pending_balance(&alice.address(), &token_address(), None, false) @@ -461,10 +552,15 @@ async fn it_transfers_alice_to_alice_with_auditor() { send_and_wait(&movement, &alice, p).await.expect("rollover"); } + let bob_pre = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); + let payload = ca .transfer( &alice.address(), - &alice.address(), + &bob.address(), &token_address(), TRANSFER_AMOUNT, &alice_dk, @@ -476,6 +572,15 @@ async fn it_transfers_alice_to_alice_with_auditor() { send_and_wait(&movement, &alice, payload) .await .expect("transfer tx"); + + let bob_post = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); + assert_eq!( + bob_post.pending_balance(), + bob_pre.pending_balance() + TRANSFER_AMOUNT as u128 + ); } /// A freshly-registered account's pending balance is not frozen. @@ -726,17 +831,24 @@ async fn it_transfers_with_total_balance_after_deposit() { register_alice(&movement, &alice, &alice_dk).await; deposit(&movement, &alice, DEPOSIT_AMOUNT).await; + let (bob, bob_dk) = setup_recipient(&movement).await; + let ca = make_confidential_asset(&movement); - let pre = ca + let alice_pre = ca .get_balance(&alice.address(), &token_address(), &alice_dk) .await - .expect("get_balance"); - let transfer_amount = pre.available_balance() as u64 + 1; + .expect("get_balance alice"); + let bob_pre = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); + // Force the auto-rollover path: spend more than `available`, drawing from pending too. + let transfer_amount = alice_pre.available_balance() as u64 + 1; let transfer_payload = ca .transfer_with_total_balance( &alice, - &alice.address(), + &bob.address(), &token_address(), transfer_amount, &alice_dk, @@ -749,10 +861,22 @@ async fn it_transfers_with_total_balance_after_deposit() { .await .expect("transfer-with-total tx"); - let post = ca + let alice_post = ca .get_balance(&alice.address(), &token_address(), &alice_dk) .await - .expect("get_balance"); - let pre_total = pre.available_balance() + pre.pending_balance(); - assert_eq!(post.available_balance() + post.pending_balance(), pre_total); + .expect("get_balance alice"); + let bob_post = ca + .get_balance(&bob.address(), &token_address(), &bob_dk) + .await + .expect("get_balance bob"); + // Alice's total (available + pending) drops by the transfer amount; it lands in Bob's pending. + let alice_pre_total = alice_pre.available_balance() + alice_pre.pending_balance(); + assert_eq!( + alice_post.available_balance() + alice_post.pending_balance(), + alice_pre_total - transfer_amount as u128 + ); + assert_eq!( + bob_post.pending_balance(), + bob_pre.pending_balance() + transfer_amount as u128 + ); } diff --git a/crates/confidential-assets/tests/e2e/txn_builder.rs b/crates/confidential-assets/tests/e2e/txn_builder.rs index c1163fa..c0fe0d6 100644 --- a/crates/confidential-assets/tests/e2e/txn_builder.rs +++ b/crates/confidential-assets/tests/e2e/txn_builder.rs @@ -178,26 +178,37 @@ async fn builder_withdraws_alices_balance() { .expect("withdraw tx"); } -/// Builder-level self-transfer: produces a transfer payload accepted by Move's -/// `verify_transfer_sigma_proof` (basic positive case for the raw builder API). +/// Builder-level transfer Alice → Bob: produces a transfer payload accepted by Move's +/// `verify_transfer_sigma_proof` (basic positive case for the raw builder API). A distinct +/// recipient is required — the contract rejects self-transfers with ESELF_TRANSFER. #[tokio::test] #[ignore = "requires localnet — see tests/README.md"] -async fn builder_transfers_to_self() { +async fn builder_transfers_to_bob() { let movement = make_movement().expect("movement client"); let alice = get_test_account(); let alice_dk = get_test_confidential_account(Some(&alice)); + let bob = Ed25519Account::generate(); + let bob_dk = get_test_confidential_account(Some(&bob)); let module = module_address(); let builder = ConfidentialAssetTransactionBuilder::new(&movement, Some(&module)) .expect("valid module address"); - fund_and_migrate(&movement, &alice).await.expect("fund"); - let r = builder - .register_balance(&alice.address(), &token_address(), &alice_dk) - .await - .expect("build register"); - send_and_wait(&movement, &alice, r) + fund_and_migrate(&movement, &alice) .await - .expect("register tx"); + .expect("fund alice"); + fund_and_migrate(&movement, &bob).await.expect("fund bob"); + + // Register both Alice (sender) and Bob (recipient) confidential balances. + for (acct, dk) in [(&alice, &alice_dk), (&bob, &bob_dk)] { + let r = builder + .register_balance(&acct.address(), &token_address(), dk) + .await + .expect("build register"); + send_and_wait(&movement, acct, r) + .await + .expect("register tx"); + } + let dep = builder .deposit(&alice.address(), &token_address(), DEPOSIT_AMOUNT, None) .expect("build deposit"); @@ -215,7 +226,7 @@ async fn builder_transfers_to_self() { let transfer = builder .transfer( &alice.address(), - &alice.address(), + &bob.address(), &token_address(), TRANSFER_AMOUNT, &alice_dk, From 89e1b4fef6c74b5d3cb6cc90755772ee33f55b09 Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:30:11 -0400 Subject: [PATCH 07/11] fix: make confidential assets address 0x1 in tests --- .github/workflows/ca-e2e.yml | 15 ++++++--------- crates/confidential-assets/tests/e2e/helpers.rs | 15 +++++++-------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ca-e2e.yml b/.github/workflows/ca-e2e.yml index 0860926..b49231b 100644 --- a/.github/workflows/ca-e2e.yml +++ b/.github/workflows/ca-e2e.yml @@ -136,15 +136,12 @@ jobs: cat "$LOG" exit 1 fi - ADDR=$(grep -oE 'publish signer \(default profile\) 0x[0-9a-f]{64}' "$LOG" \ - | head -n1 | awk '{print $NF}') - if [[ -z "$ADDR" ]]; then - echo "Could not parse publish-signer address from script output." >&2 - cat "$LOG" - exit 1 - fi - echo "CONFIDENTIAL_MODULE_ADDRESS=$ADDR" - echo "CONFIDENTIAL_MODULE_ADDRESS=$ADDR" >> "$GITHUB_ENV" + # confidential_asset moved into the framework (aptos-core #366), so it's published at + # the framework address 0x1 — not the experimental publish-signer account this step + # used to parse out of the script log. We still wait for "Done" above so the feature + # flag and chain-auditor bootstrap have completed before the tests run. + echo "CONFIDENTIAL_MODULE_ADDRESS=0x1" + echo "CONFIDENTIAL_MODULE_ADDRESS=0x1" >> "$GITHUB_ENV" - name: Verify REST endpoint is up run: | diff --git a/crates/confidential-assets/tests/e2e/helpers.rs b/crates/confidential-assets/tests/e2e/helpers.rs index 072cd57..dbe60f7 100644 --- a/crates/confidential-assets/tests/e2e/helpers.rs +++ b/crates/confidential-assets/tests/e2e/helpers.rs @@ -20,15 +20,14 @@ pub const FUND_AMOUNT: u64 = 1_000_000_000; pub fn module_address() -> String { let raw = env::var("CONFIDENTIAL_MODULE_ADDRESS").expect( "CONFIDENTIAL_MODULE_ADDRESS env var is required for e2e tests \ - (run scripts/start-localnet-confidential-assets.sh and export the printed module address)", - ); - let hex = raw.trim_start_matches("0x"); - assert_eq!( - hex.len(), - 64, - "CONFIDENTIAL_MODULE_ADDRESS must be a 32-byte hex string (64 chars after 0x), got {} chars: {raw:?}", - hex.len() + (run scripts/start-localnet-confidential-assets.sh; confidential_asset now ships in the \ + framework, so this is 0x1)", ); + // confidential_asset moved into the framework (aptos-core #366) — the address is `0x1`, not + // the 32-byte experimental publish-signer account it used to be. Just validate it parses. + AccountAddress::from_hex(&raw).unwrap_or_else(|e| { + panic!("CONFIDENTIAL_MODULE_ADDRESS must be a valid hex address, got {raw:?}: {e}") + }); raw } From 8498dc5666b2f4e393f8432e2af93c4acc08080f Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:51:01 -0400 Subject: [PATCH 08/11] fix: use full 0x1 address with leading zeros for confidential module address --- .github/workflows/ca-e2e.yml | 14 ++++++++------ .../confidential-assets/tests/e2e/helpers.rs | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ca-e2e.yml b/.github/workflows/ca-e2e.yml index b49231b..4f41c1f 100644 --- a/.github/workflows/ca-e2e.yml +++ b/.github/workflows/ca-e2e.yml @@ -136,12 +136,14 @@ jobs: cat "$LOG" exit 1 fi - # confidential_asset moved into the framework (aptos-core #366), so it's published at - # the framework address 0x1 — not the experimental publish-signer account this step - # used to parse out of the script log. We still wait for "Done" above so the feature - # flag and chain-auditor bootstrap have completed before the tests run. - echo "CONFIDENTIAL_MODULE_ADDRESS=0x1" - echo "CONFIDENTIAL_MODULE_ADDRESS=0x1" >> "$GITHUB_ENV" + # confidential_asset moved into the framework (aptos-core #366), so it's at the + # framework address 0x1 — not the experimental publish-signer account this step used to + # parse out of the script log. Use the full 32-byte form (the test helper requires a + # 64-hex-char address, matching TOKEN_ADDRESS). We still wait for "Done" above so the + # feature flag and chain-auditor bootstrap have completed before the tests run. + ADDR=0x0000000000000000000000000000000000000000000000000000000000000001 + echo "CONFIDENTIAL_MODULE_ADDRESS=$ADDR" + echo "CONFIDENTIAL_MODULE_ADDRESS=$ADDR" >> "$GITHUB_ENV" - name: Verify REST endpoint is up run: | diff --git a/crates/confidential-assets/tests/e2e/helpers.rs b/crates/confidential-assets/tests/e2e/helpers.rs index dbe60f7..048e8bd 100644 --- a/crates/confidential-assets/tests/e2e/helpers.rs +++ b/crates/confidential-assets/tests/e2e/helpers.rs @@ -19,15 +19,18 @@ pub const FUND_AMOUNT: u64 = 1_000_000_000; pub fn module_address() -> String { let raw = env::var("CONFIDENTIAL_MODULE_ADDRESS").expect( - "CONFIDENTIAL_MODULE_ADDRESS env var is required for e2e tests \ - (run scripts/start-localnet-confidential-assets.sh; confidential_asset now ships in the \ - framework, so this is 0x1)", + "CONFIDENTIAL_MODULE_ADDRESS env var is required for e2e tests (run \ + scripts/start-localnet-confidential-assets.sh). confidential_asset now ships in the \ + framework, so this is 0x1 in full 32-byte form: \ + 0x0000000000000000000000000000000000000000000000000000000000000001", + ); + let hex = raw.trim_start_matches("0x"); + assert_eq!( + hex.len(), + 64, + "CONFIDENTIAL_MODULE_ADDRESS must be a 32-byte hex string (64 chars after 0x), got {} chars: {raw:?}", + hex.len() ); - // confidential_asset moved into the framework (aptos-core #366) — the address is `0x1`, not - // the 32-byte experimental publish-signer account it used to be. Just validate it parses. - AccountAddress::from_hex(&raw).unwrap_or_else(|e| { - panic!("CONFIDENTIAL_MODULE_ADDRESS must be a valid hex address, got {raw:?}: {e}") - }); raw } From 07552b73ee94a8070fd94b7e5616f3135ad24b23 Mon Sep 17 00:00:00 2001 From: musitdev Date: Wed, 17 Jun 2026 15:05:18 +0200 Subject: [PATCH 09/11] feat(confidential-assets): add normalize and rollover steps to simple_ca_transfer example - normalize Alice's available balance after the confidential transfer - rollover Bob's pending balance then normalize his available balance - add balance reads and assertions after each step to validate state - add the correct cargo run command with explicit package flag to the header --- .../examples/simple_ca_transfer.rs | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/confidential-assets/examples/simple_ca_transfer.rs b/crates/confidential-assets/examples/simple_ca_transfer.rs index 07ff53c..26d8cdd 100644 --- a/crates/confidential-assets/examples/simple_ca_transfer.rs +++ b/crates/confidential-assets/examples/simple_ca_transfer.rs @@ -16,6 +16,7 @@ // 4. CONFIDENTIAL_MODULE_ADDRESS=0x (optional, defaults to 0x1) // To run wih all defaul: // cargo run --example simple_ca_transfer --features e2e +// cargo run -p confidential-assets --example simple_ca_transfer --features confidential-assets/e2e use confidential_assets::api::ConfidentialAsset; use confidential_assets::crypto::twisted_ed25519::{ @@ -162,11 +163,11 @@ async fn main() -> MovementResult<()> { movement.sign_submit_and_wait(&alice, payload, None).await?; println!(); - // ── 8. Verify final balances ───────────────────────────────────────────── + // ── 8. Verify balances after transfer ──────────────────────────────────── let alice_bal = ca.get_balance(&alice.address(), &token, &alice_dk).await?; let bob_bal = ca.get_balance(&bob.address(), &token, &bob_dk).await?; - println!("=== Final balances ==="); + println!("=== Balances after transfer ==="); println!( " Alice — available: {}, pending: {}", alice_bal.available_balance(), @@ -179,7 +180,6 @@ async fn main() -> MovementResult<()> { ); println!(); - // The transfer amount leaves Alice's available and lands in Bob's pending. assert_eq!( alice_bal.available_balance(), (DEPOSIT_AMOUNT - TRANSFER_AMOUNT) as u128, @@ -191,7 +191,73 @@ async fn main() -> MovementResult<()> { "Bob's pending balance should equal the transfer amount" ); - println!("All assertions passed. Example complete!"); + // ── 9. Normalize Alice's available balance ──────────────────────────────── + println!("Normalizing Alice's balance …"); + let payload = ca + .normalize_balance(&alice.address(), &alice_dk, &token) + .await?; + movement.sign_submit_and_wait(&alice, payload, None).await?; + + let alice_bal = ca.get_balance(&alice.address(), &token, &alice_dk).await?; + println!( + " Alice after normalize — available: {}, pending: {}", + alice_bal.available_balance(), + alice_bal.pending_balance() + ); + assert_eq!( + alice_bal.available_balance(), + (DEPOSIT_AMOUNT - TRANSFER_AMOUNT) as u128, + "Alice's available balance should be unchanged after normalize" + ); + println!(); + + // ── 10. Rollover Bob's pending balance → available ──────────────────────── + println!("Rolling over Bob's pending balance …"); + let payloads = ca + .rollover_pending_balance(&bob.address(), &token, Some(&bob_dk), false) + .await?; + for p in payloads { + movement.sign_submit_and_wait(&bob, p, None).await?; + } + + let bob_bal = ca.get_balance(&bob.address(), &token, &bob_dk).await?; + println!( + " Bob after rollover — available: {}, pending: {}", + bob_bal.available_balance(), + bob_bal.pending_balance() + ); + assert_eq!( + bob_bal.available_balance(), + TRANSFER_AMOUNT as u128, + "Bob's available balance should equal the transfer amount after rollover" + ); + assert_eq!( + bob_bal.pending_balance(), + 0, + "Bob's pending balance should be zero after rollover" + ); + println!(); + + // ── 11. Normalize Bob's available balance ───────────────────────────────── + println!("Normalizing Bob's balance …"); + let payload = ca + .normalize_balance(&bob.address(), &bob_dk, &token) + .await?; + movement.sign_submit_and_wait(&bob, payload, None).await?; + + let bob_bal = ca.get_balance(&bob.address(), &token, &bob_dk).await?; + println!( + " Bob after normalize — available: {}, pending: {}", + bob_bal.available_balance(), + bob_bal.pending_balance() + ); + assert_eq!( + bob_bal.available_balance(), + TRANSFER_AMOUNT as u128, + "Bob's available balance should be unchanged after normalize" + ); + + println!("\nAll assertions passed. Example complete!"); Ok(()) } From ea02d1b69ff7e9145fa24a24df0b91621197916b Mon Sep 17 00:00:00 2001 From: musitdev Date: Wed, 17 Jun 2026 18:34:33 +0200 Subject: [PATCH 10/11] add Bob normalization to simple CA transfer --- .../examples/simple_ca_transfer.rs | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/crates/confidential-assets/examples/simple_ca_transfer.rs b/crates/confidential-assets/examples/simple_ca_transfer.rs index 26d8cdd..099a42f 100644 --- a/crates/confidential-assets/examples/simple_ca_transfer.rs +++ b/crates/confidential-assets/examples/simple_ca_transfer.rs @@ -20,13 +20,13 @@ use confidential_assets::api::ConfidentialAsset; use confidential_assets::crypto::twisted_ed25519::{ - DECRYPTION_KEY_DERIVATION_MESSAGE, TwistedEd25519PrivateKey, + TwistedEd25519PrivateKey, DECRYPTION_KEY_DERIVATION_MESSAGE, }; use movement_sdk::{ - Movement, MovementConfig, MovementError, MovementResult, account::Ed25519Account, transaction::{EntryFunction, TransactionPayload}, types::{AccountAddress, Identifier, MoveModuleId, TypeTag}, + Movement, MovementConfig, MovementError, MovementResult, }; use std::env; @@ -191,26 +191,6 @@ async fn main() -> MovementResult<()> { "Bob's pending balance should equal the transfer amount" ); - // ── 9. Normalize Alice's available balance ──────────────────────────────── - println!("Normalizing Alice's balance …"); - let payload = ca - .normalize_balance(&alice.address(), &alice_dk, &token) - .await?; - movement.sign_submit_and_wait(&alice, payload, None).await?; - - let alice_bal = ca.get_balance(&alice.address(), &token, &alice_dk).await?; - println!( - " Alice after normalize — available: {}, pending: {}", - alice_bal.available_balance(), - alice_bal.pending_balance() - ); - assert_eq!( - alice_bal.available_balance(), - (DEPOSIT_AMOUNT - TRANSFER_AMOUNT) as u128, - "Alice's available balance should be unchanged after normalize" - ); - println!(); - // ── 10. Rollover Bob's pending balance → available ──────────────────────── println!("Rolling over Bob's pending balance …"); let payloads = ca From ab68869fe61a807140b30167b2bb218a2e921299 Mon Sep 17 00:00:00 2001 From: musitdev Date: Wed, 17 Jun 2026 18:49:52 +0200 Subject: [PATCH 11/11] pass cargo fmt --all --- crates/confidential-assets/examples/simple_ca_transfer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/confidential-assets/examples/simple_ca_transfer.rs b/crates/confidential-assets/examples/simple_ca_transfer.rs index 099a42f..50ba3df 100644 --- a/crates/confidential-assets/examples/simple_ca_transfer.rs +++ b/crates/confidential-assets/examples/simple_ca_transfer.rs @@ -20,13 +20,13 @@ use confidential_assets::api::ConfidentialAsset; use confidential_assets::crypto::twisted_ed25519::{ - TwistedEd25519PrivateKey, DECRYPTION_KEY_DERIVATION_MESSAGE, + DECRYPTION_KEY_DERIVATION_MESSAGE, TwistedEd25519PrivateKey, }; use movement_sdk::{ + Movement, MovementConfig, MovementError, MovementResult, account::Ed25519Account, transaction::{EntryFunction, TransactionPayload}, types::{AccountAddress, Identifier, MoveModuleId, TypeTag}, - Movement, MovementConfig, MovementError, MovementResult, }; use std::env;