From bc7e3cf93e24112299f29753b16ff6b5a59bf430 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 28 Sep 2026 06:02:48 +0700 Subject: [PATCH] docs: encrypt the 69-byte compact xpub in the contact-request guide The book's contact-request guide encrypted the full 78-byte BIP32 xpub. DIP-15 and every receiver (the reference mobile wallets and our Rust, Swift and Kotlin SDKs) use the 69-byte compact form: parent fingerprint, chain code and public key. Both lengths pad to the same 80-byte ciphertext, so the contract accepts the document and the recipient's wallet then drops it. The helper now slices the compact form out of the decoded xpub. Also corrects doc comments that no longer match the code: - build_contact_accounts only queues work for the signer-backed drain - displayHidden hides a contact; it is not the ignore feature - contactInfo privateData plaintext is the DIP-15 var-int encoding, not CBOR - the pending contact-crypto queue is not restored on load, and nothing blocks that any more - SpvEventForwarder and TokenWallet were replaced by LockNotifyHandler and IdentitySyncManager - the shielded example and bench no longer mirror an iOS raw-seed bind or a drive-abci seeder Co-Authored-By: Claude Opus 5.5 --- book/src/evo-sdk/dashpay-contact-requests.md | 19 +++++++-- .../src/contact_info.rs | 3 +- .../examples/shielded_sync_paloma.rs | 3 +- .../src/wallet/asset_lock/manager.rs | 2 +- .../src/wallet/asset_lock/sync/proof.rs | 2 +- .../src/wallet/asset_lock/sync/recovery.rs | 2 +- .../wallet/identity/crypto/contact_info.rs | 5 ++- .../identity/network/contact_requests.rs | 39 +++++++------------ .../src/wallet/identity/network/tokens/mod.rs | 3 +- .../state/managed_identity/dashpay.rs | 7 ++-- .../tests/shielded_decrypt_bench.rs | 2 +- 11 files changed, 46 insertions(+), 41 deletions(-) diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md index beaf9e0953e..10f1a88081d 100644 --- a/book/src/evo-sdk/dashpay-contact-requests.md +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -31,7 +31,15 @@ The current DashPay contract schema requires the system field `encryptedPublicKey` is exactly 96 bytes: - 16 bytes: AES-CBC initialization vector -- 80 bytes: AES-CBC ciphertext for the sender's 78-byte serialized contact xpub +- 80 bytes: AES-CBC ciphertext for the sender's 69-byte compact contact xpub + +The compact xpub is the parent fingerprint (4 bytes), chain code (32 bytes) +and public key (33 bytes), without the version, depth and child number of a +full 78-byte BIP32 serialization. Receivers, including the reference mobile +wallets and the Rust, Swift and Kotlin SDKs, refuse any other plaintext +length. Both 69 and 78 bytes pad to the same 80-byte ciphertext, so the +contract cannot catch the mistake: a request that encrypts the full 78 bytes +is stored on chain, and the recipient's wallet then drops it. The sender derives the contact xpub from the sender identity, recipient identity, account, and address index. The sender then encrypts that xpub with an @@ -99,14 +107,17 @@ function deriveSharedKey({ return crypto.createHash('sha256').update(Buffer.concat([compressedPrefix, x])).digest(); } -function serializedXpubPayload(xpub: string): Buffer { +function compactXpubPayload(xpub: string): Buffer { const payload = dashcore.encoding.Base58Check.decode(xpub); if (payload.length !== 78) { throw new Error(`Invalid DashPay contact xpub length: ${payload.length}`); } - return payload; + // BIP32 layout: version(4) depth(1) parentFingerprint(4) childNumber(4) + // chainCode(32) publicKey(33). DIP-15 encrypts only the parent + // fingerprint, chain code and public key: 69 bytes. + return Buffer.concat([payload.subarray(5, 9), payload.subarray(13, 78)]); } function encryptContactXpub({ @@ -122,7 +133,7 @@ function encryptContactXpub({ privateKeyWif: senderEncryptionPrivateKeyWif, publicKeyBytes: recipientDecryptionPublicKeyBytes, }); - const payload = serializedXpubPayload(contactXpub); + const payload = compactXpubPayload(contactXpub); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); const encrypted = Buffer.concat([ diff --git a/packages/rs-platform-encryption/src/contact_info.rs b/packages/rs-platform-encryption/src/contact_info.rs index 48564c93143..bf24b8686b9 100644 --- a/packages/rs-platform-encryption/src/contact_info.rs +++ b/packages/rs-platform-encryption/src/contact_info.rs @@ -39,7 +39,8 @@ pub fn decrypt_enc_to_user_id(key: &[u8; 32], ciphertext: &[u8; 32]) -> [u8; 32] out } -/// Encrypt a `contactInfo.privateData` plaintext (CBOR bytes) as +/// Encrypt a `contactInfo.privateData` plaintext (the DIP-15 var-int +/// encoding, built by the wallet's `encode_private_data`) as /// `IV(16) ‖ AES-256-CBC(plaintext)` — the same prepended-IV layout /// `encryptedPublicKey` uses (DIP-15 doesn't pin the layout for this /// field; we adopt the same convention). diff --git a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs index f015a3613ba..762280b0eb0 100644 --- a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs +++ b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs @@ -247,8 +247,7 @@ async fn main() { hex::encode(platform_wallet.wallet_id()) ); - // --- 4. Bind shielded account 0 with the raw seed (mirrors the - // iOS `bindShieldedRawSeed` path; same FFI shape). --- + // --- 4. Bind shielded account 0 with the raw seed. --- let coordinator = manager .shielded_coordinator() .await diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 7bd6fdc0e8a..bd3c06f8637 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -36,7 +36,7 @@ pub struct AssetLockManager { pub(super) wallet_manager: Arc>>, /// Identifies which wallet within the manager this manager operates on. pub(super) wallet_id: WalletId, - /// Notified on InstantLock / ChainLock events by SpvEventForwarder. + /// Notified on InstantLock / ChainLock events by `LockNotifyHandler`. /// Used by `wait_for_proof()` and `wait_for_chain_lock()`. pub(super) lock_notify: Arc, /// Transaction broadcaster — pluggable so the same `AssetLockManager` diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index 5a2eb7fc4b3..e7d4cdbc2c2 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -994,7 +994,7 @@ impl AssetLockManager { /// /// Wait for an asset lock proof by checking transaction context state. /// - /// Wakes on `lock_notify` (fired by `SpvEventForwarder` on InstantLock / + /// Wakes on `lock_notify` (fired by `LockNotifyHandler` on InstantLock / /// ChainLock events) and re-checks the transaction record context. /// /// Returns a properly-constructed `AssetLockProof` on success, or diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index e4fc8302234..7e886ea7da6 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -2682,7 +2682,7 @@ mod tests { /// rebuild that fails at input selection is direct proof the funding /// reservation is still held. signer: crate::test_support::WalletSigner, - /// The handle `SpvEventForwarder` fires on IS/ChainLock events, so + /// The handle `LockNotifyHandler` fires on IS/ChainLock events, so /// a test can wake an in-flight proof wait the way the live wallet /// does. lock_notify: Arc, diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs index 63b07a87f11..89047507b1a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs @@ -137,8 +137,9 @@ pub struct ContactInfoPrivateData { pub alias_name: Option, /// Free-form note. pub note: Option, - /// Whether the contact is hidden / ignored (DIP-15 `displayHidden` — the - /// hide flag, also the cross-device ignore signal). + /// Whether the contact is hidden (DIP-15 `displayHidden`). This hides an + /// established contact; it is not the ignore feature, which is local to + /// each device and covers senders who are not contacts. pub display_hidden: bool, /// Accepted rotated account-references of an established contact (DIP-15 /// `acceptedAccounts`). Empty until multi-account is populated. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 8f95fed9279..41b9c0addaa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1885,33 +1885,24 @@ impl DashPayView<'_, B> { out } - /// Build the two DashPay accounts for one established contact, - /// applying the transient/permanent failure policy. + /// Queue the two DashPay account builds for one established contact. /// - /// Order: - /// 1. Register the `DashpayReceivingFunds` account — derivable from our - /// own seed, no decryption needed. This is what makes *incoming* - /// contact payments visible to SPV; restore-from-seed leaves it - /// unbuilt, so the sweep rebuilds it for every established contact. - /// 2. Fetch the counterparty identity and **validate** the request's - /// key indices via [`validate_contact_request`] BEFORE any ECDH — - /// an attacker-crafted index pointing at an AUTHENTICATION key would - /// otherwise derive a wrong shared secret and poison the account. - /// 3. Register the `DashpayExternalAccount` (decrypt + ECDH). + /// The recurring sweep runs without a signer, so it cannot derive the + /// receiving (friendship) xpub or the ECDH secret itself. It enqueues + /// both ops for the signer-backed drain + /// ([`Self::drain_pending_contact_crypto_verified`]), which registers the + /// `DashpayReceivingFunds` account, fetches the counterparty, validates + /// the request's key indices before any ECDH, and registers the + /// `DashpayExternalAccount`. Transient failures stay queued for the next + /// drain; permanent ones mark the contact `payment_channel_broken`. /// - /// Failure policy: - /// - **Transient** (identity fetch / network): logged, left for the - /// next sweep to retry. The broken flag stays clear. - /// - **Permanent** (validation failure, decrypt/decode failure): the - /// contact is marked `payment_channel_broken` so subsequent sweeps - /// skip it until a superseding request arrives. + /// Identities that are not ours to build (no `identity_index`) are + /// skipped and logged. Enqueueing is idempotent per (owner, contact, + /// kind), so calling this every sweep is a no-op until the drain clears + /// the entries. /// - /// Watch-only / seedless wallets (no `identity_index`) are skipped and - /// logged — the watch-only ECDH path (host-side signing hook) lands - /// later. - /// - /// Called **after** the sync write guard is dropped: the register - /// functions re-acquire the non-reentrant wallet-manager lock. + /// Called **after** the sync write guard is dropped: this re-acquires + /// the non-reentrant wallet-manager lock. async fn build_contact_accounts( &self, identity_id: &Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs index 84942b02e8c..e68496838f9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs @@ -5,7 +5,8 @@ //! is an identity-as-actor operation, so it lives here next to the rest of //! the identity-lifecycle and DashPay surface (same precedent as the //! DashPay merge described in the parent `mod.rs`). Token *bookkeeping* -//! (watch / sync / balance) stays on [`crate::wallet::tokens::TokenWallet`] +//! (watch / sync / balance) lives on +//! [`IdentitySyncManager`](crate::manager::identity_sync::IdentitySyncManager) //! because it's wallet-scoped, not identity-scoped. mod burn; diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index fb981b0ebd4..fdb698f7cbf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -150,9 +150,10 @@ pub struct DashPayState { /// on-chain ciphertext + public-key indices; each entry still carries its /// `owner_identity_id` (== this identity) as the drain's routing key and the /// SQLite key column. In-memory only for the live session: the queue is - /// persisted to the changeset (SQLite backend), but cold-load restore is - /// blocked upstream, so a re-imported wallet re-syncs from scratch and the - /// sweep re-enqueues what it needs. Deliberately NOT captured by + /// persisted to the changeset (SQLite backend), but `load()` does not + /// restore it (nothing blocks that any more; see the storage crate's + /// `pending_contact_crypto` reader), so after a restart the sweep + /// re-enqueues what it needs. Deliberately NOT captured by /// `IdentityEntry::from_managed` — persistence rides the flat changeset /// delta, not a per-identity snapshot. /// See [`PendingContactCrypto`](crate::changeset::PendingContactCrypto). diff --git a/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs b/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs index 423f0cdb9fc..1be9af56b95 100644 --- a/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs +++ b/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs @@ -33,7 +33,7 @@ use rayon::prelude::*; use platform_wallet::wallet::shielded::keys::OrchardKeySet; const ENCRYPTED_NOTE_WIRE_LEN: usize = 216; -const SEED_BENCH: [u8; 32] = [0x73; 32]; // matches SEED_A in drive-abci's seeder +const SEED_BENCH: [u8; 32] = [0x73; 32]; // same seed as SEED_A in examples/shielded_sync.rs /// Generate `count` filler `ShieldedEncryptedNote`s with random bytes /// matching the on-chain wire layout. Deterministic given `rng_seed`.