From c926be8f89acbe2eb6895c19e15a340d77354064 Mon Sep 17 00:00:00 2001 From: Andy Golay Date: Tue, 5 May 2026 19:55:28 -0400 Subject: [PATCH 1/5] confidential-assets: multisig and other open --- confidential-assets/WALLET_INTEGRATION.md | 342 +++++++++++++++++++--- 1 file changed, 302 insertions(+), 40 deletions(-) diff --git a/confidential-assets/WALLET_INTEGRATION.md b/confidential-assets/WALLET_INTEGRATION.md index a124214ff..611205a58 100644 --- a/confidential-assets/WALLET_INTEGRATION.md +++ b/confidential-assets/WALLET_INTEGRATION.md @@ -218,6 +218,132 @@ Each per-asset `dk` (natively derived or imported) is stored, exported, and impo - The derivation policy is stable across releases. For software-backed accounts this includes the BIP-32 path layout `m/44'/637'/{accountIndex}'/1'/{tokenIndex}'` and the `tokenIndex` derivation `u32_le(SHA-256(tokenMetadataAddress)[0..4]) & 0x7FFFFFFF`. For hardware-backed accounts it includes the SDK's fixed `decryptionKeyDerivationMessage` prefix and the convention that the 32-byte token metadata address is appended as its lowercase hex representation, separated by a single ASCII colon. Any change to either yields a different `dk[token]` / `ek[token]` and breaks existing registrations; release notes must call out such changes. - The derivation message used with `fromSignature` is hard-coded in the wallet and is never supplied by a dApp. The dApp's only influence on derivation is the 32-byte FA metadata address it passes through `ca_*` methods; the wallet always inserts that address into the same fixed path layout (software backing) or appends it to the same fixed prefix message (hardware backing). See [Wallet adapter integration](#wallet-adapter-integration). +### Motion Wallet keystore schema + +This section specifies how Motion Wallet implements the storage requirements above. The invariants in [Storage and export](#storage-and-export) and [Security invariants](#security-invariants) are normative for any wallet; the concrete schema below is normative for Motion Wallet specifically. Other implementations may choose a different schema as long as they preserve the invariants. + +#### Wallet entries + +Motion Wallet represents a multisig account as a first-class wallet entry alongside Ed25519-backed entries: + +```ts +type WalletEntry = + | { kind: 'mnemonic'; id: string; /* … existing fields … */ } + | { kind: 'private-key'; id: string; /* … existing fields … */ } + | { + kind: 'multisig'; + id: string; // local entry id; unrelated to on-chain address + address: string; // multisig account address (32 bytes, 0x-prefixed lowercase) + threshold: number; // k in k-of-n + owners: string[]; // all on-chain owner addresses + ownedByWalletIds: string[]; // ids of local Ed25519 wallets that are also on-chain owners; + // can be empty (view-only) or contain multiple ids + // (e.g. two device-local wallets that are both owners) + }; +``` + +`ownedByWalletIds` is an array, not a single id, so a user with two device-local Ed25519 wallets that are both on-chain owners of the same multisig has one multisig entry, not two — and the imported `dk` set is not duplicated across owner blobs. When a multisig proposal needs an owner signature for approval, the popup picks among the listed local wallets; if `ownedByWalletIds` is empty, the entry is a read-only view (balances visible if `dk` entries are present, but no approvals possible from this device). + +Removing the last local Ed25519 wallet referenced by `ownedByWalletIds` does not delete the multisig entry — its imported `dk`s remain available for balance decryption — but the entry is marked view-only in the UI. + +#### Storage location + +There is one `dk` store per wallet entry, keyed by entry id: + +``` +mv_dk_store:${walletEntryId} +``` + +For mnemonic and private-key entries this stores any imported `dk`s for that account (rare but supported — e.g. a cross-device shared `dk` for a single-owner account). For multisig entries it stores the imported `dk[multisig, token]` set that gives the local user the ability to decrypt balances and propose transfers for that multisig. + +This per-entry keying contains corruption blast radius, lets a wallet-delete remove one storage key, and — critically for the multisig case — keeps each `dk` material in a single canonical location regardless of how many device-local Ed25519 wallets co-own the multisig. + +#### Persisted shape + +```ts +type DkStoreV1 = { + version: 1; + walletEntryId: string; + entries: Record; +}; + +// The lookup key inside a store is just the token's metadata address; the +// account address is implicit in the parent store (it's the wallet entry's address). +// 0x-prefixed, lowercase, 32-byte hex. +type DkEntryKey = string; + +type EncryptedDkEntry = { + source: 'imported'; // see "What is persisted" below + tokenMetaAddr: string; + ciphertext: string; // base64 AES-GCM ciphertext + tag + iv: string; // base64, 12 bytes, fresh per entry + label?: string; // token symbol/name at import; UI only + importedAt: number; // unix ms +}; +``` + +The outer `DkStoreV1` envelope is a plain JSON blob in `chrome.storage.local`. Per-entry AES-GCM provides the per-entry isolation; the envelope is not double-encrypted. The account address is not stored on individual entries because it is implied by the parent store's `walletEntryId` — see the AAD binding below for how this is enforced cryptographically. + +#### What is persisted + +Only **imported** entries are persisted at rest. Natively derived `dk[token]` values are recomputed on demand from root key material the wallet already holds (mnemonic for software, fresh device signature for hardware) and live only in an in-memory cache for the unlocked session. + +The cache shares its lifecycle with the Ed25519 signing-key cache: a derived `dk[token]` is computed lazily on first use during an unlocked session, retained for the remainder of that session, and zeroed on the same events that zero `cachedSigners` — wallet lock, idle auto-lock, and any future invalidation event that already clears the signing-key cache. Concretely the cache is a `Map` alongside `cachedSigners` in `services/wallet/account.ts`, governed by the existing `walletMutex`, and tied to the same lock/idle hooks rather than carrying its own eviction policy. + +Tying `dk` lifetime to the signing-key lifetime keeps the privacy blast radius equal to the fund-movement blast radius: any window in which an attacker with wallet-process access could read `dk[token]` is the same window in which they could already produce signatures with the Ed25519 key, so a stricter `dk`-only eviction policy would close no real gap and would make hardware-wallet UX unusable (a fresh device signature per CA operation). + +This split enforces the doc's two-form storage rule structurally: there is no on-disk state to enumerate for natively derived keys. + +#### Encryption key + +Each entry's `ciphertext` is sealed under Motion Wallet's existing runtime second-layer key from `core/storage/encrypted-storage.ts` — the PBKDF2-from-password key parameterised by `mv_storage_salt`. Reasons: + +- It is already lifecycle-bound to the unlocked session and zeroed on lock — the same lifecycle a `dk` ciphertext key requires. +- It avoids a third PBKDF2 run on unlock (already 600k iterations for the mnemonic vault). +- The "tentative read before key init" timing issue for `mv_active_wallet` (see project notes) does not apply to `dk` operations, which only run after unlock has fully completed. + +The mnemonic-vault key is *not* reused: that key conceptually unlocks the seed, and binding `dk` storage to it would propagate seed-vault format changes into `dk` storage. + +#### AAD binding + +Per-entry isolation is enforced cryptographically through AES-GCM additional authenticated data. The AAD is reconstructed at decrypt time from the loader's arguments, never read back from storage: + +``` +AAD = utf8("mv-dk-v1") || 0x00 || accountAddress || tokenMetaAddr +``` + +Where `accountAddress` is the parent wallet entry's address (the multisig address, or the Ed25519 account address) and `tokenMetaAddr` is the FA metadata address — each the raw 32 bytes (not hex). Binding the address into the AAD makes a ciphertext physically unmovable between stores: even though the entry doesn't carry the address as a plaintext field, AES-GCM tag verification fails on any cross-store substitution. The version tag in AAD also makes future schema changes (e.g. `mv-dk-v2`) unforgeable from v1 ciphertexts. + +#### Loader contract + +A single function in the wallet implements the doc's loader signature: + +```ts +loadDk(accountAddress: AccountAddress, tokenMetaAddr: AccountAddress): Promise +``` + +The wallet first resolves `accountAddress` to a `WalletEntry` (Ed25519 or multisig). Resolution order within that entry: + +1. In-memory cache for natively derived entries (mnemonic- or device-backed entries only), keyed by `${accountAddress}:${tokenMetaAddr}`. +2. For mnemonic-backed entries whose `accountAddress` matches a known mnemonic-derived account: derive via `fromDerivationPath("m/44'/637'/{accountIndex}'/1'/{tokenIndex}'", mnemonic)`, populate cache, return. +3. For hardware-backed entries whose `accountAddress` matches a known device account: derive via `fromSignature(device.sign(decryptionKeyDerivationMessage ‖ ":" ‖ hex(tokenMetaAddr)))`, populate cache, return. +4. Imported entry in `mv_dk_store:${walletEntry.id}.entries[tokenMetaAddr]`: AES-GCM-decrypt with AAD as defined above (using the parent entry's `accountAddress`); return. +5. Otherwise, throw — the loader does not fall back to "any `dk` for this account." + +Multisig entries skip steps 2 and 3: there is no mnemonic or device that can derive a multisig's `dk` (a multisig has no private key), so step 4 is the only path that can succeed for them. If no imported entry exists for `(multisigAddress, tokenMetaAddr)`, the loader throws — the user has not yet imported the shared `dk` for that asset, and the UI should prompt for import or surface the asset as view-only-without-key. + +Proof-construction routines accept exactly one `dk` and one `tokenMetaAddr` and reject mismatches before any cryptographic work begins, as required by [Security invariants](#security-invariants). + +#### Migration + +Schema v1 is additive: on unlock, if `mv_dk_store:${walletEntryId}` is absent, the wallet treats it as `{ version: 1, walletEntryId, entries: {} }` and lazy-creates on first import. There is no v0 to migrate. A `dkSchemaVersion` field will be introduced only when a v2 forces it. + +When a multisig wallet entry is created (whether by importing an existing on-chain multisig or by completing a creation flow), the wallet creates an empty `mv_dk_store:${entryId}` and prompts the user to import each registered token's `dk` separately, per [DK sharing among co-owners](#dk-sharing-among-co-owners). + +#### Whole-wallet export + +When the wallet exposes a "back up wallet" flow that already includes the encrypted mnemonic vault, it includes every `mv_dk_store:${walletEntryId}` blob alongside it — both for Ed25519 entries and for multisig entries. The blobs are already sealed under the runtime second-layer key and are useless without the password; excluding them would silently lose imported multisig material that mnemonic recovery cannot reproduce. The export-flow copy must state that the backup includes imported decryption keys. + --- ## Operation-by-operation design @@ -249,12 +375,13 @@ A public fungible-asset balance is moved into the confidential pending balance. |---|---| | User enters the amount to deposit | App | | App calls `ca_deposit({ token, amount })` | App → Wallet | -| Check whether the account is registered for `token` | Wallet | -| If not registered: present a confirmation for the `register` transaction; submit only after the user confirms | Wallet ↔ User | -| Present a confirmation for the `deposit` transaction (enumerated alongside `register` in the same flow if applicable); build and sign `deposit(sender, token, amount)` | Wallet ↔ User | -| After user confirmation, submit each transaction; return the transaction hash | Wallet → App | +| Check `has_confidential_asset_store(account, token)` and (if registered) `is_normalized(account, token)` | Wallet | +| Route to the appropriate single on-chain entrypoint:
  • **Not registered** → `register_and_deposit_and_rollover_pending_balance` (SDK: `registerAndDepositAndRollover`).
  • **Registered, normalized** → `deposit_and_rollover_pending_balance` (SDK: `depositAndRollover`).
  • **Registered, not normalized** → `deposit_and_normalize_and_rollover_pending_balance` (SDK: `depositNormalizeAndRollover`).
| Wallet | +| For the not-registered route, derive `dk[token]` and persist a new keystore entry as in [Register](#register); the on-chain entrypoint atomically registers, deposits, and rolls over | Wallet | +| Present a single confirmation for one transaction; the confirmation states which entrypoint will be invoked | Wallet ↔ User | +| After the user confirms, submit the transaction; return the transaction hash | Wallet → App | -Deposit itself does not require `dk[token]`. When the account is not yet registered for the token, the wallet presents the user with a single review-and-confirm step that enumerates both the `register` and `deposit` transactions; neither transaction is submitted before user confirmation. +Deposit itself does not require `dk[token]` for the deposit step, but the not-registered route does require `dk[token]` to derive `ek[token]` for the embedded register call. Every route results in **one** on-chain transaction; the wallet does not sequence a separate `register` followed by a separate `deposit`. The user sees one approval regardless of which route applies. ### Withdraw @@ -265,13 +392,12 @@ Confidential balance is moved back to a public fungible-asset balance. The withd | User enters the amount to withdraw | App | | App calls `ca_withdraw({ token, amount })` | App → Wallet | | Fetch the on-chain actual balance ciphertext; decrypt with `dk[token]` | Wallet | -| If `actual < amount` but `actual + pending ≥ amount`: enumerate the prerequisite `normalize` (where required) and `rollover` transactions for inclusion in the user-confirmation step | Wallet | +| If `actual < amount`: return `INSUFFICIENT_BALANCE`. The wallet does **not** auto-rollover pending funds to cover the shortfall; the user must explicitly accept incoming funds first via [`ca_rolloverPending`](#rollover-and-normalization) | Wallet → App | | Build the sigma proof and the range proof for the new balance | Wallet | -| Present a single confirmation enumerating every transaction in the sequence (any prerequisite `normalize` and `rollover`, followed by `withdraw`); each transaction lists its parameters and gas estimate | Wallet ↔ User | -| After the user confirms, sign and submit each transaction in order | Wallet | -| Return the transaction hash for the final `withdraw` (and intermediate hashes where the wallet API exposes them) | Wallet → App | +| Present a single confirmation for one `withdraw` transaction; the confirmation lists parameters and gas estimate | Wallet ↔ User | +| After the user confirms, sign and submit the transaction; return its hash | Wallet → App | -The `withdrawWithTotalBalance` flow constructs the full sequence above, including any prerequisite rollover or normalization, but does not submit it without explicit user confirmation. See [Rollover and normalization](#rollover-and-normalization). +`ca_withdraw` operates on the user's actual (spendable) balance only. Pending balance is a queue of unaccepted incoming transfers, not part of total balance, and no SDK or wallet code path silently accepts pending funds on the user's behalf in order to make a spend succeed. This preserves the property in [Guiding principles, item 4](#guiding-principles): rollover requires explicit user authorization. The current SDK helpers `withdrawWithTotalBalance` / `transferWithTotalBalance` violate this property and are required to change — see [SDK changes required by this design](#sdk-changes-required-by-this-design). ### Confidential transfer @@ -282,17 +408,16 @@ Encrypted value moves from sender to recipient. The transfer amount is hidden on | User enters recipient, amount, and optional auditor addresses | App | | App calls `ca_transfer({ token, recipient, amount, auditorAddresses? })` | App → Wallet | | Fetch the sender's actual balance ciphertext; decrypt with `dk[token]` | Wallet | -| If `actual < amount`: enumerate the prerequisite `normalize` (where required) and `rollover` transactions for inclusion in the user-confirmation step | Wallet | +| If `actual < amount`: return `INSUFFICIENT_BALANCE`. The wallet does **not** auto-rollover pending funds to cover the shortfall; the user must explicitly accept incoming funds first via [`ca_rolloverPending`](#rollover-and-normalization) | Wallet → App | | Fetch the recipient's `ek[token]` from chain | Wallet | -| Fetch the global auditor `ek` from the chain-wide view (mandatory inclusion) | Wallet | -| Fetch the per-asset auditor `ek` for the token, if configured (`get_auditor`) | Wallet | -| Combine the global auditor, the per-asset auditor (when configured), and any per-transfer auditor keys supplied in the request | Wallet | +| Fetch the chain-level auditor `ek` via `get_chain_auditor()` (mandatory inclusion) | Wallet | +| Fetch the per-asset auditor `ek` for the token, if configured, via `get_asset_auditor(token)` | Wallet | +| Combine the chain-level auditor, the per-asset auditor (when configured), and any per-transfer auditor keys supplied in the request | Wallet | | Build the `ConfidentialTransfer` payload with proofs (sigma plus two range proofs) | Wallet | -| Present a single confirmation enumerating every transaction in the sequence (any prerequisite `normalize` and `rollover`, followed by `confidential_transfer`); the confirmation lists recipient, amount, included auditors, and per-transaction gas estimates | Wallet ↔ User | -| After the user confirms, sign and submit each transaction in order | Wallet | -| Return the transaction hash for the final `confidential_transfer` | Wallet → App | +| Present a single confirmation for one `confidential_transfer` transaction; the confirmation lists recipient, amount, included auditors, and gas estimate | Wallet ↔ User | +| After the user confirms, sign and submit the transaction; return its hash | Wallet → App | -The wallet performs the cryptographic and balance-state work. The dApp supplies only the recipient, amount, and any optional auditors. The user authorizes the resulting transaction sequence in a single review step before any transaction is submitted. +`ca_transfer` operates on the user's actual (spendable) balance only, on the same principle as `ca_withdraw` above. The wallet performs the cryptographic and balance-state work; the dApp supplies only the recipient, amount, and any optional auditors. ### Rollover and normalization @@ -304,7 +429,7 @@ Rollover and normalization are on-chain transactions. They incur gas and alter t - The wallet displays the pending balance as a distinct, user-visible state when `pending > 0` for any registered `(account, token)` pair, with an explicit action labeled "Accept incoming funds" (or an equivalent unambiguous phrasing). - Activating that action prompts the user to review and confirm a rollover transaction. The wallet computes whether `normalize` is required first, and if so chains it: the user is presented with a single confirmation that authorizes the full sequence (`normalize` followed by `rollover`, where applicable), with both transactions clearly enumerated. -- The same explicit-authorization requirement applies to `transferWithTotalBalance` and `withdrawWithTotalBalance` flows: when the actual balance is insufficient and rollover (with optional normalization) must precede the spend, the wallet presents the user with a single confirmation that enumerates and authorizes every transaction in the sequence. +- The wallet does not bundle rollover into spend flows. `ca_withdraw` and `ca_transfer` operate on the user's actual (spendable) balance only and return `INSUFFICIENT_BALANCE` when `amount > actual`, regardless of how much pending balance the user has. Accepting pending funds is a separate, explicit user action via `ca_rolloverPending` (or its in-wallet equivalent "Accept incoming funds"). This avoids silently combining "spend funds" with "accept incoming transfers" — they are conceptually distinct decisions and authorized independently. - The wallet does not initiate rollover, normalization, or any other on-chain transaction in the background, on a timer, on balance fetch, on receipt of an inbound transfer, or in response to any dApp signal. Each on-chain transaction is preceded by user confirmation in the wallet UI. #### Behavior by scenario @@ -314,15 +439,14 @@ Rollover and normalization are on-chain transactions. They incur gas and alter t | The wallet observes `pending > 0` for a registered `(account, token)` pair | The wallet surfaces a "pending — accept incoming funds" indicator on the balance row. No transaction is submitted until the user activates it. | | User activates the rollover action with normalization not required | The wallet presents a single confirmation for one `rollover` transaction. The transaction is submitted only after the user confirms. | | User activates the rollover action with normalization required | The wallet presents a single confirmation that enumerates `normalize` and `rollover`. After the user confirms, the wallet submits `normalize`, awaits confirmation, then submits `rollover`. The user authorizes the sequence once. | -| User initiates a confidential transfer with `actual < amount` and `actual + pending ≥ amount` | The wallet presents a single confirmation enumerating the required `normalize` (if applicable), `rollover`, and `confidential_transfer` transactions. The wallet submits the sequence only after the user confirms. | -| User initiates a withdraw with `actual < amount` and `actual + pending ≥ amount` | As above, with `withdraw` in place of `confidential_transfer`. | +| User initiates a confidential transfer or withdraw with `actual < amount` | The wallet returns `INSUFFICIENT_BALANCE`. If `pending > 0` could cover the shortfall, the dApp surface (and the wallet's own UI on its built-in send/withdraw screens) prompts the user to first activate "Accept incoming funds," after which they can retry the spend. The wallet does not bundle rollover with the spend. | | Receive-only account (the user only receives confidential transfers) | The pending balance accumulates and remains visible in the UI. The wallet does not roll it over until the user activates the explicit action. | An account that only receives transfers and does not send accumulates funds in the pending balance, which are not spendable until the user authorizes a rollover. The wallet's role is to make this state evident and to make the action available; the wallet does not perform rollover on the user's behalf without authorization. #### dApp interaction -The dApp does not need to model normalization. The wallet presents a single combined balance (actual plus pending, where pending is clearly labeled as awaiting acceptance). The dApp may invoke `ca_rolloverPending` to express the user's intent to roll over; the wallet still routes that invocation through an explicit user-confirmation step before submitting any transaction. While `normalize` and `rollover` transactions are confirming on chain, the wallet may display a "processing" indicator; that indicator does not represent any wallet-initiated activity beyond what the user authorized. +The dApp does not need to model normalization. The wallet exposes `actual` (spendable) and `pending` (awaiting acceptance) as separate fields on `ca_getBalances`; the dApp displays them as the user's spendable balance and a clearly labeled "incoming, pending acceptance" line, never summed into a single "total balance" number. The dApp may invoke `ca_rolloverPending` to express the user's intent to accept pending funds; the wallet routes that invocation through an explicit user-confirmation step before submitting any transaction, and chains `normalize` first if required (silent within the single approval, since `normalize` is a protocol implementation detail of "accept incoming funds"). While `normalize` and `rollover` transactions are confirming on chain, the wallet may display a "processing" indicator; that indicator does not represent any wallet-initiated activity beyond what the user authorized. ### Key rotation (not wallet-supported) @@ -362,7 +486,7 @@ Confidential balances are shown by default as a separate line item beneath the r Rollover is a user-visible action. When `pending > 0` for a registered `(account, token)` pair, the wallet displays the pending portion as a distinct state alongside the spendable balance, with an explicit "Accept incoming funds" action. No `rollover` transaction is submitted without the user activating that action and confirming the resulting transaction in the wallet UI. -Normalization is an internal protocol detail. When a `normalize` transaction is required as a prerequisite for rollover (or for a spend that requires rollover), the wallet enumerates it within the same user-confirmation step that authorizes the rollover or the spend. The user authorizes the full sequence in a single review; they do not need to understand normalization as an independent concept. While submitted transactions are confirming on chain, the wallet may display a subtle "processing" indicator; the indicator does not represent any wallet activity beyond the transactions the user has already authorized. +Normalization is an internal protocol detail. When a `normalize` transaction is required as a prerequisite for rollover, the wallet chains it within the same user-confirmation step that authorizes "Accept incoming funds" — the user authorizes one logical action and does not need to understand normalization as an independent concept. Spends (`ca_withdraw`, `ca_transfer`) never trigger normalization or rollover, by design: they operate on the user's actual balance only, and the user authorizes "accept incoming funds" separately when they want to make pending funds spendable. While submitted transactions are confirming on chain, the wallet may display a subtle "processing" indicator; the indicator does not represent any wallet activity beyond the transactions the user has already authorized. ### Spam-token handling @@ -392,6 +516,8 @@ The device's chain application must expose deterministic message signing over ar A multisig account is a resource account: it holds funds but has no private key, so a multisig account cannot run `fromDerivationPath` itself. Proofs for multisig confidential-asset operations must bind to the multisig account's address — the SDK's Fiat–Shamir transcript includes `senderAddress` (see `src/crypto/fiatShamir.ts`), and proofs built against any other address abort on chain. +Motion Wallet represents a multisig as a first-class wallet entry (`WalletEntry.kind = 'multisig'`, see [Motion Wallet keystore schema / Wallet entries](#wallet-entries)). The popup's wallet switcher lists multisig entries alongside Ed25519 entries, the Home page shows the multisig's confidential balances when the corresponding `dk` entries have been imported, and pending multisig proposals surface in the wallet's approval flow the same way dApp-originated approvals do. The dApp (`gmove-multisig`) remains the place where users *manage* a multisig (create it, change owners, etc.); the wallet is where they *use* it. Surfacing the multisig as a wallet entry — rather than only as a dApp concept — is what lets a user open the wallet popup and immediately see the assets and pending actions that affect them, without first navigating to a specific dApp. + ### Data ownership For a k-of-n multisig confidential-asset account, each co-owner's wallet holds the same kinds of material it would for a single-owner account. The thing *shared* across owners is the **per-asset `dk` set for the multisig account** — one shared `dk[multisig, token]` for each token the multisig has registered. Nothing else crosses owner boundaries, and sharing is opt-in per asset: co-owners can run a multisig where every owner holds `dk[multisig, USDC]` but only a subset hold `dk[multisig, MOVE]`, depending on which owners are expected to propose which kinds of transfers. @@ -510,8 +636,8 @@ The shared-`dk` design (per asset) is the chosen construction for this integrati The on-chain protocol supports auditors: parties that receive encrypted copies of transfer amounts under their own encryption keys. A confidential transfer carries one encrypted copy per included auditor. Three distinct sources contribute auditor encryption keys to a transfer: -1. **Global (chain-level) auditor.** A single encryption key configured at the chain level applies to every confidential transfer of every fungible asset on the chain, with no exceptions. The wallet must include this auditor's encryption key in every confidential transfer it constructs. The key is read from a chain-wide view (denoted `get_global_auditor` in this document; the exact Move name is fixed by the protocol). It is installed or updated only by the chain's governance authority. -2. **Per-asset auditor.** An optional encryption key is stored on chain per fungible asset and applies to every confidential transfer of that asset. It is installed or updated only by the asset issuer — the root owner of the asset's FA metadata object — via `set_auditor` in Move. The SDK reads it via `get_auditor(token)`. When set, the wallet must include this auditor in transfers of the affected asset. +1. **Global (chain-level) auditor.** A single encryption key configured at the chain level applies to every confidential transfer of every fungible asset on the chain, with no exceptions. The wallet must include this auditor's encryption key in every confidential transfer it constructs. The key is read from the on-chain `#[view] get_chain_auditor()` (see `confidential_asset.move`), which returns `Option`. The accompanying `get_chain_auditor_epoch()` view returns the current epoch. It is installed or updated only by the chain's governance authority via `set_chain_auditor`. +2. **Per-asset auditor.** An optional encryption key is stored on chain per fungible asset and applies to every confidential transfer of that asset. It is installed or updated only by the asset issuer — the root owner of the asset's FA metadata object — via `set_asset_auditor` in Move. The SDK reads it via `get_asset_auditor(token)` (returns `Option`); the matching epoch is `get_asset_auditor_epoch(token)`. When set, the wallet must include this auditor in transfers of the affected asset. 3. **Per-transfer (voluntary) auditors.** The sender may include additional auditor encryption keys at transfer time. These are not stored on chain; they appear only in the transaction data and the emitted `Transferred` event. The three sources compose: a single confidential transfer always carries an encrypted copy for the global auditor, also carries one for the per-asset auditor when one is configured, and may additionally carry one for each per-transfer auditor supplied with the request. @@ -539,12 +665,15 @@ The three sources compose: a single confidential transfer always carries an encr recipient: string; // recipient account address amount: string; // transfer amount (decimal string or bigint-compatible) auditorAddresses?: string[]; // optional per-transfer auditor encryption keys (hex) - senderAuditorHint?: string; // optional opaque metadata (max 256 bytes, bound into proof) + senderAuditorHint?: string; // optional opaque metadata, hex-encoded; max length set by the on-chain + // `max_sender_auditor_hint_bytes()` view (decoded byte length, not hex length) } ``` The global auditor and the per-asset auditor are not parameters of this request. The wallet always reads them from chain and includes them in the constructed transfer; the dApp neither supplies nor overrides them. +`senderAuditorHint`, when supplied, is bound into the transfer sigma Fiat–Shamir transcript by appending its **BCS `vector`** encoding (ULEB128 length prefix followed by the bytes), exactly as the on-chain verifier does — see `bcs::to_bytes(sender_auditor_hint)` in `confidential_proof.move` and `bcsSerializeMoveVectorU8` in `crypto/confidentialTransfer.ts`. The wallet must use the same bytes that will be passed as the `sender_auditor_hint` entry-function argument; any divergence causes the on-chain verifier to reject the proof. The wallet enforces the on-chain length cap before proof construction by reading `max_sender_auditor_hint_bytes()`. + ### Auditor epoch If the on-chain module exposes an `auditor_epoch` field on the chain-level and per-asset auditor records, the wallet reads the epoch alongside the corresponding key, treats a mismatch with any cached value as a stale read, and refreshes the key before constructing a transfer. Defining the on-chain field is out of scope for this integration. @@ -566,7 +695,7 @@ If the on-chain module exposes an `auditor_epoch` field on the chain-level and p | Scenario | Impact | Mitigation | |---|---|---| -| Rollover not performed | Pending funds are not spendable. The user observes a pending balance but cannot transfer or withdraw it until rollover is performed. | The wallet displays the pending balance with an explicit "Accept incoming funds" action whenever `pending > 0` (see [Rollover and normalization](#rollover-and-normalization)). When the user initiates a spend with `actual < amount`, the wallet enumerates the prerequisite `rollover` (and `normalize` where required) within the same confirmation step, so the user authorizes the full sequence in a single review. | +| Rollover not performed | Pending funds are not spendable. The user observes a pending balance but cannot transfer or withdraw it until rollover is performed. | The wallet displays the pending balance with an explicit "Accept incoming funds" action whenever `pending > 0` (see [Rollover and normalization](#rollover-and-normalization)). When the user initiates a spend with `actual < amount`, the wallet returns `INSUFFICIENT_BALANCE` and the UI prompts the user to accept incoming funds first if `pending` could cover the shortfall. Spend and rollover are authorized as separate user actions — the wallet never bundles them. | | Normalization skipped before rollover | Rollover aborts with `ENORMALIZATION_REQUIRED`. Gas spent, no state change. | The wallet checks `is_normalized` before rollover and chains `normalize` first when required. | | Wrong recipient address | Confidential transfer is irreversible. The amount is hidden on chain, but it is sent to the wrong party. | Standard address-validation UX. No confidential-asset-specific mitigation beyond what applies to non-confidential transfers. | | Wrong token metadata address | Transaction fails, or the wrong asset is moved. | The wallet resolves token identifiers to FA metadata addresses and displays the asset name for user confirmation. | @@ -597,7 +726,7 @@ The read and write method tables in this section are the definitive list of `ca_ #### Mapping to the chain and SDK -Implementations of these entry points call the confidential-asset module's Move `view` and `entry` functions as required. The chain-level global auditor is read via the chain-wide global-auditor view; the per-asset auditor is read via the on-chain `get_auditor` view. The package `@moveindustries/confidential-assets` provides the corresponding APIs for trusted (non-browser) code. +Implementations of these entry points call the confidential-asset module's Move `view` and `entry` functions as required. The chain-level auditor is read via `get_chain_auditor()` (with `get_chain_auditor_epoch()` for staleness checks); the per-asset auditor is read via `get_asset_auditor(token)` (with `get_asset_auditor_epoch(token)`). The package `@moveindustries/confidential-assets` provides the corresponding APIs for trusted (non-browser) code. ### Read methods @@ -606,18 +735,18 @@ Implementations of these entry points call the confidential-asset module's Move | `ca_getBalances` | `{ tokens: string[] }` | `{ balances: { token, registered, available, pending }[] }` | Wallet decrypts; dApp sees plaintext numbers only | | `ca_isRegistered` | `{ token }` | `{ registered: boolean }` | No `dk` needed | | `ca_getEncryptionKey` | `{ token }` | `{ encryptionKey: string }` | Public key — safe to return | -| `ca_getGlobalAuditor` | `{}` | `{ auditorEncryptionKey: string }` | Chain-level (global) auditor; included in every confidential transfer. Corresponds to the on-chain global auditor view | -| `ca_getAuditor` | `{ token }` | `{ auditorEncryptionKey?: string }` | Optional per-asset auditor; corresponds to on-chain `get_auditor` / SDK `getAssetAuditorEncryptionKey`. Omit or empty if no per-asset auditor is configured for the token | +| `ca_getGlobalAuditor` | `{}` | `{ auditorEncryptionKey?: string, epoch: number }` | Chain-level (global) auditor; included in every confidential transfer. Reads `get_chain_auditor()` and `get_chain_auditor_epoch()`. `auditorEncryptionKey` is omitted when no chain auditor has been configured (in which case the wallet refuses to construct a transfer per [Wallet responsibilities](#wallet-responsibilities)). | +| `ca_getAuditor` | `{ token }` | `{ auditorEncryptionKey?: string, epoch: number }` | Optional per-asset auditor; reads `get_asset_auditor(token)` and `get_asset_auditor_epoch(token)`. `auditorEncryptionKey` is omitted when no per-asset auditor is configured for the token. | ### Write methods | Method | Request | Response | Notes | |---|---|---|---| | `ca_register` | `{ token, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Wallet derives `dk[token]`, builds the proof, and presents the transaction for user confirmation. Submits after confirmation, or returns BCS bytes if `mode: "buildOnly"`. | -| `ca_deposit` | `{ token, amount, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | If the account is not registered for `token`, the wallet enumerates `register` and `deposit` in a single user-confirmation step and submits both after confirmation. | -| `ca_withdraw` | `{ token, amount, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | If `actual < amount` and rollover is required, the wallet enumerates `normalize` (where required), `rollover`, and `withdraw` in a single user-confirmation step and submits the sequence after confirmation. | -| `ca_transfer` | `{ token, recipient, amount, auditorAddresses?, senderAuditorHint?, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | If `actual < amount` and rollover is required, the wallet enumerates `normalize` (where required), `rollover`, and `confidential_transfer` in a single user-confirmation step and submits the sequence after confirmation. | -| `ca_rolloverPending` | `{ token, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Explicit rollover. The wallet enumerates `normalize` (where required) and `rollover` in a single user-confirmation step and submits after confirmation. | +| `ca_deposit` | `{ token, amount, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | The wallet routes to the appropriate single on-chain entrypoint based on registration and normalization state — `register_and_deposit_and_rollover_pending_balance`, `deposit_and_rollover_pending_balance`, or `deposit_and_normalize_and_rollover_pending_balance`. One transaction in every case. See [Deposit](#deposit). | +| `ca_withdraw` | `{ token, amount, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Operates on actual balance only. Always one on-chain transaction. Returns `INSUFFICIENT_BALANCE` when `amount > actual`, regardless of pending; the dApp prompts the user to accept incoming funds first if needed. | +| `ca_transfer` | `{ token, recipient, amount, auditorAddresses?, senderAuditorHint?, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Operates on actual balance only. Always one on-chain transaction. Same `INSUFFICIENT_BALANCE` behavior as `ca_withdraw`. | +| `ca_rolloverPending` | `{ token, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Accept incoming funds. The wallet chains `normalize` (where required) and `rollover` in a single user-confirmation step and submits after confirmation — at most two on-chain transactions, silently chained because `normalize` is a protocol detail of "accept incoming funds." Returns the final `rollover` transaction hash. | **`sender`** defaults to the wallet's own account address. Pass an explicit value (e.g. a multisig account address) when the executing signer is not the wallet account; the value is bound into the proof's Fiat–Shamir transcript and must match the executor at chain-verification time. A non-default `sender` requires `mode: "buildOnly"` — the wallet cannot sign a transaction on behalf of an account whose key it does not hold. @@ -629,6 +758,80 @@ Implementations of these entry points call the confidential-asset module's Move - Decrypted balances via `ca_getBalances`. - The dApp must not receive `dk`, proof material, raw ciphertext, or any data from which the decryption key could be derived. +### Errors + +Failed `ca_*` calls return a structured error with a finite, versioned enum of codes. The goal is to give dApps the information they can act on — surface a user-facing message, retry, fall back, prompt re-unlock — without leaking wallet internals or turning the wallet into an oracle that a malicious dApp can probe through differential error analysis. + +#### Error shape + +```ts +type CaError = { + code: CaErrorCode; + message: string; // user-facing, no internals + details?: CaErrorDetails; // only fields below; never balance state, ciphertext, vault params, + // KDF details, proof intermediates, stack traces, or storage paths +}; + +type CaErrorCode = + // User / session + | 'USER_REJECTED' // user rejected the approval popup or closed it + | 'WALLET_LOCKED' // wallet is locked; dApp should prompt unlock + | 'NOT_CONNECTED' // dApp origin is not connected to this wallet + // Capability + | 'UNSUPPORTED_METHOD' // wallet doesn't implement this ca_* method + | 'UNSUPPORTED_MODE' // e.g. mode: "buildOnly" not supported for this method or sender + | 'CA_FEATURE_UNAVAILABLE' // chain-level auditor unset; wallet refuses to construct transfer + // Request validity + | 'INVALID_REQUEST' // malformed token address, missing required field, value out of range + | 'TOKEN_NOT_REGISTERED' // ca_transfer / ca_withdraw before ca_register / ca_deposit + | 'TOKEN_FROZEN' // confidential store is frozen for this (account, token) + | 'TOKEN_DISABLED' // token is not on the allow list + // Economics + | 'INSUFFICIENT_BALANCE' // amount exceeds actual (pending is not counted; rollover is a separate user action) + | 'PENDING_COUNTER_LIMIT' // protocol pending-counter overflow; rollover required + // Execution + | 'NETWORK_ERROR' // RPC unreachable, view fetch failed, or chain timed out + | 'CHAIN_REJECTED' // transaction submitted but chain returned an abort + | 'PROOF_FAILED' // local proof construction or pre-flight verification failed + | 'INTERNAL_ERROR'; // catch-all; details omitted + +type CaErrorDetails = { + // Present on CHAIN_REJECTED only. Both fields are public chain state. + abortCode?: number; + moduleAbort?: string; // e.g. "ENORMALIZATION_REQUIRED" + txHash?: string; // hash of the rejected transaction, when available + + // Present on UNSUPPORTED_METHOD / UNSUPPORTED_MODE only. + requiredCapability?: string; // e.g. "ca_transfer", "buildOnly" +}; +``` + +#### What is and is not exposed + +- `details.abortCode` and `details.moduleAbort` are surfaced because Move abort codes are public chain state and are directly actionable for the dApp ("this failed because the store was frozen → show 'asset is currently frozen'"). +- `details.txHash` is surfaced on `CHAIN_REJECTED` because the dApp may need to look the transaction up. +- `details.requiredCapability` is surfaced on capability errors so the dApp can either degrade gracefully or prompt the user to update their wallet. +- The wallet does **not** surface: internal storage paths, vault format or KDF parameters, balance ciphertexts or decrypted values outside the legitimate `ca_getBalances` flow, intermediate values from proof construction, hardware-device responses beyond "device error" / "user rejected on device", stack traces, or any field that varies with private state. +- `INTERNAL_ERROR` is intentionally opaque. Internal failure modes are an implementation detail and must not be pinned in the wire contract; full context lives in the wallet's own telemetry, not in dApp responses. The wallet logs the underlying cause locally with a correlation id and includes only the correlation id (no leaked internals) in `message` if needed for support. + +#### Stability + +The `CaErrorCode` enum is stable across releases. Adding new codes is a breaking change to dApps that switch on the enum exhaustively, so additions go through the same versioning treatment as adding methods to the `ca_*` surface — see [Wallet-standard feature advertisement](#wallet-standard-feature-advertisement). Renaming or removing codes is not permitted within a single feature version. + +### Concurrency + +`ca_*` write methods require explicit user authorization in the wallet UI (a confirmation screen for in-wallet flows, an approval window opened by the service worker for dApp-initiated flows). The user is one person interacting with one confirmation surface at a time, so there is no UX state in which two CA writes can be authorized in parallel. The wallet does not need a per-account or per-(account, token) mutex to serialize CA operations: the user-approval requirement already does so. + +Concretely: + +- **In-wallet flows.** The user navigates linearly through screens (Send → review → submit). Two confirmation screens are never on screen at once. +- **dApp-initiated flows.** The transaction-manager limits in-flight approval windows per origin (per the existing `canOpenPopup` rate limit), and `chrome.windows.onRemoved` resolves any closed approval as "rejected by user." Dapps cannot stack pending approvals. +- **Reads** (`ca_getBalances`, `ca_isRegistered`, etc.) require no user approval and may run concurrently with anything; they may return slightly stale data, which the next write will refetch anyway. + +The one window of genuine overlap is between "user authorized transaction A" and "transaction A confirmed on chain." During that window a dApp may call another `ca_*` method, opening a new approval. The wallet builds the new operation's proof by **fetching fresh on-chain state at proof-build time** — the SDK does not cache state for proof construction. If A has confirmed by then, the proof binds to the post-A state and submits cleanly. If A has not yet confirmed, the proof binds to the pre-A state; if A subsequently lands first, the chain rejects the second transaction with an abort (proof no longer matches the on-chain ciphertext) and the wallet returns `CHAIN_REJECTED` to the dApp. The dApp retries; the next attempt sees fresh state and succeeds. This is self-correcting and requires no in-wallet locking. + +dApps do not need to model concurrency. They issue `ca_*` calls when the user requests an action; if a stale-state race causes a `CHAIN_REJECTED`, retry once with the same parameters. + ### Wallet adapter integration The wallet adapter (`@moveindustries/wallet-adapter-react`) provides `useWallet()` with generic methods (`signAndSubmitTransaction`, etc.). For confidential assets, the adapter exposes thin wrapper functions that: @@ -662,6 +865,69 @@ These are RPC calls to the wallet, not invocations of the `ConfidentialAsset` SD The adapter must not offer a generic "sign arbitrary bytes for confidential assets" hook. When the wallet derives `dk` via `fromSignature` (the supported path for hardware-backed accounts; see [Decryption key lifecycle](#decryption-key-lifecycle)), the signed payload is fixed by the wallet and is not supplied by the dApp; otherwise phishing or wrong-`ek` registration is possible. Software-backed accounts use `fromDerivationPath` from the mnemonic and do not sign anything for derivation. +### Wallet-standard feature advertisement + +Confidential-asset support is advertised through the wallet-standard `features` map under **two keys pointing at the same feature object**, matching the dual-publish convention already used by `@moveindustries/wallet-adapter-react` for every other feature: + +```ts +features: { + // ... + 'aptos:confidentialAssets': confidentialAssetsFeature, + 'movement:confidentialAssets': confidentialAssetsFeature, + // ... +} +``` + +The two keys share one object reference; nothing is duplicated except the entry in the map. A dApp using the Movement adapter (which probes both prefixes) finds the feature under either name; a dApp built against the Aptos wallet-standard tooling finds it under `aptos:confidentialAssets`. + +No version suffix is used in v1, matching the convention for every other feature in the Movement adapter (`aptos:signTransaction`, `movement:signTransaction`, etc., none of which carry suffixes). If a future change to the `ca_*` surface is incompatible with v1 callers, it will be advertised under whatever versioning convention the rest of the wallet-standard has adopted by then (e.g. a `:v2` suffix), with both versions co-published during a deprecation window. + +#### Future direction + +The dual-publish convention exists because Movement inherited its wallet-standard feature names (`aptos:*`) from AIP-62 and adopted `movement:*` aliases on top. This is a transitional state. The Movement ecosystem should consider deprecating the `aptos:*` aliases in a future, ecosystem-coordinated release — wallet, adapter, and major dApps moving in lockstep — at which point Motion Wallet would drop `aptos:confidentialAssets` (and the inherited AIP-62 `aptos:*` keys) in favor of `movement:*` only. That migration is out of scope for the confidential-assets integration; the CA work just adopts the existing dual-publish convention rather than getting ahead of it. + +### SDK changes required by this design + +The `@moveindustries/confidential-assets` package supplies the proof construction and transaction builders that the wallet calls in its background service worker. The current package exposes most of what the wallet needs, but three changes are required to make the design above implementable and to remove a footgun that contradicts the design's authorization model. + +#### 1. `withdrawWithTotalBalance` / `transferWithTotalBalance` must not auto-rollover + +`ConfidentialAsset.withdrawWithTotalBalance` (`api/confidentialAsset.ts:265`) and `ConfidentialAsset.transferWithTotalBalance` (`api/confidentialAsset.ts:417`) currently call `checkSufficientBalanceAndRolloverIfNeeded` (`api/confidentialAsset.ts:677`), which fetches the user's balance, sees that `actual` alone is insufficient, checks whether `actual + pending ≥ amount`, and if so submits a `rollover_pending_balance` transaction automatically before submitting the spend. They return `Promise` — an array — because they can result in 1 or 2 on-chain transactions. + +This behavior contradicts [Guiding principles, item 4](#guiding-principles): rollover is an explicit user-authorized action, not a side effect of "I want to spend more than my actual balance." The principle applies to any use of confidential assets, not only wallet-mediated calls — a CLI tool, server-side automation, or any other caller that silently accepts incoming funds in order to make a spend succeed runs the same risk of executing transfers and incurring gas the funds-owner did not consent to. + +**Required change:** remove the auto-rollover branch from both helpers. Either: + +- **Option A (recommended):** Delete the helpers entirely. They primarily existed as a UX nicety; without auto-rollover their only remaining behavior would be a pre-flight balance check, which any caller can do directly via `getBalance` followed by `withdraw` / `transfer`. Deletion removes a confusing API surface (the names "withTotalBalance" suggest pending is part of total balance, which it isn't) and prevents future re-introduction of the same footgun. +- **Option B:** Keep the helpers, but make them throw `Insufficient balance` whenever `actual < amount`, regardless of pending. The pending balance plays no role in the helper. The names should be renamed (e.g. `withdrawWithBalanceCheck`) to remove the misleading "TotalBalance" framing. + +Either option restores the invariant that no SDK code path silently accepts incoming funds. + +#### 2. Build-only API for proof construction + +For multisig confidential-asset operations, the wallet must construct proofs and return raw `EntryFunction` BCS bytes rather than submitting a transaction. The dApp wraps those bytes in `MultiSigTransactionPayload` and proposes the transaction through `multisig_account::create_transaction`. See [Multisig accounts](#multisig-accounts) and the `mode: "buildOnly"` parameter on every `ca_*` write method. + +Today the high-level `ConfidentialAsset` class always submits via a signer. The lower-level `ConfidentialAssetTransactionBuilder` accepts an arbitrary `sender` and constructs the necessary proofs, but does not expose a serialized `EntryFunction` directly. Each wallet implementer would need to bridge that gap themselves, which invites byte-level divergence. + +**Required change:** add a build-only entry point — either as new methods on `ConfidentialAsset` (`buildRegister`, `buildDeposit`, `buildWithdraw`, `buildConfidentialTransfer`, `buildRolloverPending`, `buildNormalize`) or as a sibling class (`ConfidentialAssetBuilder`). Each method takes the same arguments as its submitting counterpart but uses an explicit `sender: AccountAddressInput` (no signer) and a `decryptionKey` for proof construction, and returns `Uint8Array` of BCS-encoded `EntryFunction` bytes. No fee payer, no signer, no submission. + +#### 3. Canonical derivation helpers + +The doc fixes two derivation policies: + +- **Software backings:** `tokenIndex = u32_le(SHA-256(tokenMetadataAddress)[0..4]) & 0x7FFFFFFF`, then `dk[token] = TwistedEd25519PrivateKey.fromDerivationPath("m/44'/637'/{accountIndex}'/1'/{tokenIndex}'", mnemonic)`. +- **Hardware backings:** `message[token] = decryptionKeyDerivationMessage ‖ ":" ‖ hex(tokenMetadataAddress)`, then `dk[token] = TwistedEd25519PrivateKey.fromSignature(device.sign(message))`. + +These layouts are part of the wallet ↔ chain compatibility contract: a different `tokenIndex` formula or a different signed-message layout produces a different `dk[token]` / `ek[token]` and orphans every existing registration. Re-implementing the byte assembly in each wallet is therefore a divergence risk waiting to happen. + +**Required change:** export named helpers from `@moveindustries/confidential-assets`: + +- `tokenIndexFromMetadataAddress(tokenMetaAddr: AccountAddressInput): number` — returns the 31-bit hardened-index suffix. +- `softwareDecryptionKeyDerivationPath(accountIndex: number, tokenMetaAddr: AccountAddressInput): string` — returns `"m/44'/637'/{accountIndex}'/1'/{tokenIndex}'"` ready to feed into `fromDerivationPath`. +- `hardwareDecryptionKeyDerivationMessage(tokenMetaAddr: AccountAddressInput): Uint8Array` — returns the bytes to be signed by the hardware device. + +Wallet implementations call these instead of re-deriving the byte layouts. Tests in this package assert the helpers' outputs against fixed test vectors so a regression is caught upstream rather than after registrations have been written on chain. + ### Token addressing All `ca_*` methods that take a `token` parameter must use the fungible-asset metadata object address (32 bytes). Legacy coin type strings (the `0x1::module::CoinType` form) must not be used. @@ -689,10 +955,6 @@ These should be resolved before implementation: | # | Question | Options | Notes | |---|---|---|---| -| 1 | **Should `ca_deposit` auto-register?** | (a) Yes — seamless. (b) No — require explicit `ca_register` first. | Auto-register is better UX; two transactions (register + deposit) can be sequenced by the wallet. | -| 2 | **Per-transfer auditor address UX** | (a) Per-transfer entry only. (b) Wallet-managed address book. (c) dApp provides a list, wallet confirms. | The global and per-asset auditors are not in scope here; this question concerns only the optional per-transfer (voluntary) auditors. For v1, (a) or (c) is likely sufficient. | -| 4 | **Error reporting granularity** | What does the dApp see when rollover fails, normalization fails, proof generation fails, or the chain rejects? | Wallet should map internal failures to meaningful dApp-facing errors without leaking protocol internals. | -| 5 | **Multi-transaction flows** | When withdraw requires rollover + normalize + withdraw (3 txs), does the wallet handle all three silently, or notify the dApp of intermediate steps? | Recommend silent chaining with a single response for the final operation. | -| 6 | **Concurrent operations** | Can a dApp fire `ca_transfer` while a `ca_rolloverPending` is in flight? | Wallet should serialize confidential-asset operations per account/token to avoid on-chain race conditions. | -| 7 | **Spam token rollover** | Should the wallet auto-rollover unknown/low-value tokens, or prompt the user first? | For v1, auto-rollover everything. Spam filtering is an enhancement for later. | +| 1 | **Per-transfer auditor address UX** | (a) Per-transfer entry only. (b) Wallet-managed address book. (c) dApp provides a list, wallet confirms. | The global and per-asset auditors are not in scope here; this question concerns only the optional per-transfer (voluntary) auditors. For v1, (a) or (c) is likely sufficient. | +| 2 | **Spam token rollover and surfacing** | When a token the user has never interacted with appears in `pending` (e.g. unsolicited airdrops, scam-token lookalikes), how does the wallet surface it and how is rollover scoped? | Suggested v1 answer: **per-token rollover only** (the user accepts incoming funds for one token at a time; no "accept all" action), **show unknown tokens with a warning badge** (not hidden, not blocked), **no allowlist dependency in v1** (rely on the badge plus the existing per-token approval to slow phishing patterns). This avoids gas-extraction traps, makes the user's pending-counter exhaustion exposure obvious, and keeps spam filtering out of v1's critical path while leaving room for an allowlist-based enhancement later. | From 9b269fb692d525511f27c8c5251819d6274603e8 Mon Sep 17 00:00:00 2001 From: Andy Golay Date: Thu, 7 May 2026 16:19:33 -0400 Subject: [PATCH 2/5] update doc with keyless --- confidential-assets/WALLET_INTEGRATION.md | 294 ++++++++++++++++++++-- 1 file changed, 269 insertions(+), 25 deletions(-) diff --git a/confidential-assets/WALLET_INTEGRATION.md b/confidential-assets/WALLET_INTEGRATION.md index 611205a58..46ca1e0d0 100644 --- a/confidential-assets/WALLET_INTEGRATION.md +++ b/confidential-assets/WALLET_INTEGRATION.md @@ -20,12 +20,14 @@ - [Key rotation (not wallet-supported)](#key-rotation-not-wallet-supported) 5. [Wallet UX decisions](#wallet-ux-decisions) 6. [Hardware wallets](#hardware-wallets) -7. [Multisig accounts](#multisig-accounts) -8. [Auditor support](#auditor-support) -9. [Safety and loss-of-funds analysis](#safety-and-loss-of-funds-analysis) -10. [Wallet ↔ application interface](#wallet--application-interface) -11. [Application conformance rules](#application-conformance-rules) -12. [Open questions](#open-questions) +7. [Keyless accounts](#keyless-accounts) +8. [Multisig accounts](#multisig-accounts) +9. [Auditor support](#auditor-support) +10. [Safety and loss-of-funds analysis](#safety-and-loss-of-funds-analysis) +11. [Wallet ↔ application interface](#wallet--application-interface) +12. [Application conformance rules](#application-conformance-rules) +13. [Branch integration plan (Motion Wallet)](#branch-integration-plan-motion-wallet) +14. [Open questions](#open-questions) --- @@ -96,7 +98,11 @@ The wallet maintains a separate `dk` for every `(account, token)` pair the accou ### Derivation -Software backings derive `dk[token]` via `TwistedEd25519PrivateKey.fromDerivationPath` (`confidential-assets/src/crypto/twistedEd25519.ts:163`). Hardware backings derive `dk[token]` via `TwistedEd25519PrivateKey.fromSignature` (`twistedEd25519.ts:172`). +Three backings are supported, one per account type: + +- **Software backings** derive `dk[token]` via `TwistedEd25519PrivateKey.fromDerivationPath` (`confidential-assets/src/crypto/twistedEd25519.ts:163`). +- **Hardware backings** derive `dk[token]` via `TwistedEd25519PrivateKey.fromSignature` (`twistedEd25519.ts:172`). +- **Keyless backings** derive `dk[token]` via `TwistedEd25519PrivateKey.fromUniformBytes` over `HKDF-SHA512` of the keyless pepper (see [HKDF layout for keyless backings](#hkdf-layout-for-keyless-backings) and [Keyless accounts](#keyless-accounts)). The pepper is the long-lived per-identity secret the keyless wallet already holds for address derivation; ephemeral signing keys play no role in `dk` derivation. #### Path layout for software backings @@ -146,6 +152,30 @@ Where: - `tokenMetadataAddressHex` is the 32-byte token metadata address rendered as a 64-character lowercase hex string with no `0x` prefix. - `":"` is a single ASCII colon byte (`0x3a`). +#### HKDF layout for keyless backings + +Keyless accounts have no mnemonic and no device that can sign a wallet-fixed message with a stable key (the keyless ephemeral keypair rotates). The wallet's stable per-identity secret is the **keyless pepper** it already holds for address derivation. `dk[account, token]` is derived from the pepper via HKDF, with the **account address** and the token metadata address both bound into the `info` field: + +``` +ikm = pepper // 31 bytes (hex-decoded + // from `MovementKeyless.completeLoginWithJwt(...).pepper`) +salt = utf8("movement-ca/v1") // 14 bytes +info = utf8("dk:") || accountAddress || tokenMetadataAddress // 3 + 32 + 32 = 67 bytes + // raw 32-byte addresses, not hex +okm = HKDF-SHA512(ikm, salt, info, L = 64) // 64 bytes +dk[account, token] = TwistedEd25519PrivateKey.fromUniformBytes(okm) +``` + +Where: + +- `pepper` is the per-identity secret the keyless wallet maintains; it survives ephemeral-key rotation and is recovered through the same path that recovers address derivation. In Motion Wallet via `@eigerco/movement-keyless` it is 31 raw bytes (the hex string returned by the prover, hex-decoded). HKDF accepts any input length, so the policy here is robust to a future change in pepper-service byte width. +- `salt` is the fixed ASCII string `movement-ca/v1`. The `v1` suffix reserves room to introduce a `v2` layout in a future release without orphaning existing registrations under `v1`. +- `info` is the 3-byte ASCII prefix `dk:` followed by the raw 32-byte account address and then the raw 32-byte token metadata address (neither rendered as hex). +- `accountAddress` is the address whose on-chain `ek` slot this `dk` is being derived for: the keyless wallet's own account address when deriving an owner-account `dk[token]`, or the multisig account's address when the keyless owner is acting as designated proposer for `dk[multisig, token]` (see [Multisig accounts](#multisig-accounts)). Binding `accountAddress` into `info` is what lets a single keyless identity (one pepper) safely back multiple distinct CA accounts — the keyless owner's own account plus any number of multisigs the owner is a designated proposer for — without `dk` collisions across them. It mirrors how software backings use `accountIndex` to distinguish multiple accounts under a single mnemonic. +- `L = 64` provides ≥ 512 bits of entropy for uniform reduction modulo the Ed25519 group order ℓ. `fromUniformBytes` performs the reduction and returns a 32-byte scalar in canonical little-endian form. + +The pepper is held in the same trust class as the mnemonic for software backings: in the wallet process only, never returned to any web origin, never logged, and never supplied by a dApp. + #### dApp-supplied parameters A dApp may supply the 32-byte token metadata address through `ca_register`, `ca_transfer`, and similar methods. It supplies no other derivation parameter. The wallet does not accept path prefixes, hardened-index counts, signed-message prefixes, or any other derivation input from a dApp. @@ -196,6 +226,31 @@ dk[USDC] = TwistedEd25519PrivateKey.fromSignature(device.sign(msg[USDC])) The wallet does not persist natively derived `dk[token]` for a hardware-backed account across lock events; each `dk[token]` is recomputed from a fresh device signature on unlock. Imported `dk` entries (for multi-owner custody) are persisted in the encrypted keystore. +#### Example 4 — keyless wallet, two registered tokens + +For the keyless owner's own account (address `acct`) registered for MOVE and USDC: + +``` +info[MOVE] = utf8("dk:") || acct || MOVE_meta_addr +info[USDC] = utf8("dk:") || acct || USDC_meta_addr + +okm[MOVE] = HKDF-SHA512(pepper, utf8("movement-ca/v1"), info[MOVE], 64) +okm[USDC] = HKDF-SHA512(pepper, utf8("movement-ca/v1"), info[USDC], 64) + +dk[acct, MOVE] = TwistedEd25519PrivateKey.fromUniformBytes(okm[MOVE]) +dk[acct, USDC] = TwistedEd25519PrivateKey.fromUniformBytes(okm[USDC]) +``` + +If the same keyless owner is the designated proposer for two multisigs `M1` and `M2`, both registered for MOVE, the same pepper produces three distinct `dk` values for token MOVE — one per `(account, token)` pair — because the account address is bound into `info`: + +``` +dk[acct, MOVE] = fromUniformBytes(HKDF-SHA512(pepper, salt, "dk:" || acct || MOVE_meta_addr, 64)) +dk[M1, MOVE] = fromUniformBytes(HKDF-SHA512(pepper, salt, "dk:" || M1 || MOVE_meta_addr, 64)) +dk[M2, MOVE] = fromUniformBytes(HKDF-SHA512(pepper, salt, "dk:" || M2 || MOVE_meta_addr, 64)) +``` + +As with software and hardware backings, natively derived `dk[account, token]` for a keyless account is recomputed on demand from the pepper and is not persisted at rest. + ### Storage and export Each per-asset `dk` (natively derived or imported) is stored, exported, and imported on the same footing as an Ed25519 signing key. @@ -206,17 +261,17 @@ Each per-asset `dk` (natively derived or imported) is stored, exported, and impo | In memory | Loaded only while an operation against the corresponding token is running; zeroed on wallet lock and on idle timeout. Loading `dk[X]` does not decrypt `dk[Y]`. | | Export | User-initiated UI action, scoped to one `(account, token)`. Gated by master-password re-prompt and typed confirmation of the asset name. Returns a single 32-byte hex string. No bulk export. No dApp-callable export. | | Import | User-initiated UI action, scoped to one `(account, token)`. Imported entries are labeled `imported` in the UI. | -| Backup | Mnemonic recovery reproduces every natively derived `dk[token]`. Imported `dk` entries are not reproduced by mnemonic recovery and must be retained out of band. | +| Backup | For software backings, mnemonic recovery reproduces every natively derived `dk[token]`. For hardware backings, re-pairing the same device reproduces them. For keyless backings, pepper recovery (the same path that recovers the keyless account's address) reproduces them. Imported `dk` entries are not reproduced by any of these paths and must be retained out of band. | | Display | The wallet UI may display `ek[token]`. `dk[token]` bytes are displayed only inside the export confirmation flow. | ### Security invariants -- A given `dk[token]` is held in the wallet process's memory only while an operation against that token is running. On wallet lock, all cached per-asset `dk` values are zeroed along with the rest of the unlocked key material. For software-backed accounts the mnemonic and any cached BIP-32 intermediate state are also zeroed; for hardware-backed accounts the mnemonic is never present in wallet memory. +- A given `dk[token]` is held in the wallet process's memory only while an operation against that token is running. On wallet lock, all cached per-asset `dk` values are zeroed along with the rest of the unlocked key material. For software-backed accounts the mnemonic and any cached BIP-32 intermediate state are also zeroed; for hardware-backed accounts the mnemonic is never present in wallet memory; for keyless-backed accounts the pepper and any cached HKDF intermediate state are also zeroed. - `dk[token]` bytes are never returned to any web origin and are never logged. -- `dk[token]` is stored at rest only in one of two forms: (a) derivable on demand from root key material the wallet already holds (the mnemonic for software-backed accounts, or device re-signing for hardware-backed accounts); or (b) a user-imported standalone blob in the encrypted keystore, with the same protections as imported Ed25519 signing keys, gated behind an explicit user import action. Form (b) is never written by a dApp-callable code path. +- `dk[token]` is stored at rest only in one of two forms: (a) derivable on demand from root key material the wallet already holds (the mnemonic for software-backed accounts, device re-signing for hardware-backed accounts, or the pepper for keyless-backed accounts); or (b) a user-imported standalone blob in the encrypted keystore, with the same protections as imported Ed25519 signing keys, gated behind an explicit user import action. Form (b) is never written by a dApp-callable code path. - Per-asset isolation is enforced in code, not by convention. The function that loads a `dk` takes `(accountAddress, tokenMetadataAddress)` and returns exactly one `dk`. No API returns "the account's `dk`" or "all `dk` values." Proof-construction routines accept a single `dk` and a single token address; a mismatch is rejected before any cryptographic work begins. -- The derivation policy is stable across releases. For software-backed accounts this includes the BIP-32 path layout `m/44'/637'/{accountIndex}'/1'/{tokenIndex}'` and the `tokenIndex` derivation `u32_le(SHA-256(tokenMetadataAddress)[0..4]) & 0x7FFFFFFF`. For hardware-backed accounts it includes the SDK's fixed `decryptionKeyDerivationMessage` prefix and the convention that the 32-byte token metadata address is appended as its lowercase hex representation, separated by a single ASCII colon. Any change to either yields a different `dk[token]` / `ek[token]` and breaks existing registrations; release notes must call out such changes. -- The derivation message used with `fromSignature` is hard-coded in the wallet and is never supplied by a dApp. The dApp's only influence on derivation is the 32-byte FA metadata address it passes through `ca_*` methods; the wallet always inserts that address into the same fixed path layout (software backing) or appends it to the same fixed prefix message (hardware backing). See [Wallet adapter integration](#wallet-adapter-integration). +- The derivation policy is stable across releases. For software-backed accounts this includes the BIP-32 path layout `m/44'/637'/{accountIndex}'/1'/{tokenIndex}'` and the `tokenIndex` derivation `u32_le(SHA-256(tokenMetadataAddress)[0..4]) & 0x7FFFFFFF`. For hardware-backed accounts it includes the SDK's fixed `decryptionKeyDerivationMessage` prefix and the convention that the 32-byte token metadata address is appended as its lowercase hex representation, separated by a single ASCII colon. For keyless-backed accounts it includes the fixed HKDF parameters specified in [HKDF layout for keyless backings](#hkdf-layout-for-keyless-backings) — `salt = utf8("movement-ca/v1")`, `info = utf8("dk:") || accountAddress || tokenMetadataAddress` (each 32 raw bytes), `hash = SHA-512`, `L = 64`, scalar reduced via `fromUniformBytes`. Any change to any of these yields a different `dk[token]` / `ek[token]` and breaks existing registrations; release notes must call out such changes. +- The derivation message used with `fromSignature`, and the HKDF salt and info layout used with the keyless pepper, are hard-coded in the wallet and are never supplied by a dApp. The dApp's only influence on derivation is the 32-byte FA metadata address it passes through `ca_*` methods; the wallet always inserts that address into the same fixed path layout (software backing), appends it to the same fixed prefix message (hardware backing), or splices it into the same fixed HKDF `info` field (keyless backing). See [Wallet adapter integration](#wallet-adapter-integration). ### Motion Wallet keystore schema @@ -230,6 +285,7 @@ Motion Wallet represents a multisig account as a first-class wallet entry alongs type WalletEntry = | { kind: 'mnemonic'; id: string; /* … existing fields … */ } | { kind: 'private-key'; id: string; /* … existing fields … */ } + | { kind: 'keyless'; id: string; /* … existing fields, including the encrypted pepper … */ } | { kind: 'multisig'; id: string; // local entry id; unrelated to on-chain address @@ -286,7 +342,7 @@ The outer `DkStoreV1` envelope is a plain JSON blob in `chrome.storage.local`. P #### What is persisted -Only **imported** entries are persisted at rest. Natively derived `dk[token]` values are recomputed on demand from root key material the wallet already holds (mnemonic for software, fresh device signature for hardware) and live only in an in-memory cache for the unlocked session. +Only **imported** entries are persisted at rest. Natively derived `dk[token]` values are recomputed on demand from root key material the wallet already holds (mnemonic for software, fresh device signature for hardware, pepper for keyless) and live only in an in-memory cache for the unlocked session. The cache shares its lifecycle with the Ed25519 signing-key cache: a derived `dk[token]` is computed lazily on first use during an unlocked session, retained for the remainder of that session, and zeroed on the same events that zero `cachedSigners` — wallet lock, idle auto-lock, and any future invalidation event that already clears the signing-key cache. Concretely the cache is a `Map` alongside `cachedSigners` in `services/wallet/account.ts`, governed by the existing `walletMutex`, and tied to the same lock/idle hooks rather than carrying its own eviction policy. @@ -324,13 +380,14 @@ loadDk(accountAddress: AccountAddress, tokenMetaAddr: AccountAddress): Promise Date: Wed, 13 May 2026 15:54:59 -0500 Subject: [PATCH 3/5] update, some open questions answered --- confidential-assets/MOTION_WALLET_ROLLOUT.md | 138 ++++++++++++++++ confidential-assets/WALLET_INTEGRATION.md | 165 ++----------------- 2 files changed, 155 insertions(+), 148 deletions(-) create mode 100644 confidential-assets/MOTION_WALLET_ROLLOUT.md diff --git a/confidential-assets/MOTION_WALLET_ROLLOUT.md b/confidential-assets/MOTION_WALLET_ROLLOUT.md new file mode 100644 index 000000000..b391e7e4f --- /dev/null +++ b/confidential-assets/MOTION_WALLET_ROLLOUT.md @@ -0,0 +1,138 @@ +# Motion Wallet rollout — confidential assets + +This file tracks the engineering rollout plan for landing confidential-assets support in Motion Wallet. It is **not** part of the design specification — for the design see [`WALLET_INTEGRATION.md`](./WALLET_INTEGRATION.md). When the rollout is complete this file should be deleted. + +## Branch integration plan + +This section captures the concrete plan for combining Motion Wallet's existing keyless branch (`feat/keyless-wallet`) with the existing CA work (`confidential-assets-local`) into a single shippable branch. It is grounded in the actual state of both branches as of this writing, not in inference. + +### State of the two branches + +- **`feat/keyless-wallet`** — head `6bb7a61`. Implements full keyless authentication via `@eigerco/movement-keyless`: OAuth via `chrome.identity.launchWebAuthFlow`, ZK-proof generation via the prover, ephemeral-key refresh on a Chrome alarm. Files: `src/services/wallet/keyless-{auth,session,signer,config}.ts`, plus a `keyless` variant in `WalletEntry`. **The pepper is fetched on every unlock and currently discarded** (`src/services/wallet/account.ts` `initializeFromKeyless` destructures only `{ account }` from `MovementKeyless.completeLoginWithJwt`). +- **`confidential-assets-local`** — head `3019c40`. Implements CA registration / send / balances against the SDK; uses `accountIndex`-keyed software-backed `dk` derivation; assumes mnemonic or private-key vault types and explicitly throws `"Unsupported account type for confidential assets"` for anything else. Includes localnet-specific config and a `docs/CONFIDENTIAL_ASSETS_INTEGRATION_PLAN.md` whose "keyless" row predates pepper exposure and is now stale. +- **Divergence:** `feat/keyless-wallet` is 22 ahead of, 17 behind, the merge-base with `confidential-assets-local`. + +### Recommended strategy + +Cut a new branch from `feat/keyless-wallet`, port CA changes onto it, and drop the localnet-only bits. + +Reasons: + +- The keyless branch is closer to product trunk (it's the active product surface, not an experiment). +- The CA branch's localnet bits are obsolete now that CA is on Movement testnet — porting *forward* lets you delete them rather than carry them. +- Pepper lifecycle is the most invasive piece and already lives on the keyless branch; porting CA *to* keyless is one-directional, while the reverse would re-do this work. + +### Proposed branch name + +`feat/confidential-assets` (cut from `feat/keyless-wallet`). + +### Step-by-step plan + +#### Step 1 — Cut the branch + +``` +git switch feat/keyless-wallet +git pull +git switch -c feat/confidential-assets +``` + +#### Step 2 — Retain the pepper in the unlocked session + +The keyless branch currently discards the pepper immediately after address verification: `MovementKeyless.completeLoginWithJwt` uses `pepper` to recompute the expected address, then the wallet destructures only `{ account }` and lets `pepper` fall out of scope. CA needs the pepper available *for the duration of the unlocked session* (same lifecycle as `cachedSigners` — in memory only, zeroed on wallet lock, never written to disk). The change is to widen the destructure and add `pepper` to the in-memory session struct. + +In `src/services/wallet/account.ts`, change `initializeFromKeyless` to capture `pepper` and stash it in `keylessActiveSession`: + +```ts +// before +const { account } = await keyless.completeLoginWithJwt(jwt, ephemeralKey) +// after +const { account, pepper } = await keyless.completeLoginWithJwt(jwt, ephemeralKey) +// ... +keylessActiveSession = { signer, aud: vaultData.aud, pepper } +``` + +Update the `keylessActiveSession` type: + +```ts +let keylessActiveSession: { + signer: KeylessSigner + aud: string + pepper: Uint8Array // 31 raw bytes; hex-decoded from MovementKeyless result +} | null = null +``` + +The same `pepper` field is also captured by `refreshKeylessSession` (in `src/services/wallet/keyless-session.ts`) when the ephemeral key is refreshed mid-session — the prover round-trip there returns the same pepper, but the session struct should be updated atomically with the new signer to avoid windowed inconsistency. Keep pepper handling colocated with signer handling. + +Zero the pepper on lock alongside `cachedSigners` (the existing lock path already calls `signer.dispose()`; add an explicit `keylessActiveSession.pepper.fill(0)` and set the field to `null`). + +#### Step 3 — Generalize `dk` derivation + +In `src/services/wallet/account.ts`, the existing `getTwistedDecryptionKey(accountIndex, tokenMetadataAddress)` branches on `vaultType`. Add a third branch: + +```ts +if (vaultType === 'keyless') { + if (!keylessActiveSession) throw new Error('Wallet locked') + return keylessDecryptionKey( + keylessActiveSession.pepper, + keylessActiveSession.signer.getAddress(), + tokenMetadataAddress, + ) +} +``` + +`keylessDecryptionKey` is the new SDK helper specified in the design doc (SDK helpers); it runs the canonical HKDF-SHA512 derivation and returns a `TwistedEd25519PrivateKey`. The SDK addition (`TwistedEd25519PrivateKey.fromUniformBytes`, the helper itself) lands in `@moveindustries/confidential-assets` as a prerequisite — a small PR there before any wallet code lands. + +The existing `getEd25519SigningAccount` is a misnomer for the keyless case (a keyless signer is not an `Ed25519Account`). Rename to `getCaSenderAddress(accountIndex)` and return just the `AccountAddress` — the only thing the CA SDK call paths actually need from this function once `dk` is sourced separately. Mnemonic and private-key paths return `signer.accountAddress`; keyless returns `keylessActiveSession.signer.getAddress()`. + +If any CA SDK call still requires a full `Account`-shaped signer (e.g. `ca.registerBalance({ signer, ... })`), use the keyless branch's `KeylessSigner` directly — it already implements the same `Signer` interface that the rest of the wallet uses. The CA SDK's `signer` parameter is structurally typed; verify the keyless `account` from `@eigerco/movement-keyless` is shape-compatible (it is in `KeylessSigner.buildSignSubmit` already, via `signer: this.account! as never`). + +#### Step 4 — Port the CA service module + +Bring over `src/services/wallet/confidential-asset.ts` from `confidential-assets-local`. Adjust: + +- Replace `getEd25519SigningAccount(accountIndex)` calls with `getCaSenderAddress(accountIndex)` for address-only uses (most uses). +- For `ca.registerBalance({ signer, ... })` and similar, source `signer` from the wallet's existing signer factory (the same one keyless and mnemonic both feed into). +- Remove the explicit `'Unsupported account type for confidential assets'` throw in the keyless path — replaced by Step 3's keyless branch. + +#### Step 5 — Port UI hooks and pages + +Bring over `src/popup/hooks/useConfidentialBalances.ts` and any CA UI pages from `confidential-assets-local`. These should be backing-agnostic since they go through the wallet service layer — no keyless-specific changes expected, but smoke-test against a keyless account on testnet. + +#### Step 6 — Drop localnet-only bits + +Audit `confidential-assets-local` for localnet-only changes (network config additions, hardcoded addresses, dev-loop helpers). Drop them; testnet is the new baseline. Likely sites: + +- `src/core/network/config.ts` — drop any localnet `confidentialAssetModuleAddress` overrides if testnet's default is canonical. +- `package.json` — drop any `file:..` overrides that pointed at a localnet-built SDK. + +The existing `docs/CONFIDENTIAL_ASSETS_INTEGRATION_PLAN.md` on the CA branch is stale (its keyless row predates pepper exposure). Either delete it or replace its keyless row with a one-line pointer to `wallet_integration.md` § Keyless accounts — don't carry both forward. + +#### Step 7 — Tests + +- Unit-test `keylessDecryptionKey` against fixed pepper / address / token vectors (so a regression in the SDK is caught upstream of the wallet). +- Unit-test the `account.ts` keyless branch of `getTwistedDecryptionKey`: same pepper + same address + same token = same `dk`; same pepper + different addresses (owner vs multisig) = different `dk`; same pepper + same address + different tokens = different `dk`. +- Integration-test against testnet: register a token on a keyless account, deposit, verify balance decryption, send a confidential transfer, verify the recipient (also keyless) decrypts the right amount. +- Regression-test the existing keyless and mnemonic paths to confirm the `dk`-derivation rename / branch addition didn't break anything. + +#### Step 8 — Wire up the SDK additions + +Concurrent with the wallet work (or just before): land the SDK additions in `@moveindustries/confidential-assets`: + +- `TwistedEd25519PrivateKey.fromUniformBytes(bytes: Uint8Array): TwistedEd25519PrivateKey` — accepts ≥ 32 bytes, reduces mod ℓ. +- `keylessDecryptionKey(pepper: Uint8Array, accountAddress: AccountAddressInput, tokenMetaAddr: AccountAddressInput): TwistedEd25519PrivateKey` — runs the canonical HKDF expansion specified in the design doc. +- Tests asserting fixed input vectors → fixed output `dk` bytes (regression guard for the wallet ↔ chain compatibility contract). + +### Risks and order-of-operations notes + +- **SDK PR must land first.** The wallet branch depends on `keylessDecryptionKey` and `fromUniformBytes`; landing the wallet PR before the SDK PR strands the wallet branch on a broken import. +- **First-time keyless registration is irreversible at the policy level.** Once a keyless account registers `ek[token]` on chain, that key is bound to *this* HKDF policy version (`v1`). A late change to salt, info layout, or hash function before public testnet release is free; after public release it requires `rotate_encryption_key`. So: lock the policy strings (`"movement-ca/v1"`, `"dk:"`, SHA-512, L=64) in code review, not after. +- **Multisig is a separate question.** Motion Wallet's multisig support and CA-on-multisig are independent of this branch merge. Do not block the keyless+CA combination on multisig; treat multisig as a follow-up. +- **`accountIndex` mismatch.** The `confidential-assets-local` branch threads `accountIndex` everywhere. Keyless wallets currently use `accountIndex = 0` only (one keyless account per vault). This works as-is, but if Motion Wallet later supports multiple keyless accounts per identity, the `getCaSenderAddress(accountIndex)` signature is already the right shape. + +### Not implemented by this merge + +The items below are part of the design (see the design doc) but do not ship in this particular PR. They are deferred implementation work, not unresolved design. + +- Multisig CA flows. +- Hardware-backed CA. +- Per-asset auditor UI (separate work stream). diff --git a/confidential-assets/WALLET_INTEGRATION.md b/confidential-assets/WALLET_INTEGRATION.md index 46ca1e0d0..ea0e8ffb7 100644 --- a/confidential-assets/WALLET_INTEGRATION.md +++ b/confidential-assets/WALLET_INTEGRATION.md @@ -26,8 +26,7 @@ 10. [Safety and loss-of-funds analysis](#safety-and-loss-of-funds-analysis) 11. [Wallet ↔ application interface](#wallet--application-interface) 12. [Application conformance rules](#application-conformance-rules) -13. [Branch integration plan (Motion Wallet)](#branch-integration-plan-motion-wallet) -14. [Open questions](#open-questions) +13. [Open questions](#open-questions) --- @@ -176,6 +175,8 @@ Where: The pepper is held in the same trust class as the mnemonic for software backings: in the wallet process only, never returned to any web origin, never logged, and never supplied by a dApp. +**Federated keyless.** Movement supports both vanilla and federated keyless. The federated-keyless pepper has identical semantics to the vanilla-keyless pepper — same lifecycle, same rotation policy (none), same per-identity stability — because both authentication paths flow through the same pepper service (`keyless/pepper/service`) and the federation address (`jwk_addr`) is metadata that does not enter the pepper or IDC derivation. The HKDF policy above therefore applies unchanged to federated-keyless accounts. A user with the same OIDC identity surfaced via vanilla and via a federation derives the same pepper and therefore the same `dk[token]` across both paths. + #### dApp-supplied parameters A dApp may supply the 32-byte token metadata address through `ca_register`, `ca_transfer`, and similar methods. It supplies no other derivation parameter. The wallet does not accept path prefixes, hardened-index counts, signed-message prefixes, or any other derivation input from a dApp. @@ -270,7 +271,7 @@ Each per-asset `dk` (natively derived or imported) is stored, exported, and impo - `dk[token]` bytes are never returned to any web origin and are never logged. - `dk[token]` is stored at rest only in one of two forms: (a) derivable on demand from root key material the wallet already holds (the mnemonic for software-backed accounts, device re-signing for hardware-backed accounts, or the pepper for keyless-backed accounts); or (b) a user-imported standalone blob in the encrypted keystore, with the same protections as imported Ed25519 signing keys, gated behind an explicit user import action. Form (b) is never written by a dApp-callable code path. - Per-asset isolation is enforced in code, not by convention. The function that loads a `dk` takes `(accountAddress, tokenMetadataAddress)` and returns exactly one `dk`. No API returns "the account's `dk`" or "all `dk` values." Proof-construction routines accept a single `dk` and a single token address; a mismatch is rejected before any cryptographic work begins. -- The derivation policy is stable across releases. For software-backed accounts this includes the BIP-32 path layout `m/44'/637'/{accountIndex}'/1'/{tokenIndex}'` and the `tokenIndex` derivation `u32_le(SHA-256(tokenMetadataAddress)[0..4]) & 0x7FFFFFFF`. For hardware-backed accounts it includes the SDK's fixed `decryptionKeyDerivationMessage` prefix and the convention that the 32-byte token metadata address is appended as its lowercase hex representation, separated by a single ASCII colon. For keyless-backed accounts it includes the fixed HKDF parameters specified in [HKDF layout for keyless backings](#hkdf-layout-for-keyless-backings) — `salt = utf8("movement-ca/v1")`, `info = utf8("dk:") || accountAddress || tokenMetadataAddress` (each 32 raw bytes), `hash = SHA-512`, `L = 64`, scalar reduced via `fromUniformBytes`. Any change to any of these yields a different `dk[token]` / `ek[token]` and breaks existing registrations; release notes must call out such changes. +- The derivation policy is stable across releases. For software-backed accounts this includes the BIP-32 path layout `m/44'/637'/{accountIndex}'/1'/{tokenIndex}'` and the `tokenIndex` derivation `u32_le(SHA-256(tokenMetadataAddress)[0..4]) & 0x7FFFFFFF`, plus the multisig-proposer reduction `multisigAccountIndex = u32_le(SHA-256(multisigAddress)[0..4]) & 0x7FFFFFFF` (see [DK sharing among co-owners](#dk-sharing-among-co-owners)). For hardware-backed accounts it includes the SDK's fixed `decryptionKeyDerivationMessage` prefix and the convention that the 32-byte token metadata address is appended as its lowercase hex representation, separated by a single ASCII colon — plus the multisig-proposer variant which additionally prepends `hex(multisigAddress)` between the prefix and the token (see [DK sharing among co-owners](#dk-sharing-among-co-owners)). For keyless-backed accounts it includes the fixed HKDF parameters specified in [HKDF layout for keyless backings](#hkdf-layout-for-keyless-backings) — `salt = utf8("movement-ca/v1")`, `info = utf8("dk:") || accountAddress || tokenMetadataAddress` (each 32 raw bytes), `hash = SHA-512`, `L = 64`, scalar reduced via `fromUniformBytes`. Any change to any of these yields a different `dk[token]` / `ek[token]` and breaks existing registrations; release notes must call out such changes. - The derivation message used with `fromSignature`, and the HKDF salt and info layout used with the keyless pepper, are hard-coded in the wallet and are never supplied by a dApp. The dApp's only influence on derivation is the 32-byte FA metadata address it passes through `ca_*` methods; the wallet always inserts that address into the same fixed path layout (software backing), appends it to the same fixed prefix message (hardware backing), or splices it into the same fixed HKDF `info` field (keyless backing). See [Wallet adapter integration](#wallet-adapter-integration). ### Motion Wallet keystore schema @@ -589,6 +590,8 @@ The same window-of-compromise reasoning applies to the pepper: while the wallet Pepper recovery (the same path that recovers the keyless account's address) reproduces every natively derived `dk[token]`. Pepper loss is equivalent to mnemonic loss for a software backing: the confidential balances for every token registered against this account become unrecoverable. Imported `dk[token]` entries (multi-owner custody) are not reproduced by pepper recovery and must be retained out of band, on the same footing as for software and hardware backings. +**Loss of OIDC provider access.** Pepper recovery itself requires the user to re-authenticate with the OIDC provider that backs their keyless identity. If the user permanently loses access to that provider account — deleted Google account, identity-provider shutdown, employer revoking access to a workforce IdP, etc. — they cannot re-authenticate, cannot fetch the pepper, and every `dk[token]` derived from that pepper becomes unrecoverable. The on-chain keyless account itself faces the same fate for fund movement, so CA recovery inherits the tail risk of the keyless identity model rather than introducing a new one. Wallet UX should make this risk obvious before the user sinks meaningful confidential balances into a keyless-only account. + If the keyless derivation policy is rotated in a future release (a `v2` HKDF layout), the wallet must perform `rotate_encryption_key` for each registered token *while the old `v1`-derived `dk[token]` is still derivable*; otherwise the on-chain `ek[token]` becomes orphaned. This rotation is out of scope for the wallet UI, on the same footing as decryption-key rotation generally — see [Key rotation (not wallet-supported)](#key-rotation-not-wallet-supported). #### Requirements on the wallet @@ -671,7 +674,15 @@ With these parameters in place, the dApp constructs no proofs and holds no `dk`. ### DK sharing among co-owners -Each `dk[multisig, token]` is per-`(account, token)` material derived inside one owner's wallet — via `fromDerivationPath` for software-backed accounts, `fromSignature` for hardware-backed accounts, or HKDF over the originating owner's keyless pepper for keyless-backed accounts. In all three cases the derivation is parameterised by the multisig account's address (as the `accountIndex`-equivalent identity, the suffix of the signed message, or a component of the HKDF `info` field, respectively) and the specific token's metadata address. No other co-owner can reproduce it from their own wallet alone. Multi-owner confidential-asset custody therefore requires sharing each registered asset's `dk` separately: +A `dk` is a 32-byte Ristretto scalar with no address embedded in it. The cryptographic linkage between a `dk` and a multisig vault is established at registration: the wallet computes `ek = dk.publicKey()` and a multisig proposal writes `ek` into the on-chain `(multisigAddress, tokenMetaAddr)` slot. From that point forward, proofs verify against `ek` and the Fiat–Shamir transcript binds them to `senderAddress = multisigAddress` (see [Multisig accounts](#multisig-accounts)). The chain does not know how `dk` was produced, only that the registered `ek` is the public counterpart it accepts. + +Off-chain *derivation* exists so the originating owner can reproduce those 32 bytes deterministically from their root key material after a wallet restore, and so a single owner who is a designated proposer for multiple multisigs derives a distinct `dk` per vault rather than colliding. The per-backing rules for multisig-proposer derivation are: + +- **Software backings (mnemonic):** `dk[multisig, token] = TwistedEd25519PrivateKey.fromDerivationPath("m/44'/637'/{multisigAccountIndex}'/1'/{tokenIndex}'", mnemonic)`, where `multisigAccountIndex = u32_le(SHA-256(multisigAddress)[0..4]) & 0x7FFFFFFF`. The reduction mirrors the `tokenIndex` formula in [Path layout for software backings](#path-layout-for-software-backings). Collision probability with the proposer's own personal account indices is ~1/2³¹ per multisig registration; on collision the proposer must rotate via `rotate_encryption_key`. The reduction is fixed: a different formula yields a different `dk` and orphans the registration. +- **Hardware backings (device signature):** `message[multisig, token] = decryptionKeyDerivationMessage ‖ ":" ‖ hex(multisigAddress) ‖ ":" ‖ hex(tokenMetadataAddress)`, then `dk[multisig, token] = TwistedEd25519PrivateKey.fromSignature(device.sign(message))`. This is a multisig-proposer-specific layout that prepends `hex(multisigAddress)` to the single-owner hardware layout in [Signed-message layout for hardware backings](#signed-message-layout-for-hardware-backings); the single-owner layout is unchanged, so existing non-multisig hardware-backed registrations are not affected. +- **Keyless backings (pepper):** `dk[multisig, token] = keylessDecryptionKey(pepper, multisigAddress, tokenMetadataAddress)`, with `multisigAddress` substituted into the `accountAddress` slot of the HKDF `info` field defined in [HKDF layout for keyless backings](#hkdf-layout-for-keyless-backings). No layout change needed — the keyless layout already binds `accountAddress`. + +In all three cases the derivation is parameterised by the multisig account's address and the specific token's metadata address, and no other co-owner can reproduce it from their own wallet alone (every backing's root material is per-owner private). Multi-owner confidential-asset custody therefore requires sharing each registered asset's `dk` separately: 1. For a given token `T`, one designated owner derives `dk[multisig, T]` normally in their wallet, with the multisig account's address as the binding identity. 2. The same owner registers the corresponding `ek[multisig, T]` against the multisig account's address on chain, by submitting a multisig proposal that invokes `register` for token `T`. @@ -1050,155 +1061,13 @@ Browser dApps integrating with confidential assets must follow these rules: --- -## Branch integration plan (Motion Wallet) - -This section captures the concrete plan for combining Motion Wallet's existing keyless branch (`feat/keyless-wallet`) with the existing CA work (`confidential-assets-local`) into a single shippable branch. It is grounded in the actual state of both branches as of this writing, not in inference. - -### State of the two branches - -- **`feat/keyless-wallet`** — head `6bb7a61`. Implements full keyless authentication via `@eigerco/movement-keyless`: OAuth via `chrome.identity.launchWebAuthFlow`, ZK-proof generation via the prover, ephemeral-key refresh on a Chrome alarm. Files: `src/services/wallet/keyless-{auth,session,signer,config}.ts`, plus a `keyless` variant in `WalletEntry`. **The pepper is fetched on every unlock and currently discarded** (`src/services/wallet/account.ts` `initializeFromKeyless` destructures only `{ account }` from `MovementKeyless.completeLoginWithJwt`). -- **`confidential-assets-local`** — head `3019c40`. Implements CA registration / send / balances against the SDK; uses `accountIndex`-keyed software-backed `dk` derivation; assumes mnemonic or private-key vault types and explicitly throws `"Unsupported account type for confidential assets"` for anything else. Includes localnet-specific config and a `docs/CONFIDENTIAL_ASSETS_INTEGRATION_PLAN.md` whose "keyless" row predates pepper exposure and is now stale. -- **Divergence:** `feat/keyless-wallet` is 22 ahead of, 17 behind, the merge-base with `confidential-assets-local`. - -### Recommended strategy - -Cut a new branch from `feat/keyless-wallet`, port CA changes onto it, and drop the localnet-only bits. - -Reasons: - -- The keyless branch is closer to product trunk (it's the active product surface, not an experiment). -- The CA branch's localnet bits are obsolete now that CA is on Movement testnet — porting *forward* lets you delete them rather than carry them. -- Pepper lifecycle is the most invasive piece and already lives on the keyless branch; porting CA *to* keyless is one-directional, while the reverse would re-do this work. - -### Proposed branch name - -`feat/confidential-assets` (cut from `feat/keyless-wallet`). - -### Step-by-step plan - -#### Step 1 — Cut the branch - -``` -git switch feat/keyless-wallet -git pull -git switch -c feat/confidential-assets -``` - -#### Step 2 — Retain the pepper in the unlocked session - -The keyless branch currently discards the pepper immediately after address verification: `MovementKeyless.completeLoginWithJwt` uses `pepper` to recompute the expected address, then the wallet destructures only `{ account }` and lets `pepper` fall out of scope. CA needs the pepper available *for the duration of the unlocked session* (same lifecycle as `cachedSigners` — in memory only, zeroed on wallet lock, never written to disk). The change is to widen the destructure and add `pepper` to the in-memory session struct. - -In `src/services/wallet/account.ts`, change `initializeFromKeyless` to capture `pepper` and stash it in `keylessActiveSession`: - -```ts -// before -const { account } = await keyless.completeLoginWithJwt(jwt, ephemeralKey) -// after -const { account, pepper } = await keyless.completeLoginWithJwt(jwt, ephemeralKey) -// ... -keylessActiveSession = { signer, aud: vaultData.aud, pepper } -``` - -Update the `keylessActiveSession` type: - -```ts -let keylessActiveSession: { - signer: KeylessSigner - aud: string - pepper: Uint8Array // 31 raw bytes; hex-decoded from MovementKeyless result -} | null = null -``` - -The same `pepper` field is also captured by `refreshKeylessSession` (in `src/services/wallet/keyless-session.ts`) when the ephemeral key is refreshed mid-session — the prover round-trip there returns the same pepper, but the session struct should be updated atomically with the new signer to avoid windowed inconsistency. Keep pepper handling colocated with signer handling. - -Zero the pepper on lock alongside `cachedSigners` (the existing lock path already calls `signer.dispose()`; add an explicit `keylessActiveSession.pepper.fill(0)` and set the field to `null`). - -#### Step 3 — Generalize `dk` derivation - -In `src/services/wallet/account.ts`, the existing `getTwistedDecryptionKey(accountIndex, tokenMetadataAddress)` branches on `vaultType`. Add a third branch: - -```ts -if (vaultType === 'keyless') { - if (!keylessActiveSession) throw new Error('Wallet locked') - return keylessDecryptionKey( - keylessActiveSession.pepper, - keylessActiveSession.signer.getAddress(), - tokenMetadataAddress, - ) -} -``` - -`keylessDecryptionKey` is the new SDK helper specified in [§ SDK changes / 3. Canonical derivation helpers](#3-canonical-derivation-helpers); it runs the canonical HKDF-SHA512 derivation and returns a `TwistedEd25519PrivateKey`. The SDK addition (`TwistedEd25519PrivateKey.fromUniformBytes`, the helper itself) lands in `@moveindustries/confidential-assets` as a prerequisite — a small PR there before any wallet code lands. - -The existing `getEd25519SigningAccount` is a misnomer for the keyless case (a keyless signer is not an `Ed25519Account`). Rename to `getCaSenderAddress(accountIndex)` and return just the `AccountAddress` — the only thing the CA SDK call paths actually need from this function once `dk` is sourced separately. Mnemonic and private-key paths return `signer.accountAddress`; keyless returns `keylessActiveSession.signer.getAddress()`. - -If any CA SDK call still requires a full `Account`-shaped signer (e.g. `ca.registerBalance({ signer, ... })`), use the keyless branch's `KeylessSigner` directly — it already implements the same `Signer` interface that the rest of the wallet uses. The CA SDK's `signer` parameter is structurally typed; verify the keyless `account` from `@eigerco/movement-keyless` is shape-compatible (it is in `KeylessSigner.buildSignSubmit` already, via `signer: this.account! as never`). - -#### Step 4 — Port the CA service module - -Bring over `src/services/wallet/confidential-asset.ts` from `confidential-assets-local`. Adjust: - -- Replace `getEd25519SigningAccount(accountIndex)` calls with `getCaSenderAddress(accountIndex)` for address-only uses (most uses). -- For `ca.registerBalance({ signer, ... })` and similar, source `signer` from the wallet's existing signer factory (the same one keyless and mnemonic both feed into). -- Remove the explicit `'Unsupported account type for confidential assets'` throw in the keyless path — replaced by Step 3's keyless branch. - -#### Step 5 — Port UI hooks and pages - -Bring over `src/popup/hooks/useConfidentialBalances.ts` and any CA UI pages from `confidential-assets-local`. These should be backing-agnostic since they go through the wallet service layer — no keyless-specific changes expected, but smoke-test against a keyless account on testnet. - -#### Step 6 — Drop localnet-only bits - -Audit `confidential-assets-local` for localnet-only changes (network config additions, hardcoded addresses, dev-loop helpers). Drop them; testnet is the new baseline. Likely sites: - -- `src/core/network/config.ts` — drop any localnet `confidentialAssetModuleAddress` overrides if testnet's default is canonical. -- `package.json` — drop any `file:..` overrides that pointed at a localnet-built SDK. - -The existing `docs/CONFIDENTIAL_ASSETS_INTEGRATION_PLAN.md` on the CA branch is stale (its keyless row predates pepper exposure). Either delete it or replace its keyless row with a one-line pointer to this `wallet_integration.md` § Keyless accounts — don't carry both forward. - -#### Step 7 — Tests - -- Unit-test `keylessDecryptionKey` against fixed pepper / address / token vectors (so a regression in the SDK is caught upstream of the wallet). -- Unit-test the `account.ts` keyless branch of `getTwistedDecryptionKey`: same pepper + same address + same token = same `dk`; same pepper + different addresses (owner vs multisig) = different `dk`; same pepper + same address + different tokens = different `dk`. -- Integration-test against testnet: register a token on a keyless account, deposit, verify balance decryption, send a confidential transfer, verify the recipient (also keyless) decrypts the right amount. -- Regression-test the existing keyless and mnemonic paths to confirm the `dk`-derivation rename / branch addition didn't break anything. - -#### Step 8 — Wire up the SDK additions - -Concurrent with the wallet work (or just before): land the SDK additions in `@moveindustries/confidential-assets`: - -- `TwistedEd25519PrivateKey.fromUniformBytes(bytes: Uint8Array): TwistedEd25519PrivateKey` — accepts ≥ 32 bytes, reduces mod ℓ. -- `keylessDecryptionKey(pepper: Uint8Array, accountAddress: AccountAddressInput, tokenMetaAddr: AccountAddressInput): TwistedEd25519PrivateKey` — runs the canonical HKDF expansion specified in this doc. -- Tests asserting fixed input vectors → fixed output `dk` bytes (regression guard for the wallet ↔ chain compatibility contract). - -### Risks and order-of-operations notes - -- **SDK PR must land first.** The wallet branch depends on `keylessDecryptionKey` and `fromUniformBytes`; landing the wallet PR before the SDK PR strands the wallet branch on a broken import. -- **First-time keyless registration is irreversible at the policy level.** Once a keyless account registers `ek[token]` on chain, that key is bound to *this* HKDF policy version (`v1`). A late change to salt, info layout, or hash function before public testnet release is free; after public release it requires `rotate_encryption_key`. So: lock the policy strings (`"movement-ca/v1"`, `"dk:"`, SHA-512, L=64) in code review, not after. -- **Multisig is a separate question.** Motion Wallet's multisig support and CA-on-multisig are independent of this branch merge. Do not block the keyless+CA combination on multisig; treat multisig as a follow-up. -- **`accountIndex` mismatch.** The `confidential-assets-local` branch threads `accountIndex` everywhere. Keyless wallets currently use `accountIndex = 0` only (one keyless account per vault). This works as-is, but if Motion Wallet later supports multiple keyless accounts per identity, the `getCaSenderAddress(accountIndex)` signature is already the right shape. - -### Out of scope for this merge - -- Multisig CA flows (separate effort). -- Hardware-backed CA (also separate; see Open questions row 3). -- Per-asset auditor UI (already has its own line of work). - ---- - ## Open questions -These should be resolved before implementation: +These are design decisions the spec has not yet made. Each must be resolved before any wallet implementation writes confidential-asset registrations on chain under that decision, because once `ek` is registered the choice in effect at registration time is the only one that reproduces a matching `dk` thereafter. | # | Question | Options | Notes | |---|---|---|---| | 1 | **Per-transfer auditor address UX** | (a) Per-transfer entry only. (b) Wallet-managed address book. (c) dApp provides a list, wallet confirms. | The global and per-asset auditors are not in scope here; this question concerns only the optional per-transfer (voluntary) auditors. For v1, (a) or (c) is likely sufficient. | | 2 | **Spam token rollover and surfacing** | When a token the user has never interacted with appears in `pending` (e.g. unsolicited airdrops, scam-token lookalikes), how does the wallet surface it and how is rollover scoped? | Suggested v1 answer: **per-token rollover only** (the user accepts incoming funds for one token at a time; no "accept all" action), **show unknown tokens with a warning badge** (not hidden, not blocked), **no allowlist dependency in v1** (rely on the badge plus the existing per-token approval to slow phishing patterns). This avoids gas-extraction traps, makes the user's pending-counter exhaustion exposure obvious, and keeps spam filtering out of v1's critical path while leaving room for an allowlist-based enhancement later. | -| 3 | **Hardware-backing account-address binding** | The hardware signed-message layout currently binds only the token metadata address (`message = decryptionKeyDerivationMessage ‖ ":" ‖ hex(tokenMetadataAddress)`). A hardware-backed owner who is a designated proposer for two multisigs both registered for the same token would derive identical `dk` for both — a `dk` collision that the keyless backing avoids by also binding `accountAddress` into its HKDF `info` field. Should the hardware layout be amended (e.g. `decryptionKeyDerivationMessage ‖ ":" ‖ hex(accountAddress) ‖ ":" ‖ hex(tokenMetadataAddress)`) to match? | Amending the layout breaks every existing hardware-backing registration; not amending it leaves the `dk`-collision footgun in place for hardware-backed multisig owners. Decision needed before any hardware-backed multisig registrations are written on chain. The software backing already binds the multisig address through the BIP-32 `accountIndex` slot, so it is unaffected. | -| 4 | ~~**Pepper rotation policy**~~ → **Resolved** | The pepper returned by `@eigerco/movement-keyless` is documented as "deterministic per `(sub, aud)`" (`motion-wallet:src/core/types/wallet.ts:55`) — i.e. stable for the life of the keyless identity. CA implementation can assume no rotation. If a future Movement pepper-service version introduces rotation, this row reopens and the keyless §Recovery prose (the `v2`-HKDF-layout rotation procedure) becomes the playbook. | — | -| 5 | ~~**Pepper-service availability at unlock**~~ → **Resolved** | Motion Wallet's keyless branch already requires interactive OAuth + a prover round-trip on every unlock; the pepper is fetched fresh each time and is not cached at rest (`motion-wallet:src/services/wallet/account.ts:170`+). CA inherits this: if the pepper service / prover is unreachable, the wallet cannot unlock at all, which subsumes "CA cannot decrypt." No CA-specific degradation mode is needed. | — | -| 6 | **Federated keyless** | Movement supports both vanilla and federated keyless. Does the federated-keyless pepper have the same semantics (same lifecycle, same rotation policy, same per-identity stability) as the vanilla-keyless pepper, such that the same HKDF derivation policy applies unchanged? | Should be verified before claiming `[Keyless accounts](#keyless-accounts)` covers federated keyless. If the lifecycles diverge, federated keyless may need its own `salt` namespace (e.g. `movement-ca/federated/v1`) to prevent cross-policy `dk` aliasing. | -| 7 | ~~**Pepper byte format**~~ → **Resolved** | The Movement pepper service via `@eigerco/movement-keyless` returns a hex string (`KeylessLoginResult.pepper`) which decodes to 31 raw bytes. The wallet feeds those 31 raw bytes verbatim as the HKDF `ikm`. | — | -| 8 | ~~**Pepper at-rest storage**~~ → **Resolved (option a)** | Motion Wallet's keyless branch does not persist the pepper at rest; it is re-fetched from the prover on every unlock as part of the OAuth flow. CA inherits this: pepper lives only in `keylessActiveSession` for the unlocked-session lifetime. The `WalletEntry.kind = 'keyless'` shape need not gain an at-rest pepper field. | — | -| 9 | **Ephemeral-key expiry mid-proof** | A `confidential_transfer` requires a sigma proof and two range proofs; in-browser construction takes seconds. If the keyless ephemeral key expires between the wallet starting proof construction and the wallet attempting to submit, the user faces a re-authentication round-trip. | The proof itself binds to `senderAddress` via Fiat–Shamir, not to the ephemeral key, so the proof survives re-auth and can be wrapped in a freshly-signed transaction. Open: does the wallet (a) silently trigger keyless re-auth and re-sign the existing proof, or (b) surface a dedicated error and ask the user to retry from scratch? Affects perceived reliability for sessions held open near the ephemeral-key expiry boundary. | -| 10 | **Loss of OIDC provider access** | If a user permanently loses access to the OIDC account that backs their keyless identity (deleted Google account, identity-provider shutdown, employer revoking access to a workforce IdP, etc.), they cannot re-authenticate and cannot fetch the pepper. Every `dk[token]` derived from that pepper becomes unrecoverable. | The on-chain keyless account itself faces the same fate for fund movement; CA recovery inherits that. The doc should call this out explicitly as the keyless-specific tail risk in §[Keyless accounts](#keyless-accounts) / Recovery, parallel to mnemonic loss for software backings — and Motion Wallet UX should make it obvious before the user sinks meaningful confidential balances into a keyless-only account. | +| 3 | **Ephemeral-key expiry mid-proof** | A `confidential_transfer` requires a sigma proof and two range proofs; in-browser construction takes seconds. If the keyless ephemeral key expires between the wallet starting proof construction and the wallet attempting to submit, the user faces a re-authentication round-trip. | The proof itself binds to `senderAddress` via Fiat–Shamir, not to the ephemeral key, so the proof survives re-auth and can be wrapped in a freshly-signed transaction. Open: does the wallet (a) silently trigger keyless re-auth and re-sign the existing proof, or (b) surface a dedicated error and ask the user to retry from scratch? Affects perceived reliability for sessions held open near the ephemeral-key expiry boundary. | From 222abc3ffb1a95a183a5ea96293141d53b1100d9 Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Wed, 13 May 2026 15:56:13 -0500 Subject: [PATCH 4/5] delete extra file --- confidential-assets/MOTION_WALLET_ROLLOUT.md | 138 ------------------- 1 file changed, 138 deletions(-) delete mode 100644 confidential-assets/MOTION_WALLET_ROLLOUT.md diff --git a/confidential-assets/MOTION_WALLET_ROLLOUT.md b/confidential-assets/MOTION_WALLET_ROLLOUT.md deleted file mode 100644 index b391e7e4f..000000000 --- a/confidential-assets/MOTION_WALLET_ROLLOUT.md +++ /dev/null @@ -1,138 +0,0 @@ -# Motion Wallet rollout — confidential assets - -This file tracks the engineering rollout plan for landing confidential-assets support in Motion Wallet. It is **not** part of the design specification — for the design see [`WALLET_INTEGRATION.md`](./WALLET_INTEGRATION.md). When the rollout is complete this file should be deleted. - -## Branch integration plan - -This section captures the concrete plan for combining Motion Wallet's existing keyless branch (`feat/keyless-wallet`) with the existing CA work (`confidential-assets-local`) into a single shippable branch. It is grounded in the actual state of both branches as of this writing, not in inference. - -### State of the two branches - -- **`feat/keyless-wallet`** — head `6bb7a61`. Implements full keyless authentication via `@eigerco/movement-keyless`: OAuth via `chrome.identity.launchWebAuthFlow`, ZK-proof generation via the prover, ephemeral-key refresh on a Chrome alarm. Files: `src/services/wallet/keyless-{auth,session,signer,config}.ts`, plus a `keyless` variant in `WalletEntry`. **The pepper is fetched on every unlock and currently discarded** (`src/services/wallet/account.ts` `initializeFromKeyless` destructures only `{ account }` from `MovementKeyless.completeLoginWithJwt`). -- **`confidential-assets-local`** — head `3019c40`. Implements CA registration / send / balances against the SDK; uses `accountIndex`-keyed software-backed `dk` derivation; assumes mnemonic or private-key vault types and explicitly throws `"Unsupported account type for confidential assets"` for anything else. Includes localnet-specific config and a `docs/CONFIDENTIAL_ASSETS_INTEGRATION_PLAN.md` whose "keyless" row predates pepper exposure and is now stale. -- **Divergence:** `feat/keyless-wallet` is 22 ahead of, 17 behind, the merge-base with `confidential-assets-local`. - -### Recommended strategy - -Cut a new branch from `feat/keyless-wallet`, port CA changes onto it, and drop the localnet-only bits. - -Reasons: - -- The keyless branch is closer to product trunk (it's the active product surface, not an experiment). -- The CA branch's localnet bits are obsolete now that CA is on Movement testnet — porting *forward* lets you delete them rather than carry them. -- Pepper lifecycle is the most invasive piece and already lives on the keyless branch; porting CA *to* keyless is one-directional, while the reverse would re-do this work. - -### Proposed branch name - -`feat/confidential-assets` (cut from `feat/keyless-wallet`). - -### Step-by-step plan - -#### Step 1 — Cut the branch - -``` -git switch feat/keyless-wallet -git pull -git switch -c feat/confidential-assets -``` - -#### Step 2 — Retain the pepper in the unlocked session - -The keyless branch currently discards the pepper immediately after address verification: `MovementKeyless.completeLoginWithJwt` uses `pepper` to recompute the expected address, then the wallet destructures only `{ account }` and lets `pepper` fall out of scope. CA needs the pepper available *for the duration of the unlocked session* (same lifecycle as `cachedSigners` — in memory only, zeroed on wallet lock, never written to disk). The change is to widen the destructure and add `pepper` to the in-memory session struct. - -In `src/services/wallet/account.ts`, change `initializeFromKeyless` to capture `pepper` and stash it in `keylessActiveSession`: - -```ts -// before -const { account } = await keyless.completeLoginWithJwt(jwt, ephemeralKey) -// after -const { account, pepper } = await keyless.completeLoginWithJwt(jwt, ephemeralKey) -// ... -keylessActiveSession = { signer, aud: vaultData.aud, pepper } -``` - -Update the `keylessActiveSession` type: - -```ts -let keylessActiveSession: { - signer: KeylessSigner - aud: string - pepper: Uint8Array // 31 raw bytes; hex-decoded from MovementKeyless result -} | null = null -``` - -The same `pepper` field is also captured by `refreshKeylessSession` (in `src/services/wallet/keyless-session.ts`) when the ephemeral key is refreshed mid-session — the prover round-trip there returns the same pepper, but the session struct should be updated atomically with the new signer to avoid windowed inconsistency. Keep pepper handling colocated with signer handling. - -Zero the pepper on lock alongside `cachedSigners` (the existing lock path already calls `signer.dispose()`; add an explicit `keylessActiveSession.pepper.fill(0)` and set the field to `null`). - -#### Step 3 — Generalize `dk` derivation - -In `src/services/wallet/account.ts`, the existing `getTwistedDecryptionKey(accountIndex, tokenMetadataAddress)` branches on `vaultType`. Add a third branch: - -```ts -if (vaultType === 'keyless') { - if (!keylessActiveSession) throw new Error('Wallet locked') - return keylessDecryptionKey( - keylessActiveSession.pepper, - keylessActiveSession.signer.getAddress(), - tokenMetadataAddress, - ) -} -``` - -`keylessDecryptionKey` is the new SDK helper specified in the design doc (SDK helpers); it runs the canonical HKDF-SHA512 derivation and returns a `TwistedEd25519PrivateKey`. The SDK addition (`TwistedEd25519PrivateKey.fromUniformBytes`, the helper itself) lands in `@moveindustries/confidential-assets` as a prerequisite — a small PR there before any wallet code lands. - -The existing `getEd25519SigningAccount` is a misnomer for the keyless case (a keyless signer is not an `Ed25519Account`). Rename to `getCaSenderAddress(accountIndex)` and return just the `AccountAddress` — the only thing the CA SDK call paths actually need from this function once `dk` is sourced separately. Mnemonic and private-key paths return `signer.accountAddress`; keyless returns `keylessActiveSession.signer.getAddress()`. - -If any CA SDK call still requires a full `Account`-shaped signer (e.g. `ca.registerBalance({ signer, ... })`), use the keyless branch's `KeylessSigner` directly — it already implements the same `Signer` interface that the rest of the wallet uses. The CA SDK's `signer` parameter is structurally typed; verify the keyless `account` from `@eigerco/movement-keyless` is shape-compatible (it is in `KeylessSigner.buildSignSubmit` already, via `signer: this.account! as never`). - -#### Step 4 — Port the CA service module - -Bring over `src/services/wallet/confidential-asset.ts` from `confidential-assets-local`. Adjust: - -- Replace `getEd25519SigningAccount(accountIndex)` calls with `getCaSenderAddress(accountIndex)` for address-only uses (most uses). -- For `ca.registerBalance({ signer, ... })` and similar, source `signer` from the wallet's existing signer factory (the same one keyless and mnemonic both feed into). -- Remove the explicit `'Unsupported account type for confidential assets'` throw in the keyless path — replaced by Step 3's keyless branch. - -#### Step 5 — Port UI hooks and pages - -Bring over `src/popup/hooks/useConfidentialBalances.ts` and any CA UI pages from `confidential-assets-local`. These should be backing-agnostic since they go through the wallet service layer — no keyless-specific changes expected, but smoke-test against a keyless account on testnet. - -#### Step 6 — Drop localnet-only bits - -Audit `confidential-assets-local` for localnet-only changes (network config additions, hardcoded addresses, dev-loop helpers). Drop them; testnet is the new baseline. Likely sites: - -- `src/core/network/config.ts` — drop any localnet `confidentialAssetModuleAddress` overrides if testnet's default is canonical. -- `package.json` — drop any `file:..` overrides that pointed at a localnet-built SDK. - -The existing `docs/CONFIDENTIAL_ASSETS_INTEGRATION_PLAN.md` on the CA branch is stale (its keyless row predates pepper exposure). Either delete it or replace its keyless row with a one-line pointer to `wallet_integration.md` § Keyless accounts — don't carry both forward. - -#### Step 7 — Tests - -- Unit-test `keylessDecryptionKey` against fixed pepper / address / token vectors (so a regression in the SDK is caught upstream of the wallet). -- Unit-test the `account.ts` keyless branch of `getTwistedDecryptionKey`: same pepper + same address + same token = same `dk`; same pepper + different addresses (owner vs multisig) = different `dk`; same pepper + same address + different tokens = different `dk`. -- Integration-test against testnet: register a token on a keyless account, deposit, verify balance decryption, send a confidential transfer, verify the recipient (also keyless) decrypts the right amount. -- Regression-test the existing keyless and mnemonic paths to confirm the `dk`-derivation rename / branch addition didn't break anything. - -#### Step 8 — Wire up the SDK additions - -Concurrent with the wallet work (or just before): land the SDK additions in `@moveindustries/confidential-assets`: - -- `TwistedEd25519PrivateKey.fromUniformBytes(bytes: Uint8Array): TwistedEd25519PrivateKey` — accepts ≥ 32 bytes, reduces mod ℓ. -- `keylessDecryptionKey(pepper: Uint8Array, accountAddress: AccountAddressInput, tokenMetaAddr: AccountAddressInput): TwistedEd25519PrivateKey` — runs the canonical HKDF expansion specified in the design doc. -- Tests asserting fixed input vectors → fixed output `dk` bytes (regression guard for the wallet ↔ chain compatibility contract). - -### Risks and order-of-operations notes - -- **SDK PR must land first.** The wallet branch depends on `keylessDecryptionKey` and `fromUniformBytes`; landing the wallet PR before the SDK PR strands the wallet branch on a broken import. -- **First-time keyless registration is irreversible at the policy level.** Once a keyless account registers `ek[token]` on chain, that key is bound to *this* HKDF policy version (`v1`). A late change to salt, info layout, or hash function before public testnet release is free; after public release it requires `rotate_encryption_key`. So: lock the policy strings (`"movement-ca/v1"`, `"dk:"`, SHA-512, L=64) in code review, not after. -- **Multisig is a separate question.** Motion Wallet's multisig support and CA-on-multisig are independent of this branch merge. Do not block the keyless+CA combination on multisig; treat multisig as a follow-up. -- **`accountIndex` mismatch.** The `confidential-assets-local` branch threads `accountIndex` everywhere. Keyless wallets currently use `accountIndex = 0` only (one keyless account per vault). This works as-is, but if Motion Wallet later supports multiple keyless accounts per identity, the `getCaSenderAddress(accountIndex)` signature is already the right shape. - -### Not implemented by this merge - -The items below are part of the design (see the design doc) but do not ship in this particular PR. They are deferred implementation work, not unresolved design. - -- Multisig CA flows. -- Hardware-backed CA. -- Per-asset auditor UI (separate work stream). From 356e9e5b79cd49ffe34260cc6668f26376019085 Mon Sep 17 00:00:00 2001 From: ganymedio <17599867+ganymedio@users.noreply.github.com> Date: Thu, 14 May 2026 13:28:53 -0500 Subject: [PATCH 5/5] wallet-integration: on-chain dk_inbox for multisig dk sharing Replaces the out-of-band hex-share flow with an encrypted on-chain envelope channel via a new dk_inbox Move module. Folds Recovery from a shared dk leak in as a subsection of DK sharing among co-owners and updates the algorithm-choice table to reflect on-chain delivery and the retroactive-disclosure tradeoff. --- confidential-assets/WALLET_INTEGRATION.md | 275 ++++++++++++++++++---- 1 file changed, 223 insertions(+), 52 deletions(-) diff --git a/confidential-assets/WALLET_INTEGRATION.md b/confidential-assets/WALLET_INTEGRATION.md index ea0e8ffb7..64fae03fc 100644 --- a/confidential-assets/WALLET_INTEGRATION.md +++ b/confidential-assets/WALLET_INTEGRATION.md @@ -299,47 +299,39 @@ type WalletEntry = }; ``` -`ownedByWalletIds` is an array, not a single id, so a user with two device-local Ed25519 wallets that are both on-chain owners of the same multisig has one multisig entry, not two — and the imported `dk` set is not duplicated across owner blobs. When a multisig proposal needs an owner signature for approval, the popup picks among the listed local wallets; if `ownedByWalletIds` is empty, the entry is a read-only view (balances visible if `dk` entries are present, but no approvals possible from this device). +`ownedByWalletIds` is an array, not a single id, so a user with two device-local Ed25519 wallets that are both on-chain owners of the same multisig has one multisig entry, not two — and the imported `dk` set is not duplicated across owner blobs. Approval of multisig proposals is signed by one of the local Ed25519 wallets referenced here (the user picks at sign time, or the wallet picks the first if there's only one). If `ownedByWalletIds` is empty, the entry is a read-only view: balances are visible when the corresponding `dk` entries are present, but no approvals can originate from this device. + +A multisig entry is **not switchable as a signing account**. Multisig has no private key, so the wallet refuses `switchWallet(multisigId)`; selecting a multisig entry in the switcher instead opens its dk-management surface (derive / import / export per token). The dApp-visible connected account remains the user's Ed25519 wallet throughout, because every fund-moving operation — `multisig_account::create_transaction`, `approve_transaction`, `execute_transaction` — is signed by the owner's Ed25519 key with the multisig address passed as a function argument. The wallet identifies the target multisig for `ca_*` `buildOnly` calls by matching the dApp-supplied `sender` parameter against `address` on a multisig entry; the entry does not need to be "active" in any sense. Removing the last local Ed25519 wallet referenced by `ownedByWalletIds` does not delete the multisig entry — its imported `dk`s remain available for balance decryption — but the entry is marked view-only in the UI. +The wallet populates `owners` and `threshold` automatically by reading `0x1::multisig_account::MultisigAccount` from chain when the user adds a multisig (the user pastes only the address). The wallet also matches the on-chain owners list against the active Ed25519 wallet's primary address to seed `ownedByWalletIds`; inactive wallets are not auto-matched (their addresses can't be derived without unlocking them), so the user must edit the entry later if additional local Ed25519 wallets also co-own the multisig. + #### Storage location -There is one `dk` store per wallet entry, keyed by entry id: +`dk` material is persisted as **one `chrome.storage.local` entry per `(wallet entry, token)` pair**, keyed by the parent entry id and the token's lowercase 32-byte metadata address (no `0x` prefix): ``` -mv_dk_store:${walletEntryId} +mv_dk_store:${walletEntryId}:${tokenAddrLowerHex} ``` For mnemonic and private-key entries this stores any imported `dk`s for that account (rare but supported — e.g. a cross-device shared `dk` for a single-owner account). For multisig entries it stores the imported `dk[multisig, token]` set that gives the local user the ability to decrypt balances and propose transfers for that multisig. -This per-entry keying contains corruption blast radius, lets a wallet-delete remove one storage key, and — critically for the multisig case — keeps each `dk` material in a single canonical location regardless of how many device-local Ed25519 wallets co-own the multisig. +Per-(entry, token) keying gives each `dk` its own AAD-bound ciphertext at rest, makes a single-token import or revocation a single-key operation, and means the AAD binding below is enforced on the storage key itself (not on a sub-field of a larger JSON envelope). Multiple device-local Ed25519 wallets that co-own the same multisig still share *one* multisig entry, so the dk set isn't duplicated. #### Persisted shape -```ts -type DkStoreV1 = { - version: 1; - walletEntryId: string; - entries: Record; -}; - -// The lookup key inside a store is just the token's metadata address; the -// account address is implicit in the parent store (it's the wallet entry's address). -// 0x-prefixed, lowercase, 32-byte hex. -type DkEntryKey = string; +Each `mv_dk_store:${entryId}:${tokenHex}` value is the AES-GCM ciphertext of the raw 32-byte `dk` scalar (no JSON envelope, no version field in the plaintext — the version tag lives in AAD). The wire format reuses Motion Wallet's standard `EncryptedValue` shape from `core/storage/encrypted-storage.ts`: -type EncryptedDkEntry = { - source: 'imported'; // see "What is persisted" below - tokenMetaAddr: string; - ciphertext: string; // base64 AES-GCM ciphertext + tag +```ts +type EncryptedValue = { + ciphertext: string; // base64 AES-GCM ciphertext + tag, sealing 32 raw dk bytes iv: string; // base64, 12 bytes, fresh per entry - label?: string; // token symbol/name at import; UI only - importedAt: number; // unix ms + v: 2; // storage-encoding version (v=2 indicates raw-bytes payload with caller-supplied AAD) }; ``` -The outer `DkStoreV1` envelope is a plain JSON blob in `chrome.storage.local`. Per-entry AES-GCM provides the per-entry isolation; the envelope is not double-encrypted. The account address is not stored on individual entries because it is implied by the parent store's `walletEntryId` — see the AAD binding below for how this is enforced cryptographically. +No `entries` map, no label, no `source` — listing tokens for an entry is done by enumerating `chrome.storage.local` keys with the `mv_dk_store:${entryId}:` prefix. UI-only labels (token symbol etc.) are looked up at render time from the on-chain token registry, not stored alongside the `dk`. #### What is persisted @@ -366,7 +358,7 @@ The mnemonic-vault key is *not* reused: that key conceptually unlocks the seed, Per-entry isolation is enforced cryptographically through AES-GCM additional authenticated data. The AAD is reconstructed at decrypt time from the loader's arguments, never read back from storage: ``` -AAD = utf8("mv-dk-v1") || 0x00 || accountAddress || tokenMetaAddr +AAD = utf8("mv-dk-v1") || accountAddress || tokenMetaAddr ``` Where `accountAddress` is the parent wallet entry's address (the multisig address, or the Ed25519 account address) and `tokenMetaAddr` is the FA metadata address — each the raw 32 bytes (not hex). Binding the address into the AAD makes a ciphertext physically unmovable between stores: even though the entry doesn't carry the address as a plaintext field, AES-GCM tag verification fails on any cross-store substitution. The version tag in AAD also makes future schema changes (e.g. `mv-dk-v2`) unforgeable from v1 ciphertexts. @@ -385,7 +377,7 @@ The wallet first resolves `accountAddress` to a `WalletEntry` (Ed25519 or multis 2. For mnemonic-backed entries whose `accountAddress` matches a known mnemonic-derived account: derive via `fromDerivationPath("m/44'/637'/{accountIndex}'/1'/{tokenIndex}'", mnemonic)`, populate cache, return. 3. For hardware-backed entries whose `accountAddress` matches a known device account: derive via `fromSignature(device.sign(decryptionKeyDerivationMessage ‖ ":" ‖ hex(tokenMetaAddr)))`, populate cache, return. 4. For keyless-backed entries whose `accountAddress` matches a known keyless account: derive via `fromUniformBytes(HKDF-SHA512(pepper, salt = utf8("movement-ca/v1"), info = utf8("dk:") || accountAddress || tokenMetaAddr, L = 64))`, populate cache, return. -5. Imported entry in `mv_dk_store:${walletEntry.id}.entries[tokenMetaAddr]`: AES-GCM-decrypt with AAD as defined above (using the parent entry's `accountAddress`); return. +5. Imported entry at `mv_dk_store:${walletEntry.id}:${tokenAddrLowerHex}`: AES-GCM-decrypt with AAD as defined above (using the parent entry's `accountAddress`); return. 6. Otherwise, throw — the loader does not fall back to "any `dk` for this account." Multisig entries skip steps 2, 3, and 4: there is no mnemonic, device, or pepper that can derive a multisig's `dk` (a multisig has no private key), so step 5 is the only path that can succeed for them. If no imported entry exists for `(multisigAddress, tokenMetaAddr)`, the loader throws — the user has not yet imported the shared `dk` for that asset, and the UI should prompt for import or surface the asset as view-only-without-key. @@ -394,13 +386,26 @@ Proof-construction routines accept exactly one `dk` and one `tokenMetaAddr` and #### Migration -Schema v1 is additive: on unlock, if `mv_dk_store:${walletEntryId}` is absent, the wallet treats it as `{ version: 1, walletEntryId, entries: {} }` and lazy-creates on first import. There is no v0 to migrate. A `dkSchemaVersion` field will be introduced only when a v2 forces it. +Schema v1 is additive: on unlock, the absence of `mv_dk_store:${entryId}:${tokenHex}` for a given token is treated as "no imported `dk` for that pair yet"; the loader proceeds to derive natively or throws (for multisig entries). There is no v0 to migrate. A future v2 would be signaled by changing the AAD version tag (`utf8("mv-dk-v2")`); existing v1 ciphertexts would fail tag verification under v2 AAD by construction. -When a multisig wallet entry is created (whether by importing an existing on-chain multisig or by completing a creation flow), the wallet creates an empty `mv_dk_store:${entryId}` and prompts the user to import each registered token's `dk` separately, per [DK sharing among co-owners](#dk-sharing-among-co-owners). +When a multisig wallet entry is created (whether by importing an existing on-chain multisig or by completing a creation flow), no `mv_dk_store:` keys are written until the user imports or derives the first per-token `dk`, per [DK sharing among co-owners](#dk-sharing-among-co-owners). #### Whole-wallet export -When the wallet exposes a "back up wallet" flow that already includes the encrypted mnemonic vault, it includes every `mv_dk_store:${walletEntryId}` blob alongside it — both for Ed25519 entries and for multisig entries. The blobs are already sealed under the runtime second-layer key and are useless without the password; excluding them would silently lose imported multisig material that mnemonic recovery cannot reproduce. The export-flow copy must state that the backup includes imported decryption keys. +When the wallet exposes a "back up wallet" flow that already includes the encrypted mnemonic vault, it includes every `mv_dk_store:*` ciphertext alongside it — both for Ed25519 entries and for multisig entries — plus `mv_storage_salt` so the receiving device can re-derive the runtime second-layer key from the same password. The blobs are sealed under that key and are useless without it; excluding them would silently lose imported multisig material that mnemonic recovery cannot reproduce. Motion Wallet's `EXPORT_BACKUP` handler emits a JSON blob with this shape: + +```json +{ + "version": 1, + "exportedAt": 1715000000000, + "wallets": [ /* WalletEntry[] (each vault is already AES-GCM-sealed) */ ], + "activeWalletId": "…", + "dkBlobs": { "mv_dk_store::": { /* EncryptedValue */ } }, + "storageSalt": "" +} +``` + +The export-flow copy must state that the backup includes imported decryption keys. --- @@ -616,7 +621,11 @@ The same raw pepper drives both the keyless account's on-chain address derivatio A multisig account is a resource account: it holds funds but has no private key, so a multisig account cannot run `fromDerivationPath` itself. Proofs for multisig confidential-asset operations must bind to the multisig account's address — the SDK's Fiat–Shamir transcript includes `senderAddress` (see `src/crypto/fiatShamir.ts`), and proofs built against any other address abort on chain. -Motion Wallet represents a multisig as a first-class wallet entry (`WalletEntry.kind = 'multisig'`, see [Motion Wallet keystore schema / Wallet entries](#wallet-entries)). The popup's wallet switcher lists multisig entries alongside Ed25519 entries, the Home page shows the multisig's confidential balances when the corresponding `dk` entries have been imported, and pending multisig proposals surface in the wallet's approval flow the same way dApp-originated approvals do. The dApp (`gmove-multisig`) remains the place where users *manage* a multisig (create it, change owners, etc.); the wallet is where they *use* it. Surfacing the multisig as a wallet entry — rather than only as a dApp concept — is what lets a user open the wallet popup and immediately see the assets and pending actions that affect them, without first navigating to a specific dApp. +Motion Wallet represents a multisig as a first-class wallet entry (`WalletEntry.kind = 'multisig'`, see [Motion Wallet keystore schema / Wallet entries](#wallet-entries)). A multisig entry is a **bookmark**, not a switchable signing account: it has no private key, and the dApp-visible connected account always remains the user's Ed25519 wallet. Selecting a multisig entry in the popup's wallet switcher opens its decryption-key management surface (derive / import / export per token, view confidential balances); it does **not** change `getAccount()` for dApps, because every fund-moving call — proposing, approving, executing — is signed by the owner's Ed25519 key against `0x1::multisig_account::*` with the multisig address as a function argument, not as the transaction sender. The wallet still receives multisig CA build requests through `ca_*` with `sender: , mode: "buildOnly"`; it looks up the multisig entry by `sender` address and is not gated on the multisig being the active wallet. + +The dApp (`gmove-multisig`) remains the place where users *manage* a multisig (create it, change owners, etc.); the wallet is where they *use* it. Surfacing the multisig as a wallet entry — rather than only as a dApp concept — is what lets a user open the wallet popup and immediately see the assets and pending actions that affect them, without first navigating to a specific dApp. + +When a multisig is added in the popup, the wallet reads `0x1::multisig_account::MultisigAccount` from chain to populate the entry's `owners` and `threshold` automatically; the user pastes only the multisig address. The wallet also matches the owners list against the user's *active* Ed25519 wallet to set `ownedByWalletIds`. Inactive Ed25519 wallets are not auto-matched, since deriving their addresses would require prompting for a password; the user can associate them later by editing the entry. ### Data ownership @@ -674,35 +683,197 @@ With these parameters in place, the dApp constructs no proofs and holds no `dk`. ### DK sharing among co-owners -A `dk` is a 32-byte Ristretto scalar with no address embedded in it. The cryptographic linkage between a `dk` and a multisig vault is established at registration: the wallet computes `ek = dk.publicKey()` and a multisig proposal writes `ek` into the on-chain `(multisigAddress, tokenMetaAddr)` slot. From that point forward, proofs verify against `ek` and the Fiat–Shamir transcript binds them to `senderAddress = multisigAddress` (see [Multisig accounts](#multisig-accounts)). The chain does not know how `dk` was produced, only that the registered `ek` is the public counterpart it accepts. +A `dk` is a 32-byte Ristretto scalar with no address embedded in it. The cryptographic linkage between a `dk` and a multisig vault is established at registration: the wallet computes `ek = dk.publicKey()` and a multisig proposal writes `ek` into the on-chain `(multisigAddress, tokenMetaAddr)` slot. From that point forward, proofs verify against `ek` and the Fiat–Shamir transcript binds them to `senderAddress = multisigAddress`. The chain does not know how `dk` was produced, only that the registered `ek` is the public counterpart it accepts. + +Off-chain *derivation* exists so the originating owner can reproduce those 32 bytes deterministically from their root key material after a wallet restore, and so a single owner who is a designated proposer for multiple multisigs derives a distinct `dk` per vault rather than colliding. The remainder of this section specifies (a) the per-backing derivation rules for the proposer, (b) the on-chain delivery channel by which the proposer transmits `dk[multisig, token]` to co-owners, (c) the envelope format used on that channel, (d) the share, owner-change, and discovery flows, and (e) the threat model and leak-recovery procedure. -Off-chain *derivation* exists so the originating owner can reproduce those 32 bytes deterministically from their root key material after a wallet restore, and so a single owner who is a designated proposer for multiple multisigs derives a distinct `dk` per vault rather than colliding. The per-backing rules for multisig-proposer derivation are: +#### Derivation: per-backing rules for the proposer - **Software backings (mnemonic):** `dk[multisig, token] = TwistedEd25519PrivateKey.fromDerivationPath("m/44'/637'/{multisigAccountIndex}'/1'/{tokenIndex}'", mnemonic)`, where `multisigAccountIndex = u32_le(SHA-256(multisigAddress)[0..4]) & 0x7FFFFFFF`. The reduction mirrors the `tokenIndex` formula in [Path layout for software backings](#path-layout-for-software-backings). Collision probability with the proposer's own personal account indices is ~1/2³¹ per multisig registration; on collision the proposer must rotate via `rotate_encryption_key`. The reduction is fixed: a different formula yields a different `dk` and orphans the registration. - **Hardware backings (device signature):** `message[multisig, token] = decryptionKeyDerivationMessage ‖ ":" ‖ hex(multisigAddress) ‖ ":" ‖ hex(tokenMetadataAddress)`, then `dk[multisig, token] = TwistedEd25519PrivateKey.fromSignature(device.sign(message))`. This is a multisig-proposer-specific layout that prepends `hex(multisigAddress)` to the single-owner hardware layout in [Signed-message layout for hardware backings](#signed-message-layout-for-hardware-backings); the single-owner layout is unchanged, so existing non-multisig hardware-backed registrations are not affected. - **Keyless backings (pepper):** `dk[multisig, token] = keylessDecryptionKey(pepper, multisigAddress, tokenMetadataAddress)`, with `multisigAddress` substituted into the `accountAddress` slot of the HKDF `info` field defined in [HKDF layout for keyless backings](#hkdf-layout-for-keyless-backings). No layout change needed — the keyless layout already binds `accountAddress`. -In all three cases the derivation is parameterised by the multisig account's address and the specific token's metadata address, and no other co-owner can reproduce it from their own wallet alone (every backing's root material is per-owner private). Multi-owner confidential-asset custody therefore requires sharing each registered asset's `dk` separately: +In all three cases the derivation is parameterised by the multisig account's address and the specific token's metadata address, and no other co-owner can reproduce it from their own wallet alone (every backing's root material is per-owner private). Multi-owner confidential-asset custody therefore requires sharing each registered asset's `dk` separately, through the on-chain channel specified below. + +#### On-chain delivery via the `dk_inbox` Move module + +Sharing happens through a dedicated Move module — `dk_inbox`, living alongside the other confidential-asset modules at `aptos-experimental/sources/confidential_asset/dk_inbox.move`. The module's only job is authenticated, recipient-addressed event emission: the proposer's wallet encrypts `dk[multisig, token]` to each co-owner's public key, packs the envelopes into one transaction, and submits a single `share_dk` call that emits one event per recipient. The chain provides durable, ordered delivery; the wallet does all the cryptography. + +The module exposes one entry function and one event: + +```move +module aptos_experimental::dk_inbox { + use std::vector; + use std::signer; + use aptos_framework::event; + use aptos_framework::timestamp; + use aptos_framework::multisig_account; + + const E_NOT_OWNER: u64 = 1; + const E_RECIPIENT_NOT_OWNER: u64 = 2; + const E_LENGTH_MISMATCH: u64 = 3; + const E_EMPTY: u64 = 4; + const E_ENVELOPE_TOO_LARGE: u64 = 5; + + const MAX_ENVELOPE_BYTES: u64 = 1024; + + #[event] + struct DkShared has drop, store { + sender: address, // owner who posted (signer of this tx) + multisig: address, + token: address, // FA metadata address + recipient: address, // owner this envelope is for + envelope: vector, // opaque ciphertext; format defined by the SDK + timestamp_us: u64, + } + + public entry fun share_dk( + sender: &signer, + multisig: address, + token: address, + recipients: vector
, + envelopes: vector>, + ) { + let sender_addr = signer::address_of(sender); + assert!(multisig_account::is_owner(multisig, sender_addr), E_NOT_OWNER); + + let n = vector::length(&recipients); + assert!(n > 0, E_EMPTY); + assert!(n == vector::length(&envelopes), E_LENGTH_MISMATCH); + + let now = timestamp::now_microseconds(); + let i = 0; + while (i < n) { + let recipient = *vector::borrow(&recipients, i); + let envelope = *vector::borrow(&envelopes, i); + assert!(multisig_account::is_owner(multisig, recipient), E_RECIPIENT_NOT_OWNER); + assert!(vector::length(&envelope) <= MAX_ENVELOPE_BYTES, E_ENVELOPE_TOO_LARGE); + event::emit(DkShared { + sender: sender_addr, + multisig, + token, + recipient, + envelope, + timestamp_us: now, + }); + i = i + 1; + }; + } +} +``` + +The module is deliberately minimal: no curve operations, no proof verification, no resource state. It validates that the sender is a current owner of the referenced multisig, that every recipient is also a current owner, that the recipient and envelope vectors are parallel and non-empty, and that each envelope is below a small byte ceiling — then emits one event per recipient. The format of the envelope bytes is an SDK concern; the chain treats it as opaque. A matching `dk_inbox.spec.move` follows the same pattern as the other modules in the directory. + +#### Envelope format + +Each `envelope` is an authenticated X25519 + AES-GCM ciphertext bound to a single `(multisig, token, sender, recipient)` tuple. The format is version-tagged so a future migration can run alongside without breaking existing imports. + +``` +envelope_v1 := + "mv-dk-enc-v1" // 12-byte ASCII version tag, also included in AAD + ‖ ephemeralX25519Pub // 32 bytes — proposer's per-share ephemeral X25519 public key + ‖ nonce // 12 bytes — AES-GCM nonce, all-zero (safe: the ephemeral key is unique per envelope) + ‖ ciphertextWithTag // 48 bytes = 32-byte dk + 16-byte GCM tag + +AAD := utf8("mv-dk-enc-v1") + ‖ multisigAddress (32 raw bytes) + ‖ tokenMetaAddress (32 raw bytes) + ‖ senderOwnerAddress (32 raw bytes) + ‖ recipientOwnerAddress (32 raw bytes) +``` + +Key derivation: -1. For a given token `T`, one designated owner derives `dk[multisig, T]` normally in their wallet, with the multisig account's address as the binding identity. -2. The same owner registers the corresponding `ek[multisig, T]` against the multisig account's address on chain, by submitting a multisig proposal that invokes `register` for token `T`. -3. The same owner exports the 32-byte `dk[multisig, T]` hex from their wallet UI — using the per-asset export flow described in [Storage and export](#storage-and-export) — and transmits it to co-owners over a secure out-of-band channel (for example, a shared password manager). The exported entry must be labeled with the token. -4. Each co-owner imports the hex into their wallet, scoped to `(multisig, T)`. +- The recipient's long-term X25519 public key is computed from their Ed25519 owner public key via the standard birational map; the Ed25519 pubkey is read from the recipient's on-chain account resource. An owner whose pubkey is not yet on chain (account never transacted) cannot receive shares until they transact at least once — see [Initial share flow](#initial-share-flow) for the partial-share fallback. +- The proposer generates a fresh X25519 keypair per envelope. `sharedSecret = X25519(ephemeralPriv, recipientX25519Pub)`. +- `aesKey = HKDF-SHA256(sharedSecret, salt = empty, info = utf8("mv-dk-share-v1") ‖ multisigAddress ‖ tokenMetaAddress ‖ senderOwnerAddress ‖ recipientOwnerAddress, L = 32)`. +- AES-GCM-256 seals the 32-byte `dk` under `aesKey` with the AAD shown above and the all-zero nonce. -This procedure is repeated once per token the multisig registers. Co-owners hold one imported keystore entry per shared asset, not a single shared per-account secret. After import, every co-owner's wallet can construct proofs for multisig confidential-asset operations on that asset. A co-owner who has not imported a given token's `dk` cannot propose transfers of that token; they may still approve such transfers, because approval requires only their Ed25519 signing key. +The AAD binds the envelope to the exact `(multisig, token, sender, recipient)` tuple announced on chain. The recipient's wallet must verify that the on-chain `DkShared` event's `multisig`, `token`, `sender`, and `recipient` fields exactly match the AAD it uses to decrypt; a mismatch is a hard refuse. -The export and import procedure uses the same user-initiated export flow described in [Storage and export](#storage-and-export), applied per token. It places a copy of `dk[multisig, T]` outside the originating wallet, with the security implications stated below. Sharing is constrained as follows: +#### Initial share flow -- Each export and each import is gated by an explicit, user-initiated wallet UI action with a clear warning and a typed confirmation. -- No dApp-callable export or import method is exposed. There is no `ca_exportDk` and no `ca_importDk`. A dApp cannot request `dk` bytes; only the user can. +For each token the multisig registers: + +1. One designated owner derives `dk[multisig, token]` normally in their wallet (per the per-backing rules above). +2. The same owner registers `ek[multisig, token]` against the multisig address on chain, by submitting a multisig proposal that invokes `register` for the token. +3. After the registration proposal executes, the same owner's wallet builds the share transaction: + - Reads the current owner list from `0x1::multisig_account::MultisigAccount`. + - For each co-owner (excluding the proposer), reads their Ed25519 public key from their account resource; converts it to X25519; constructs an envelope as specified above. + - Submits one `dk_inbox::share_dk` call carrying the full `recipients` and `envelopes` vectors. + +This is a single user-confirmation popup, a single Ed25519 signature, and a single gas payment. The N recipients are hidden in the vector. The Move module emits N per-recipient events in the same transaction. + +If any co-owner has never transacted on chain — and therefore has no recoverable Ed25519 public key — the proposer's wallet omits them from the share and surfaces a pending state: "Owner 0xefgh has not transacted yet; re-share when ready." After that owner first transacts, the proposer (or any current `dk` holder) re-runs the share for the missing recipient with a length-1 `recipients` vector. + +A co-owner who has not received a `dk` cannot propose transfers of that token; they may still approve such transfers, because approval requires only their Ed25519 signing key. + +#### Owner additions and removals + +When the multisig's owner set changes on chain, the wallet keeps the share state consistent without protocol changes: + +- **Owner added.** The dk-management screen for the multisig diffs the on-chain owner set against the set of addresses for which `DkShared` events exist (per token). Missing recipients appear as a "Share USDC dk with new owner 0xefgh…" action per token. Each action is one-click and runs `share_dk` with the new owner's address as a length-1 `recipients` vector; the new owner ends up with the same `dk` bytes as everyone else. The action is gated by an explicit confirmation surfacing the new owner's address — the wallet never auto-shares without user approval, since a maliciously added owner is a real threat. +- **Owner removed.** A removed owner retains the `dk` bytes locally (the chain cannot reach into their wallet), so they retain decryption capability for every token whose `dk` they held — both past balance state and any future state encrypted under the same `ek`. Funds remain safe because moving funds requires k-of-n owner approvals on the multisig, and a `dk` alone cannot produce them. To restore privacy for affected tokens, the remaining owners rotate the encryption key per affected token using the recovery procedure in [Recovery from a shared `dk` leak](#recovery-from-a-shared-dk-leak). The wallet detects the owner change on chain and surfaces an automatic banner listing the tokens that need rotation. + +The wallet must also warn the user when proposing to remove the last current holder of a token's `dk`: rotation itself requires the current `dk` to construct its proofs, so removing the only holder before re-sharing would render the affected token unrecoverable. + +#### Discovery: user-initiated check + +The wallet does not poll the chain for new shares on a background timer. Instead, each multisig entry's dk-management surface exposes an explicit **"Check for shared dks"** action. Clicking it runs a single indexer query scoped to the multisig address and the user's owner address(es): + +```graphql +query InboxForOwner($multisig: jsonb!, $recipient: jsonb!) { + events( + where: { + indexed_type: { _eq: "0x...::dk_inbox::DkShared" } + _and: [ + { data: { _contains: $multisig } } # { "multisig": "0x1234..." } + { data: { _contains: $recipient } } # { "recipient": "0xowner..." } + ] + } + order_by: { transaction_version: desc } + limit: 100 + ) { + transaction_version + event_index + data + } +} +``` + +The wallet filters out events whose `(multisig, token)` already has an imported `dk` in `mv_dk_store`, decrypts the remaining envelopes, and presents an import confirmation per result. The user accepts or skips each. Accepted imports write to `mv_dk_store:${multisigEntryId}:${tokenHex}` exactly as the existing import path does. The dedupe key for "already seen" is `(transaction_version, event_index)`. + +When a multisig is first added as a wallet entry and the chain shows registered confidential assets for which the user has no local `dk`, the dk-management surface displays a hint badge on the "Check for shared dks" button. This is a visual nudge only; the wallet does not auto-run the query. + +If the indexer is unavailable, the wallet falls back to the fullnode REST `GET /v1/events/by_type/...` endpoint and filters client-side. This is degraded mode; checks scale with global share volume rather than this user's inbox. + +#### Import dialog + +The import confirmation dialog is rendered in the wallet-popup chrome — the same trust surface as the transaction signing dialog — and never as web content. It states the source owner address, the multisig address, the token, and a typed-confirmation gate matching the asset name. Accepting writes the imported `dk` to the encrypted keystore under the AAD-bound storage key (see [AAD binding](#aad-binding)); the envelope's chain copy is unaffected. + +The dialog accepts two formats: + +- `mv-dk-enc-v1:` envelopes fetched from the inbox (the path described above) — the primary flow. +- `mv-dk-v1:` raw hex strings that may already exist in users' password managers from earlier flows or out-of-band transmission — a legacy fallback, retained so users mid-migration can still import. Raw hex import remains gated by master-password re-prompt and typed asset-name confirmation. New shares should not use raw hex. #### Threat model -If a shared `dk[multisig, T]` hex is disclosed (for example, through a compromised password manager or a screenshot), the multisig account's privacy for token `T` is lost: the attacker can decrypt the multisig's confidential balance for `T` and observe transfer amounts denominated in `T`. Privacy of every other registered asset is preserved, because each `dk[multisig, T']` is an independent scalar derived along a different BIP-32 path (software backing), from a different signed message (hardware backing), or under a different HKDF `info` field (keyless backing). The multisig account's funds remain safe in all cases: moving funds requires k-of-n owner approvals on the multisig proposal, which a `dk` alone cannot produce. Each shared 32-byte hex is a token-specific derivation of the originating owner's root key material — a hardened BIP-32 child of the mnemonic, a `fromSignature` reduction of a token-specific device signature, or an HKDF expansion under a token-specific `info` field of the keyless pepper. Disclosure of any single hex does not reveal the originating root secret, any parent key, or any other asset's `dk`. +The on-chain `dk_inbox` design accepts one durable cost that an out-of-band-only design does not have: + +**Retroactive disclosure via chain history.** Every envelope ever shared sits on a public, permanent chain. If a recipient's owner key is later compromised — at any future point — the attacker recovers the long-term X25519 private key (derived from the Ed25519 owner key) and decrypts every envelope ever addressed to that owner from the chain's transaction history alone. Likewise, if X25519 or AES-GCM is meaningfully weakened by future cryptanalysis, the same retroactive recovery follows without any key compromise. `rotate_encryption_key` re-encrypts only future balance state, so a retroactively recovered pre-rotation `dk` still decrypts every pre-rotation balance ciphertext stored on chain. This property is inherent to using public chain calldata as the delivery channel; it cannot be mitigated after-the-fact by deleting state, since the envelope bytes live in finalized transaction history that every full node and indexer retains. Owner key rotation hygiene is the only ongoing user-side mitigation; a post-quantum KEM for the envelope is the credible long-term mitigation, and the version tag on the envelope is the migration affordance. + +Other concerns are handled by the construction and have no residual cost: + +- *Per-token blast radius.* If a shared `dk[multisig, T]` is disclosed by any path, the multisig's privacy for token `T` is lost: the attacker can decrypt the multisig's confidential balance for `T` and observe transfer amounts denominated in `T`. Privacy of every other registered asset is preserved, because each `dk[multisig, T']` is an independent scalar derived along a different derivation path. The multisig's funds remain safe in all cases: moving funds requires k-of-n owner approvals on the multisig proposal, which a `dk` alone cannot produce. +- *Cross-context misuse.* Domain separation in HKDF `info` and AAD binding pin each envelope to its exact `(multisig, token, sender, recipient)` tuple. A recipient wallet rejects any envelope whose AAD does not match the announced on-chain event fields. +- *Replay across rotations.* After `rotate_encryption_key`, old `DkShared` events for the previous `dk` remain on chain and decrypt to the old (now invalid) 32 bytes. The wallet picks the newest envelope per `(multisig, token, recipient)` for display, and the `confidential_balance` load path validates the imported `dk` against the on-chain `ek` on first use; a stale import fails closed. +- *Sender impersonation.* The recipient wallet trusts only the on-chain `DkShared.sender` field (the actual signer of the transaction). Envelope contents are 32 bytes of `dk` with no metadata that the wallet authenticates against. +- *Inbox spam.* `share_dk` requires the sender to be a current owner of the referenced multisig; non-owners cannot post envelopes addressed to anyone. The per-envelope `MAX_ENVELOPE_BYTES` ceiling and standard gas pricing bound the cost a malicious owner could impose on co-owners. +- *Metadata visible on chain.* `DkShared`'s public fields are `sender`, `recipient`, `multisig`, `token`, and `timestamp_us`. Each of these is already essentially derivable from existing chain state — multisig membership is public, `register` events name the token, and CA proposal authorship reveals which owners hold which `dk`s in practice. The inbox therefore does not meaningfully expand the multisig's metadata footprint beyond what is already observable. -### Recovery from a shared `dk` leak +#### Recovery from a shared `dk` leak -If a shared `dk[multisig, token]` hex is disclosed (for example, through a compromised password manager or a screenshot of an import dialog), the recovery path is to rotate to a fresh `dk'` / `ek'` pair against the same multisig address, scoped to that single asset. Funds do not move; only the encryption key registered against the multisig for that asset changes. +If a shared `dk[multisig, token]` is suspected to have leaked — through a recipient key compromise, an envelope retroactively decrypted from chain history, an exported hex in a password manager, or any other path — the recovery path is to rotate to a fresh `dk'` / `ek'` pair against the same multisig address, scoped to that single asset. Funds do not move; only the encryption key registered against the multisig for that asset changes. Two distinct layers govern this procedure: @@ -714,9 +885,9 @@ Rotation procedure (executed outside the wallet UI): 1. One owner generates a fresh `dk'` and computes `ek' = dk'.publicKey()`. 2. The same owner uses `@moveindustries/confidential-assets` (`ConfidentialAsset` / `ConfidentialAssetTransactionBuilder.rotateEncryptionKey`) to build a `rotate_encryption_key` entry function bound to the multisig account's address for the affected token. The builder requires the current `dk[multisig, token]` (still held by the proposer) and the new `dk'`; it emits the sigma and range proofs that re-encrypt the on-chain balance from `ek[multisig, token]` to `ek'`. 3. The entry-function bytes are wrapped in a `MultiSigTransactionPayload` and proposed via `multisig_account::create_transaction`. Co-owners approve with their Ed25519 keys; once k-of-n approvals are reached, any owner may execute. -4. After execution, `ek'` is the registered key for `(multisig, token)` and the previous `dk[multisig, token]` no longer matches. The proposer exports `dk'` and redistributes it to co-owners over the same out-of-band channel used at initial setup. +4. After execution, `ek'` is the registered key for `(multisig, token)` and the previous `dk[multisig, token]` no longer matches. The proposer runs `dk_inbox::share_dk` once more, sealing `dk'` to the current owner set (the same flow as initial sharing). -After rotation, the previous `dk[multisig, token]` no longer matches the registered `ek` for that asset; ciphertexts the attacker observed and decrypted prior to rotation remain decryptable to them. If the disclosure includes the originating owner's mnemonic rather than only an exported `dk` hex, in-place rotation is insufficient and the recovery path is to move funds to a fresh multisig with fresh owner keys (see the threat-model note in [Key rotation](#key-rotation-not-wallet-supported)). Motion Wallet exposes no `ca_rotateEncryptionKey` method and no rotation UI; the procedure runs through the SDK in a trusted environment and then through the standard multisig proposal UI. +After rotation, the previous `dk[multisig, token]` no longer matches the registered `ek` for that asset; ciphertexts the attacker observed and decrypted prior to rotation remain decryptable to them, and any old `DkShared` envelopes on chain are also still decryptable but yield the now-invalid `dk`. If the disclosure includes the originating owner's mnemonic rather than only a `dk` hex, in-place rotation is insufficient and the recovery path is to move funds to a fresh multisig with fresh owner keys (see the threat-model note in [Key rotation](#key-rotation-not-wallet-supported)). Motion Wallet exposes no `ca_rotateEncryptionKey` method and no rotation UI; the procedure runs through the SDK in a trusted environment and then through the standard multisig proposal UI. ### Treasury-scale balances @@ -728,12 +899,12 @@ Multi-owner confidential-asset custody admits several constructions, which are n | Approach | Material per owner | Proof construction | Privacy under one-owner wallet compromise | Funds under one-owner wallet compromise | Viable against the current protocol | |---|---|---|---|---|---| -| Shared-`dk` (per-asset; this design) | Identical 32-byte `dk[multisig, token]` per shared asset, plus the owner's own Ed25519 key | One proposer constructs the full proof set using `dk[multisig, token]`; approvers contribute only Ed25519 signatures | Lost for the assets whose `dk` the attacker holds; preserved for all other registered assets | Safe — fund movement requires k-of-n Ed25519 signatures | Yes — works against the deployed Move modules without protocol change | +| Shared-`dk` with on-chain inbox delivery (per-asset; this design) | Identical 32-byte `dk[multisig, token]` per shared asset, plus the owner's own Ed25519 key | One proposer constructs the full proof set using `dk[multisig, token]`; approvers contribute only Ed25519 signatures | Lost for the assets whose `dk` the attacker holds; preserved for all other registered assets | Safe — fund movement requires k-of-n Ed25519 signatures | Yes — adds a small `dk_inbox` Move module for envelope-addressed event delivery (no verifier change). Out-of-band delivery (e.g. password manager) remains a legacy fallback | | Per-owner separate `dk` (re-encrypt to all owners) | The owner's own `dk`; transfers carry one ciphertext per owner | Proposer constructs proofs against multiple `ek` values; on-chain verifier checks all | Privacy lost only against the compromised owner's view; other owners retain it | Safe | No — current Move modules store one `ek` per `(account, token)` registration; this approach would require protocol changes and break per-asset auditor accounting | | Threshold ElGamal with threshold zero-knowledge (true MPC) | A share of `dk`; no single owner can decrypt | k owners run an interactive multi-party computation to jointly decrypt and construct a single proof | Preserved — the attacker holds one share, below threshold | Safe | No — requires a threshold-ElGamal-aware Move verifier, threshold-friendly Bulletproofs and Sigma protocols, and a multi-round MPC channel between wallets. Substantial protocol and wallet work | | Trusted-coordinator service (server holds `dk`; owners authenticate to it) | The owner's own Ed25519 key; an authentication token to the coordinator | Coordinator constructs proofs on owners' behalf | Lost on coordinator compromise — a single point of failure outside the wallet trust boundary | Safe — k-of-n approvals are still required on chain | Possible to build, but rejected. It violates [Principle 1](#guiding-principles): `dk` is wallet-custodied, and only the user — not a third-party service — may authorize disclosure of `dk` bytes. Disclosure to a shared service outside the user's control is excluded by design | -The shared-`dk` design (per asset) is the chosen construction for this integration. Each shared `dk` is transmitted and stored through a secure out-of-band channel (for example, a shared password manager). +The shared-`dk` design (per asset) is the chosen construction for this integration. Each shared `dk` is transmitted to co-owners via the on-chain `dk_inbox` module specified in [DK sharing among co-owners](#dk-sharing-among-co-owners) — encrypted per-recipient under each owner's public key — with raw-hex out-of-band transmission (for example, a shared password manager) retained only as a legacy import fallback. The on-chain delivery channel introduces one inherent tradeoff — retroactive disclosure via permanent chain history if a recipient's key is later compromised — documented in [Threat model](#threat-model). --- @@ -850,11 +1021,11 @@ Implementations of these entry points call the confidential-asset module's Move | Method | Request | Response | Notes | |---|---|---|---| -| `ca_register` | `{ token, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Wallet derives `dk[token]`, builds the proof, and presents the transaction for user confirmation. Submits after confirmation, or returns BCS bytes if `mode: "buildOnly"`. | -| `ca_deposit` | `{ token, amount, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | The wallet routes to the appropriate single on-chain entrypoint based on registration and normalization state — `register_and_deposit_and_rollover_pending_balance`, `deposit_and_rollover_pending_balance`, or `deposit_and_normalize_and_rollover_pending_balance`. One transaction in every case. See [Deposit](#deposit). | -| `ca_withdraw` | `{ token, amount, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Operates on actual balance only. Always one on-chain transaction. Returns `INSUFFICIENT_BALANCE` when `amount > actual`, regardless of pending; the dApp prompts the user to accept incoming funds first if needed. | -| `ca_transfer` | `{ token, recipient, amount, auditorAddresses?, senderAuditorHint?, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Operates on actual balance only. Always one on-chain transaction. Same `INSUFFICIENT_BALANCE` behavior as `ca_withdraw`. | -| `ca_rolloverPending` | `{ token, sender?, mode? }` | `{ txHash }` or `{ entryFunctionBcs }` | Accept incoming funds. The wallet chains `normalize` (where required) and `rollover` in a single user-confirmation step and submits after confirmation — at most two on-chain transactions, silently chained because `normalize` is a protocol detail of "accept incoming funds." Returns the final `rollover` transaction hash. | +| `ca_register` | `{ token, sender?, mode? }` | `{ hash }` or `{ entryFunctionBytes }` | Wallet derives `dk[token]`, builds the proof, and presents the transaction for user confirmation. Submits after confirmation, or returns BCS bytes if `mode: "buildOnly"`. | +| `ca_deposit` | `{ token, amount, sender?, mode? }` | `{ hash }` or `{ entryFunctionBytes }` | The wallet routes to the appropriate single on-chain entrypoint based on registration and normalization state — `register_and_deposit_and_rollover_pending_balance`, `deposit_and_rollover_pending_balance`, or `deposit_and_normalize_and_rollover_pending_balance`. One transaction in every case. See [Deposit](#deposit). | +| `ca_withdraw` | `{ token, amount, sender?, mode? }` | `{ hash }` or `{ entryFunctionBytes }` | Operates on actual balance only. Always one on-chain transaction. Returns `INSUFFICIENT_BALANCE` when `amount > actual`, regardless of pending; the dApp prompts the user to accept incoming funds first if needed. | +| `ca_transfer` | `{ token, recipient, amount, auditorAddresses?, senderAuditorHint?, sender?, mode? }` | `{ hash }` or `{ entryFunctionBytes }` | Operates on actual balance only. Always one on-chain transaction. Same `INSUFFICIENT_BALANCE` behavior as `ca_withdraw`. | +| `ca_rolloverPending` | `{ token, sender?, mode? }` | `{ hash }` or `{ entryFunctionBytes }` | Accept incoming funds. The wallet chains `normalize` (where required) and `rollover` in a single user-confirmation step and submits after confirmation — at most two on-chain transactions, silently chained because `normalize` is a protocol detail of "accept incoming funds." Returns the final `rollover` transaction hash. | **`sender`** defaults to the wallet's own account address. Pass an explicit value (e.g. a multisig account address) when the executing signer is not the wallet account; the value is bound into the proof's Fiat–Shamir transcript and must match the executor at chain-verification time. A non-default `sender` requires `mode: "buildOnly"` — the wallet cannot sign a transaction on behalf of an account whose key it does not hold. @@ -966,7 +1137,7 @@ if (!caSupported) { const balances = await caGetBalances({ tokens: [tokenAddress] }); const { auditorEncryptionKey: globalAuditorEk } = await caGetGlobalAuditor(); // chain-level; included in every transfer const { auditorEncryptionKey: assetAuditorEk } = await caGetAuditor({ token: tokenAddress }); // per-asset; optional -const { txHash } = await caTransfer({ token, recipient, amount: "100" }); +const { hash } = await caTransfer({ token, recipient, amount: "100" }); ``` These are RPC calls to the wallet, not invocations of the `ConfidentialAsset` SDK in the browser.