diff --git a/Cargo.toml b/Cargo.toml index 059046b..a6fcd36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ default-members = [ ] [workspace.package] -version = "3.1.0" +version = "3.2.0" edition = "2024" license = "LicenseRef-Acki-Nacki-Node-License" license-file = "LICENSE.md" diff --git a/bee_sdk/README.md b/bee_sdk/README.md index 7fb3b44..237f02d 100644 --- a/bee_sdk/README.md +++ b/bee_sdk/README.md @@ -79,9 +79,10 @@ const res = await deploy_multisig_via_giver({ // keys? — owner keypair; generated when omitted, always returned // owners_pubkey? — custodians ["0x…"] (uint256[]), default [owner] // req_confirms?, req_confirms_data? — default 1 - // giver_value? — SHELL (ECC[2]) gas top-up, default "10000000000" - // giver_ecc? — extra ECC, { currency_id: "amount" } + // giver_value? — SHELL (ECC[2]) gas top-up, default "1000000000000000" + // giver_ecc? — extra ECC, Map // wait_for_active? — wait until Active, default true + // code? — deploy a different multisig build (see below) }); console.log(res.address); // 0x… canonical :: @@ -95,6 +96,44 @@ const balances = await multisig_balances({ // e.g. { "2": "10000000000" } (1 = NACKL, 2 = SHELL, 3 = USDC) ``` +#### Choosing the multisig build + +`code` picks which contract gets deployed. Three forms: + +```ts +// 1. Omit it — the SDK's default build (DexDo flat Multisig). +await deploy_multisig_via_giver({ endpoints }); + +// 2. A build vendored in the SDK, by name. No `.tvc` to ship from the frontend. +await deploy_multisig_via_giver({ + endpoints, + code: "update_custodian_v2", // UpdateCustodianMultisigWallet v2.1.0 +}); + +// 3. Your own build. +import abi from "./MyMultisig.abi.json"; +await deploy_multisig_via_giver({ + endpoints, + code: { tvc_b64: myTvcBase64, abi }, // `abi`: object or string +}); +``` + +Vendored builds: + +| `code` | contract | code hash | +|---|---|---| +| *(omitted)* | DexDo flat Multisig | — | +| `"update_custodian_v2"` | `UpdateCustodianMultisigWallet` v2.1.0, `sold 0.81.0` | `09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded` | + +In form 3, `tvc_b64` and `abi` are both required and must come from the *same* +build: on ABI ≥ 2.3 the state-init data cell is rebuilt from the ABI's `fields` +before the address is hashed, so mixing one build's code with another's ABI +derives a different address whose storage layout the code doesn't agree with. +Sending only one half is an error, not a default. + +The build is part of the address, so each one lands somewhere different — the +returned `address` is always the one that was funded and deployed. + ### Multifactor wallet ```ts diff --git a/bee_wallet/Cargo.toml b/bee_wallet/Cargo.toml index b176812..31e6121 100644 --- a/bee_wallet/Cargo.toml +++ b/bee_wallet/Cargo.toml @@ -86,7 +86,12 @@ tokio = { optional = true, version = "1.49.0", features = ["rt", "macros"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] dodex-contracts = { git = "https://github.com/gosh-sh/dodex-backend.git", tag = "v1.0.0", package = "dodex-contracts" } -[dev-dependencies] +# Native integration tests (`tests/integration.rs`, `tests/dex_flows/`) only. +# These pull the native tvm/kit/dodex graph, so they MUST stay off the wasm32 +# build — otherwise `wasm-pack test` (which forces `--tests`) drags native-only +# crates (e.g. errno) into wasm32 and fails to compile. The matching test files +# are guarded with `#![cfg(not(target_arch = "wasm32"))]`. +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", features = ["default", "contracts"] } base64 = "0.22.1" bee-connect = { path = "../bee_connect" } @@ -96,13 +101,13 @@ bee-crypto = { path = "../bee_crypto" } bee-miner = { path = "../bee_miner" } serde_json = "1" tokio = { version = "1.49.0", features = ["rt", "macros", "time"] } - # DEX façade (`Dex`, halo2 prover, `proof` helpers) for the wallet-coupled # full-flow tests under `tests/dex_flows/` — the two multifactor flows that # exercise bee-wallet + DEX together. Native-only: pulls the heavy halo2 graph. # Raw contract wrappers come from `dodex-contracts` (a regular dep above). -[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] dodex-sdk = { git = "https://github.com/gosh-sh/dodex-backend.git", tag = "v1.0.0" } +# wasm-bindgen-test backs the `#[wasm_bindgen_test]` unit tests in +# `adapters/wasm` (run via `wasm-pack test --node`). Kept off the native build. [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3.56" diff --git a/bee_wallet/assets/multisig/PROVENANCE.md b/bee_wallet/assets/multisig/PROVENANCE.md index 7b9ce46..c1ede6f 100644 --- a/bee_wallet/assets/multisig/PROVENANCE.md +++ b/bee_wallet/assets/multisig/PROVENANCE.md @@ -1,5 +1,17 @@ # Multisig assets — provenance +Two builds are vendored here: + +| build | files | selected by | code hash | +|---|---|---|---| +| DexDo flat Multisig (**default**) | `Multisig.{tvc,abi.json}` | `code` omitted | `.tvc` sha256 `d3b38bca…` | +| `UpdateCustodianMultisigWallet` v2.1.0 | `v2/UpdateCustodianMultisigWallet.{tvc,abi.json}` | `code: "update_custodian_v2"` | `09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded` | + +A caller can also pass its own build as `code: { tvc_b64, abi }` — see +"Any other build" below. + +## Default build — DexDo flat Multisig + This is the **DexDo** multisig: `Multisig.abi.json` + `Multisig.tvc` are the flat Multisig that DexDo uses, sourced from the DexDo repo: @@ -17,3 +29,61 @@ Note: this TVC differs from `ackinacki-kit`'s `contracts/abi/multisig/Multisig.t DexDo's build so addresses/code match what DexDo expects. Current `Multisig.tvc` sha256: d3b38bcac8f60c1274f6099fc1e75746c02a2ff22af4efc689a754fd087a86fb + +## `v2/` — UpdateCustodianMultisigWallet v2.1.0 + +Vendored **verbatim** (not recompiled) from `gosh-sh/acki-nacki` branch `dev`, +path `contracts/0.81.0_compiled/updatecustodianmultisigwallet_v2/` — the merged +form of PR #2413 (`dev` commit `6ad89549a0b8`, "new Contracts/multisig (#2413)"). +Upstream names carry a `_v2` suffix that is redundant inside `v2/`, so they are +stored here without it; the git blob SHAs match upstream exactly: + + upstream UpdateCustodianMultisigWallet_v2.tvc 7150 B blob 9610c471dce949f6ec84b096711cb7f43c78343b + upstream UpdateCustodianMultisigWallet_v2.abi.json 10856 B blob 90b8f8518666a49e1caf30aeec332fcb22ab7311 + + .tvc sha256 535e180e85ee019c23631c6046449fa2a5536d88f55b26d64e026d671e82d520 + code hash 09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded + compiler sol 0.81.0 + +Refreshed from `dev` on 2026-07-27, superseding the pre-merge artifact taken from +branch `contracts/multisig` (`.tvc` 7147 B, sha256 `3e680a80…`, code hash +`31e402bb…`). The ABI is byte-identical across the two; only the compiled code +moved, so the **derived address of a v2 deploy changed** — anything that recorded +a v2 address computed before this refresh must recompute it. + +To re-verify or refresh: + + gh api "repos/gosh-sh/acki-nacki/contents/contracts/0.81.0_compiled/updatecustodianmultisigwallet_v2?ref=dev" \ + --jq '.[] | "\(.name) \(.sha)"' + git hash-object v2/UpdateCustodianMultisigWallet.tvc # must match the blob above + +The code hash is read straight out of the `.tvc` with +`tvm_client::boc::decode_state_init` (`code_hash` = repr hash of the state-init +code cell) — the same value a node reports for a deployed account. + +The `.tvc` sha256 is pinned in a unit test (`vendored_v2_asset_is_pinned`) and the +code hash is asserted on-chain by the integration tests, so a swapped file fails +in CI rather than on a network. + +Relative to the default build its ABI is a strict superset by function set: all +17 functions identical (constructor included), plus `submitUpdateCode(cell,cell) +-> uint64` and `confirmUpdateCode(uint64)`. Its `fields` add `m_requestsMaskCode` +and `m_code` — which is why it must be deployed with its own ABI (see below). + +Verified on shellnet after the 2026-07-27 refresh, through +`deploy_multisig_via_giver` with `code: "update_custodian_v2"` +(`test_deploy_multisig_via_giver_update_custodian_v2`): account Active at +`0480508b8bf07b3df8830ab758a61be0ee9b36427d9780466a66712277bb468c::…`, on-chain +code hash `09f596d5…` as above. + +## Any other build + +`MultisigDeploySpec.code` (`code: { tvc_b64, abi }` over the wasm boundary) +deploys a build that isn't vendored here — see `MultisigCode` in +`src/services/multisig.rs`. Both halves are required, and that is not a +formality: on ABI ≥ 2.3 the state-init **data** cell is rebuilt from the ABI's +`fields` list before the address is hashed (`tvm_sdk::ContractImage::update_data` +→ `encode_storage_fields`), so an ABI is part of the address. Pairing one build's +code with another's ABI silently derives a *different* address whose storage +layout the code does not agree with — measured: v2's code with the default ABI +lands on a third address, even though the ABIs share every function signature. diff --git a/bee_wallet/assets/multisig/v2/UpdateCustodianMultisigWallet.abi.json b/bee_wallet/assets/multisig/v2/UpdateCustodianMultisigWallet.abi.json new file mode 100644 index 0000000..90b8f85 --- /dev/null +++ b/bee_wallet/assets/multisig/v2/UpdateCustodianMultisigWallet.abi.json @@ -0,0 +1,297 @@ +{ + "ABI version": 2, + "version": "2.4", + "header": ["pubkey", "time", "expire"], + "functions": [ + { + "name": "constructor", + "inputs": [ + {"name":"owners_pubkey","type":"uint256[]"}, + {"name":"owners_address","type":"address[]"}, + {"name":"reqConfirms","type":"uint8"}, + {"name":"reqConfirmsData","type":"uint8"}, + {"name":"value","type":"uint64"} + ], + "outputs": [ + ] + }, + { + "name": "setMaxCleanupOperations", + "inputs": [ + {"name":"value","type":"uint256"} + ], + "outputs": [ + ] + }, + { + "name": "sendTransaction", + "inputs": [ + {"name":"dest","type":"address"}, + {"name":"value","type":"uint128"}, + {"name":"cc","type":"map(uint32,varuint32)"}, + {"name":"bounce","type":"bool"}, + {"name":"flags","type":"uint8"}, + {"name":"payload","type":"cell"}, + {"name":"dapp_id","type":"uint256"} + ], + "outputs": [ + {"name":"value0","type":"address"} + ] + }, + { + "name": "submitTransaction", + "inputs": [ + {"name":"dest","type":"address"}, + {"name":"value","type":"uint128"}, + {"name":"cc","type":"map(uint32,varuint32)"}, + {"name":"bounce","type":"bool"}, + {"name":"flag","type":"uint8"}, + {"name":"payload","type":"cell"}, + {"name":"dapp_id","type":"uint256"} + ], + "outputs": [ + {"name":"transId","type":"uint64"} + ] + }, + { + "name": "confirmTransaction", + "inputs": [ + {"name":"transactionId","type":"uint64"} + ], + "outputs": [ + ] + }, + { + "name": "submitDataUpdate", + "inputs": [ + {"name":"owners_pubkey","type":"uint256[]"}, + {"name":"owners_address","type":"address[]"}, + {"name":"reqConfirms","type":"uint8"}, + {"name":"reqConfirmsData","type":"uint8"} + ], + "outputs": [ + {"name":"transId","type":"uint64"} + ] + }, + { + "name": "confirmDataUpdate", + "inputs": [ + {"name":"dataUpdateId","type":"uint64"} + ], + "outputs": [ + ] + }, + { + "name": "submitUpdateCode", + "inputs": [ + {"name":"newcode","type":"cell"}, + {"name":"cell","type":"cell"} + ], + "outputs": [ + {"name":"codeUpdateId","type":"uint64"} + ] + }, + { + "name": "confirmUpdateCode", + "inputs": [ + {"name":"codeUpdateId","type":"uint64"} + ], + "outputs": [ + ] + }, + { + "name": "isConfirmed", + "inputs": [ + {"name":"mask","type":"uint32"}, + {"name":"index","type":"uint8"} + ], + "outputs": [ + {"name":"confirmed","type":"bool"} + ] + }, + { + "name": "getParameters", + "inputs": [ + ], + "outputs": [ + {"name":"maxQueuedTransactions","type":"uint8"}, + {"name":"maxCustodianCount","type":"uint8"}, + {"name":"expirationTime","type":"uint64"}, + {"name":"requiredTxnConfirms","type":"uint8"}, + {"name":"requiredDataConfirms","type":"uint8"} + ] + }, + { + "name": "getTransaction", + "inputs": [ + {"name":"transactionId","type":"uint64"} + ], + "outputs": [ + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"dest","type":"address"},{"name":"value","type":"uint128"},{"name":"cc","type":"map(uint32,varuint32)"},{"name":"sendFlags","type":"uint16"},{"name":"payload","type":"cell"},{"name":"bounce","type":"bool"},{"name":"dapp_id","type":"uint256"}],"name":"trans","type":"tuple"} + ] + }, + { + "name": "getUpdateData", + "inputs": [ + {"name":"updateDataId","type":"uint64"} + ], + "outputs": [ + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"owners_pubkey","type":"uint256[]"},{"name":"owners_address","type":"address[]"},{"name":"reqConfirms","type":"uint8"},{"name":"reqConfirmsData","type":"uint8"}],"name":"data","type":"tuple"} + ] + }, + { + "name": "getTransactions", + "inputs": [ + ], + "outputs": [ + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"dest","type":"address"},{"name":"value","type":"uint128"},{"name":"cc","type":"map(uint32,varuint32)"},{"name":"sendFlags","type":"uint16"},{"name":"payload","type":"cell"},{"name":"bounce","type":"bool"},{"name":"dapp_id","type":"uint256"}],"name":"transactions","type":"tuple[]"} + ] + }, + { + "name": "getUpdateDatas", + "inputs": [ + ], + "outputs": [ + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"owners_pubkey","type":"uint256[]"},{"name":"owners_address","type":"address[]"},{"name":"reqConfirms","type":"uint8"},{"name":"reqConfirmsData","type":"uint8"}],"name":"data","type":"tuple[]"} + ] + }, + { + "name": "getTransactionIds", + "inputs": [ + ], + "outputs": [ + {"name":"ids","type":"uint64[]"} + ] + }, + { + "name": "getUpdateCodeIds", + "inputs": [ + ], + "outputs": [ + {"name":"ids","type":"uint64[]"} + ] + }, + { + "name": "getCustodians", + "inputs": [ + ], + "outputs": [ + {"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"custodians","type":"tuple[]"} + ] + }, + { + "name": "getVersion", + "inputs": [ + ], + "outputs": [ + {"name":"value0","type":"string"}, + {"name":"value1","type":"string"} + ] + } + ], + "events": [ + { + "name": "TransferAccepted", + "inputs": [ + {"name":"payload","type":"bytes"} + ], + "outputs": [ + ] + }, + { + "name": "TransactionSent", + "inputs": [ + {"name":"transactionId","type":"uint64"}, + {"name":"dest","type":"address"}, + {"name":"value","type":"uint128"}, + {"name":"cc","type":"map(uint32,varuint32)"}, + {"name":"sendFlags","type":"uint16"}, + {"name":"bounce","type":"bool"}, + {"name":"dapp_id","type":"uint256"} + ], + "outputs": [ + ] + }, + { + "name": "TransactionSubmitted", + "inputs": [ + {"name":"transactionId","type":"uint64"}, + {"name":"creatorIndex","type":"uint8"}, + {"name":"signsRequired","type":"uint8"}, + {"name":"dest","type":"address"}, + {"name":"value","type":"uint128"}, + {"name":"dapp_id","type":"uint256"} + ], + "outputs": [ + ] + }, + { + "name": "TransactionConfirmed", + "inputs": [ + {"name":"transactionId","type":"uint64"}, + {"name":"custodianIndex","type":"uint8"}, + {"name":"signsReceived","type":"uint8"}, + {"name":"signsRequired","type":"uint8"} + ], + "outputs": [ + ] + }, + { + "name": "CustodiansUpdated", + "inputs": [ + {"name":"custodianCount","type":"uint8"}, + {"name":"reqConfirms","type":"uint8"}, + {"name":"reqConfirmsData","type":"uint8"} + ], + "outputs": [ + ] + }, + { + "name": "FundsReceived", + "inputs": [ + {"name":"sender","type":"address"}, + {"name":"value","type":"uint128"}, + {"name":"cc","type":"map(uint32,varuint32)"} + ], + "outputs": [ + ] + }, + { + "name": "WalletSetup", + "inputs": [ + {"name":"custodianCount","type":"uint8"}, + {"name":"reqConfirms","type":"uint8"}, + {"name":"reqConfirmsData","type":"uint8"} + ], + "outputs": [ + ] + }, + { + "name": "ExecutionFailure", + "inputs": [ + {"name":"dest","type":"address"}, + {"name":"value","type":"uint128"} + ], + "outputs": [ + ] + } + ], + "fields": [ + {"init":true,"name":"_pubkey","type":"uint256"}, + {"init":false,"name":"_timestamp","type":"uint64"}, + {"init":false,"name":"_constructorFlag","type":"bool"}, + {"init":false,"name":"m_ownerKey","type":"optional(uint256)"}, + {"init":false,"name":"m_ownerAddress","type":"optional(address)"}, + {"init":false,"name":"m_requestsMask","type":"uint256"}, + {"init":false,"name":"m_requestsMaskData","type":"uint256"}, + {"init":false,"name":"m_requestsMaskCode","type":"uint256"}, + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"dest","type":"address"},{"name":"value","type":"uint128"},{"name":"cc","type":"map(uint32,varuint32)"},{"name":"sendFlags","type":"uint16"},{"name":"payload","type":"cell"},{"name":"bounce","type":"bool"},{"name":"dapp_id","type":"uint256"}],"init":false,"name":"m_transactions","type":"map(uint64,tuple)"}, + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"owners_pubkey","type":"uint256[]"},{"name":"owners_address","type":"address[]"},{"name":"reqConfirms","type":"uint8"},{"name":"reqConfirmsData","type":"uint8"}],"init":false,"name":"m_data","type":"map(uint64,tuple)"}, + {"components":[{"name":"id","type":"uint64"},{"name":"confirmationsMask","type":"uint32"},{"name":"signsRequired","type":"uint8"},{"name":"signsReceived","type":"uint8"},{"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"name":"creator","type":"tuple"},{"name":"newcode","type":"cell"},{"name":"cell","type":"cell"}],"init":false,"name":"m_code","type":"map(uint64,tuple)"}, + {"components":[{"name":"owner_pubkey","type":"optional(uint256)"},{"name":"owner_address","type":"optional(address)"},{"name":"index","type":"uint8"}],"init":false,"name":"m_custodians","type":"map(uint256,tuple)"}, + {"init":false,"name":"m_custodianCount","type":"uint8"}, + {"init":false,"name":"m_defaultRequiredConfirmations","type":"uint8"}, + {"init":false,"name":"m_defaultRequiredConfirmationsData","type":"uint8"}, + {"init":false,"name":"_max_cleanup_operations","type":"uint256"} + ] +} diff --git a/bee_wallet/assets/multisig/v2/UpdateCustodianMultisigWallet.tvc b/bee_wallet/assets/multisig/v2/UpdateCustodianMultisigWallet.tvc new file mode 100644 index 0000000..9610c47 Binary files /dev/null and b/bee_wallet/assets/multisig/v2/UpdateCustodianMultisigWallet.tvc differ diff --git a/bee_wallet/src/adapters/wasm/dto/connect.rs b/bee_wallet/src/adapters/wasm/dto/connect.rs index d3a030a..ec03074 100644 --- a/bee_wallet/src/adapters/wasm/dto/connect.rs +++ b/bee_wallet/src/adapters/wasm/dto/connect.rs @@ -19,6 +19,7 @@ pub struct ConnectSessionMessage { ts: Option, raw_message_json: String, body_json: String, + body_decrypted: bool, wallet_name: Option, wallet_address: Option, challenge_nonce: Option, @@ -66,6 +67,7 @@ impl From for ConnectSessionMessage { ts: value.ts, raw_message_json: value.raw_message_json, body_json: value.body_json, + body_decrypted: value.body_decrypted, wallet_name, wallet_address, challenge_nonce, @@ -123,6 +125,14 @@ impl ConnectSessionMessage { self.body_json.clone() } + /// `true` when the encrypted body was actually decrypted for this message. + /// `body_json` is not a substitute: it falls back to the raw envelope body + /// when decryption fails, so it is always non-empty. + #[wasm_bindgen(getter)] + pub fn body_decrypted(&self) -> bool { + self.body_decrypted + } + #[wasm_bindgen(getter)] pub fn wallet_name(&self) -> Option { self.wallet_name.clone() diff --git a/bee_wallet/src/adapters/wasm/dto/multisig.rs b/bee_wallet/src/adapters/wasm/dto/multisig.rs index 05ebf68..64a35bb 100644 --- a/bee_wallet/src/adapters/wasm/dto/multisig.rs +++ b/bee_wallet/src/adapters/wasm/dto/multisig.rs @@ -13,8 +13,31 @@ export type TParamsOfDeployMultisigViaGiver = { req_confirms_data?: number; constructor_value?: string; giver_value?: string; - giver_ecc?: Record; + // Map, NOT Record: serde on the Rust side deserializes a JS Map's numeric + // keys into the u32 currency ids. A plain object/Record stringifies its keys + // and the WASM rejects them ("invalid type: string, expected u32"). + giver_ecc?: Map; wait_for_active?: boolean; + // Which multisig build to deploy. Omit for the SDK's default build, name a + // build vendored in the SDK, or pass your own code+ABI pair. + code?: TMultisigBuild | TMultisigCode; +}; + +// Builds vendored in this SDK, selectable by name — no need to ship a `.tvc` +// from the frontend. "update_custodian_v2" is UpdateCustodianMultisigWallet +// v2.1.0, code hash 09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded. +export type TMultisigBuild = "update_custodian_v2"; + +// Your own build. `tvc_b64` and `abi` are BOTH required: on ABI >= 2.3 the +// state-init data cell is rebuilt from the ABI's `fields` list before the +// address is hashed, so the ABI is part of the address. Mixing one build's code +// with another's ABI derives an address whose storage layout the code does not +// agree with — hence no way to pass just one. +export type TMultisigCode = { + // The build's `.tvc`, base64-encoded. + tvc_b64: string; + // That build's `.abi.json` — stringified or the imported object. + abi: string | object; }; export type TResultOfDeployMultisigViaGiver = { diff --git a/bee_wallet/src/adapters/wasm/mod.rs b/bee_wallet/src/adapters/wasm/mod.rs index f370721..84ac5b8 100644 --- a/bee_wallet/src/adapters/wasm/mod.rs +++ b/bee_wallet/src/adapters/wasm/mod.rs @@ -773,9 +773,18 @@ pub async fn deploy_multisig_via_giver( serde_wasm_bindgen::from_value(params_val) .map_err(|e| JsError::new(&format!("Bad TParamsOfDeployMultisigViaGiver: {e:?}")))?; - let result = crate::services::multisig::deploy_multisig_via_giver(params) - .await - .map_err(|e| JsError::new(&format!("deploy_multisig_via_giver failed: {e:?}")))?; + let result = + crate::services::multisig::deploy_multisig_via_giver(params).await.map_err(|e| { + // Preserve the typed throttle signal: a rate-limited failure surfaces + // with a stable `RateLimited:` message prefix so the UI can branch on + // it (`err.message.startsWith("RateLimited")`) instead of treating it + // as a generic transport failure. + if e.kind.as_deref() == Some("rate_limited") { + JsError::new(&e.message) + } else { + JsError::new(&format!("deploy_multisig_via_giver failed: {e:?}")) + } + })?; let js = serde_wasm_bindgen::to_value(&result) .map_err(|e| JsError::new(&format!("serialize result failed: {e:?}")))?; @@ -789,7 +798,6 @@ pub async fn deploy_multisig_via_giver( pub async fn multisig_balances( params: dto::multisig::TParamsOfMultisigBalances, ) -> Result { - use serde::Serialize; use wasm_bindgen::JsCast; let params_val: JsValue = params.into(); @@ -801,13 +809,165 @@ pub async fn multisig_balances( .await .map_err(|e| JsError::new(&format!("multisig_balances failed: {e:?}")))?; - // Serialize the map as a plain JS object: { "2": "10000000000", ... }. - let js = balances - .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + let js = serialize_ecc_balances(balances) .map_err(|e| JsError::new(&format!("serialize balances failed: {e:?}")))?; Ok(js.unchecked_into::()) } +/// Serialize ECC balances for the JS boundary as a plain object +/// `{ "": "" }` (the shape the `.d.ts` advertises as +/// `Record`). +/// +/// `json_compatible()` emits maps as JS objects, whose keys MUST be strings; a +/// raw `u32` key throws `Map key is not a string and cannot be an object key`. +/// The catch: an EMPTY map has no key to reject, so the bug stays hidden until +/// a wallet actually holds a non-zero ECC balance — which is exactly when the +/// frontend needs the numbers. Stringifying the currency ids up front makes +/// every map (empty or not) serialize to a real object. +fn serialize_ecc_balances( + balances: std::collections::BTreeMap, +) -> Result { + use serde::Serialize; + balances + .into_iter() + .map(|(id, amount)| (id.to_string(), amount)) + .collect::>() + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) +} + +/// The `code` selector is a string-or-object union, and it crosses the boundary +/// through `serde_wasm_bindgen`, not `serde_json` — so the native unit tests do +/// not cover the shape a frontend actually sends. These run under +/// `wasm-pack test --node`. +#[cfg(all(test, target_arch = "wasm32"))] +mod multisig_code_selector_tests { + use wasm_bindgen_test::*; + + use crate::services::multisig::ParamsOfDeployMultisigViaGiver; + + /// Deserializes `{ endpoints, code }` exactly as the wasm entry point does. + fn params_from_js( + code: wasm_bindgen::JsValue, + ) -> Result { + let params = js_sys::Object::new(); + let endpoints = js_sys::Array::new(); + endpoints.push(&wasm_bindgen::JsValue::from_str("https://example.invalid")); + js_sys::Reflect::set(¶ms, &"endpoints".into(), &endpoints).unwrap(); + js_sys::Reflect::set(¶ms, &"code".into(), &code).unwrap(); + serde_wasm_bindgen::from_value(params.into()).map_err(|e| format!("{e:?}")) + } + + /// `code: "update_custodian_v2"` — a plain JS string must select the + /// vendored build (serde_wasm_bindgen routes strings through + /// `deserialize_any`). + #[wasm_bindgen_test] + fn named_build_arrives_as_a_js_string() { + let params = params_from_js(wasm_bindgen::JsValue::from_str("update_custodian_v2")) + .expect("named build must deserialize"); + let code = crate::services::multisig::MultisigCode::try_from(params.code.unwrap()) + .expect("named build must resolve"); + assert!(!code.tvc.is_empty()); + assert!(code.abi.contains("submitUpdateCode"), "must be the v2 ABI"); + } + + /// `code: { tvc_b64, abi }` with `abi` as an imported JS **object** — the + /// ergonomic shape. A plain object must survive `deserialize_any` into + /// `serde_json::Value`; if it didn't, this is where it would show. + #[wasm_bindgen_test] + fn custom_build_accepts_object_abi() { + let abi = js_sys::JSON::parse(r#"{"functions":[{"name":"constructor"}]}"#).unwrap(); + let code = js_sys::Object::new(); + js_sys::Reflect::set(&code, &"tvc_b64".into(), &"AQID".into()).unwrap(); + js_sys::Reflect::set(&code, &"abi".into(), &abi).unwrap(); + + let params = params_from_js(code.into()).expect("custom build must deserialize"); + let resolved = crate::services::multisig::MultisigCode::try_from(params.code.unwrap()) + .expect("custom build must convert"); + assert_eq!(resolved.tvc, vec![1, 2, 3]); + assert!(resolved.abi.starts_with('{'), "object ABI must render as JSON: {}", resolved.abi); + } + + /// Omitting `code` keeps the default build. + #[wasm_bindgen_test] + fn absent_code_is_none() { + let params = params_from_js(wasm_bindgen::JsValue::UNDEFINED).expect("must deserialize"); + assert!(params.code.is_none()); + } + + /// Half a pair must fail with the message that names the missing half. + #[wasm_bindgen_test] + fn half_a_pair_is_rejected_with_a_useful_message() { + let code = js_sys::Object::new(); + js_sys::Reflect::set(&code, &"tvc_b64".into(), &"AQID".into()).unwrap(); + let err = params_from_js(code.into()).expect_err("half a pair must not deserialize"); + assert!(err.contains("`code.abi` is missing"), "got: {err}"); + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod multisig_balances_tests { + use std::collections::BTreeMap; + + use wasm_bindgen::prelude::*; + use wasm_bindgen::JsCast; + use wasm_bindgen_test::*; + + use super::serialize_ecc_balances; + + fn funded() -> BTreeMap { + // 1=NACKL, 2=SHELL, 3=USDC — raw integer amounts as strings. + BTreeMap::from([ + (1u32, "1000000000000000".to_string()), + (2u32, "0".to_string()), + (3u32, "1000000000000000".to_string()), + ]) + } + + fn js_string_at(obj: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(obj, &JsValue::from_str(key)).unwrap().as_string() + } + + /// The actual bug: a non-empty `{ currency_id: amount }` map must serialize + /// to a JS object with STRING keys instead of throwing "Map key is not a + /// string…". Pre-fix this path panicked the wasm call. + #[wasm_bindgen_test] + fn non_empty_balances_serialize_to_object_with_string_keys() { + let js = serialize_ecc_balances(funded()).expect("non-empty balances must serialize"); + assert!(js.is_object(), "expected a plain JS object"); + + assert_eq!(js_string_at(&js, "1").as_deref(), Some("1000000000000000")); + assert_eq!(js_string_at(&js, "2").as_deref(), Some("0")); + assert_eq!(js_string_at(&js, "3").as_deref(), Some("1000000000000000")); + + // Keys are the stringified ids, nothing more, nothing less. + let keys = js_sys::Object::keys(&js.unchecked_into::()); + assert_eq!(keys.length(), 3); + } + + /// The empty-wallet case that masked the bug — still returns `{}`. + #[wasm_bindgen_test] + fn empty_balances_serialize_to_empty_object() { + let js = serialize_ecc_balances(BTreeMap::new()).expect("empty balances must serialize"); + assert!(js.is_object()); + let keys = js_sys::Object::keys(&js.unchecked_into::()); + assert_eq!(keys.length(), 0); + } + + /// Regression guard for the stringify step: serializing the raw + /// `BTreeMap` (the pre-fix code) still fails on a non-empty + /// map. If serde_wasm_bindgen ever starts accepting integer keys here, this + /// test flips and tells us the workaround is no longer load-bearing. + #[wasm_bindgen_test] + fn raw_u32_keys_fail_without_stringify() { + use serde::Serialize; + let result = funded().serialize(&serde_wasm_bindgen::Serializer::json_compatible()); + assert!( + result.is_err(), + "raw u32 keys unexpectedly serialized — the stringify workaround may be obsolete", + ); + } +} + #[cfg(not(feature = "single-wasm"))] #[wasm_bindgen(start)] pub fn start() {} diff --git a/bee_wallet/src/infra/mod.rs b/bee_wallet/src/infra/mod.rs index 826dab6..ec3c5f3 100644 --- a/bee_wallet/src/infra/mod.rs +++ b/bee_wallet/src/infra/mod.rs @@ -73,10 +73,10 @@ pub fn is_confirmation_pending_error(err: &crate::errors::AppError) -> bool { /// (see `bee_infra::retry` doc). /// /// Available for opt-in use by callers that want HTTP-transient retries -/// (combine with [`with_retry`] via the `should_retry` parameter). Not -/// wired into any high-level read method by default — that decision -/// belongs to the caller per the retry-policy ticket. -#[allow(dead_code)] +/// (combine with [`with_retry`] via the `should_retry` parameter). Used by +/// the multisig giver/deploy path (`services::multisig::send_should_retry`); +/// not wired into high-level read methods by default — that decision belongs +/// to the caller per the retry-policy ticket. pub fn is_retryable_http_transient(err: &crate::errors::AppError) -> bool { // tvm_client::ErrorCode::HttpRequestSendError == 11. if err.error_code.as_deref() == Some("11") { diff --git a/bee_wallet/src/lib.rs b/bee_wallet/src/lib.rs index a09dbfa..a2215d7 100644 --- a/bee_wallet/src/lib.rs +++ b/bee_wallet/src/lib.rs @@ -96,9 +96,11 @@ pub use crate::services::multisig::deploy_multisig; pub use crate::services::multisig::deploy_multisig_via_giver; pub use crate::services::multisig::multisig_balances; pub use crate::services::multisig::DeployOutcome; +pub use crate::services::multisig::MultisigCode; pub use crate::services::multisig::MultisigDeploySpec; pub use crate::services::multisig::ParamsOfDeployMultisigViaGiver; pub use crate::services::multisig::ParamsOfMultisigBalances; +pub use crate::services::multisig::ParamsOfMultisigCode; pub use crate::services::multisig::ResultOfDeployMultisigViaGiver; pub use crate::services::transaction::history::ParamsOfGetHistory; pub use crate::services::zkp::ParamsOfAddZKPFactor; diff --git a/bee_wallet/src/modules/connect.rs b/bee_wallet/src/modules/connect.rs index c2d7c32..420960e 100644 --- a/bee_wallet/src/modules/connect.rs +++ b/bee_wallet/src/modules/connect.rs @@ -209,15 +209,12 @@ impl<'a> ConnectModule<'a> { if let Some(mut msg) = parsed_with_rekey { // parse returns Some for any valid envelope, even // when body decrypt fails. Only advance the DH - // state if the body was actually decrypted — at - // least one typed field must be populated. - let body_decrypted = msg.wallet_hello.is_some() - || msg.set_mining_keys.is_some() - || msg.client_disconnect.is_some() - || msg.sign_challenge.is_some() - || msg.challenge_response.is_some(); - - if body_decrypted { + // state if the body was actually decrypted. This + // must come from the parse itself: keying it off + // the typed body fields would drop every + // application-level msg_type this crate does not + // know, since none of them populate a typed field. + if msg.body_decrypted { msg.session_state_after = Some(rekey.updated_state.clone()); current_state = Some(rekey.updated_state); had_successful_rekey = true; diff --git a/bee_wallet/src/services/connect.rs b/bee_wallet/src/services/connect.rs index cd1d76f..e74cf4f 100644 --- a/bee_wallet/src/services/connect.rs +++ b/bee_wallet/src/services/connect.rs @@ -125,6 +125,15 @@ pub struct ConnectSessionMessage { pub ts: Option, pub raw_message_json: String, pub body_json: String, + /// `true` when the encrypted body was decrypted and parsed as JSON with the + /// secret supplied to the parse call. Note that `body_json` is NOT a usable + /// substitute for this flag: on decrypt failure it falls back to the raw + /// envelope body, so it is never empty. Callers deciding whether a message + /// was really readable (e.g. whether to advance DH re-key state) must look + /// at this field, not at the typed `*_body` fields — those stay `None` for + /// any application-level `msg_type` this crate does not know. + #[serde(default)] + pub body_decrypted: bool, pub wallet_hello: Option, pub set_mining_keys: Option, pub client_disconnect: Option, @@ -429,6 +438,7 @@ pub fn parse_connect_session_message_with_secret( ts: envelope.ts, raw_message_json: raw_message_json.to_string(), body_json, + body_decrypted: decoded_body.is_some(), wallet_hello: None, set_mining_keys: None, client_disconnect: None, @@ -629,6 +639,75 @@ mod tests { assert!(parsed.client_disconnect.is_none()); } + /// An application-level `msg_type` this crate knows nothing about still has + /// to report `body_decrypted`, otherwise the re-key branch in + /// `modules::connect::query_session_messages` treats it as corrupted and + /// drops it without advancing the DH state. + #[test] + fn parse_unknown_encrypted_message_reports_body_decrypted() { + let encryption_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let ts = 100u64; + let seq = 1_700_000_011_000u64; + let nonce = [11u8; 24]; + let salt = [12u8; 32]; + let msg_type = "agent_onboard_request"; + let body = serde_json::json!({ "agent_id": "agent_1" }); + let aad = connect_message_aad("sess_1", "c2w", seq, msg_type, ts).expect("aad"); + let key = derive_connect_message_key(encryption_secret, &salt).expect("key"); + let cipher = XChaCha20Poly1305::new((&key).into()); + let body_bytes = serde_json::to_vec(&body).expect("body"); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &body_bytes, aad: &aad }) + .expect("encrypt"); + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "c2w", + "seq": seq, + "type": msg_type, + "ts": ts, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": URL_SAFE_NO_PAD.encode(nonce), + "salt": URL_SAFE_NO_PAD.encode(salt), + }, + "body": URL_SAFE_NO_PAD.encode(ciphertext), + }) + .to_string(); + + let parsed = parse_connect_session_message_with_secret( + &raw, + "sess_1", + Some(encryption_secret), + "evt_11".to_string(), + 1100, + ) + .expect("parse"); + assert!(parsed.body_decrypted, "unknown msg_type must still report a decrypted body"); + assert_eq!(parsed.body_json, serde_json::to_string(&body).expect("body json")); + assert!(parsed.wallet_hello.is_none()); + assert!(parsed.set_mining_keys.is_none()); + assert!(parsed.client_disconnect.is_none()); + assert!(parsed.sign_challenge.is_none()); + assert!(parsed.challenge_response.is_none()); + + // Wrong secret: body stays undecrypted, yet body_json falls back to the + // raw ciphertext envelope body — which is why body_json can never be + // used as the "did it decrypt" signal. + let wrong_secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let undecrypted = parse_connect_session_message_with_secret( + &raw, + "sess_1", + Some(wrong_secret), + "evt_11".to_string(), + 1100, + ) + .expect("parse"); + assert!(!undecrypted.body_decrypted); + assert!(!undecrypted.body_json.is_empty()); + assert_ne!(undecrypted.body_json, "null"); + } + #[test] fn parse_encrypted_set_mining_keys_message_with_secret() { let encryption_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; diff --git a/bee_wallet/src/services/multisig.rs b/bee_wallet/src/services/multisig.rs index 2509fc5..bf94d93 100644 --- a/bee_wallet/src/services/multisig.rs +++ b/bee_wallet/src/services/multisig.rs @@ -17,6 +17,15 @@ //! shellnet deploy. The ABI/TVC are vendored from the kit (`assets/multisig/`) //! because the kit's `multisig` binding intentionally leaves deploy out of //! scope. +//! +//! That pair is the *default*, not a hard wiring: every brick takes its build +//! from the spec ([`MultisigDeploySpec::code`]), so three things are deployable +//! — the default build, a second build vendored here +//! (`UpdateCustodianMultisigWallet` v2, `code: "update_custodian_v2"`), or a +//! caller's own (`code: { tvc_b64, abi }`). Handy while v2 rolls out: shellnet +//! on v2, mainnet still on the default. A different build means a different +//! derived address, which is why the override feeds brick 1 too, and why code +//! and ABI travel together (see [`MultisigCode`]). use std::collections::HashMap; use std::sync::Arc; @@ -38,6 +47,9 @@ use ackinacki_kit::tvm_client::processing::ParamsOfProcessMessage; use ackinacki_kit::tvm_client::ClientConfig; use ackinacki_kit::tvm_client::ClientContext; use base64::Engine; +use bee_infra::with_retry_policy; +use bee_infra::RateLimiter; +use bee_infra::RetryPolicy; use serde::Deserialize; use serde::Serialize; use serde_json::json; @@ -45,24 +57,121 @@ use serde_json::json; use crate::errors::AppError; use crate::errors::AppResult; -/// Canonical flat Multisig ABI/TVC, vendored from the kit at build time so the -/// deploy is fully self-contained (the kit doesn't expose them publicly). +/// Canonical flat Multisig ABI/TVC, vendored at build time so the deploy is +/// fully self-contained (the kit doesn't expose them publicly). This is the +/// *default* build only — [`MultisigDeploySpec::code`] overrides it per deploy, +/// so a caller can put a different build (e.g. `UpdateCustodianMultisigWallet` +/// v2) on one network while the default stays put. See +/// `assets/multisig/PROVENANCE.md`. const MULTISIG_ABI: &str = include_str!("../../assets/multisig/Multisig.abi.json"); const MULTISIG_TVC: &[u8] = include_bytes!("../../assets/multisig/Multisig.tvc"); -/// ECC currency id of SHELL — the gas/value currency on Acki Nacki. Crediting -/// it shows up as the account's base `balance`. +/// `UpdateCustodianMultisigWallet` v2.1.0, the second vendored build — selected +/// by name (`code: "update_custodian_v2"`) instead of shipping the `.tvc` from +/// the frontend. Vendored verbatim from `gosh-sh/acki-nacki` `dev` +/// (`contracts/0.81.0_compiled/updatecustodianmultisigwallet_v2/`, the merged +/// form of #2413); code hash `09f596d5…`, `sold 0.81.0`. Its ABI is a strict +/// superset of the default's (adds `submitUpdateCode`/`confirmUpdateCode`) but +/// its `fields` add two storage slots, so it must be deployed with its OWN ABI +/// — see [`MultisigCode`]. +const UPDATE_CUSTODIAN_V2_ABI: &str = + include_str!("../../assets/multisig/v2/UpdateCustodianMultisigWallet.abi.json"); +const UPDATE_CUSTODIAN_V2_TVC: &[u8] = + include_bytes!("../../assets/multisig/v2/UpdateCustodianMultisigWallet.tvc"); + +/// Wire name of the [`UPDATE_CUSTODIAN_V2_TVC`] build. +const UPDATE_CUSTODIAN_V2_NAME: &str = "update_custodian_v2"; +/// sha256 of [`UPDATE_CUSTODIAN_V2_TVC`], pinned so a swapped asset is caught +/// by a unit test rather than on-chain. Corresponds to code hash +/// `09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded`. +/// Test-only: the fixture it guards is the only consumer, and CI lints the lib +/// without `--tests`, where an ungated const reads as dead code. +#[cfg(test)] +const UPDATE_CUSTODIAN_V2_TVC_SHA256: &str = + "535e180e85ee019c23631c6046449fa2a5536d88f55b26d64e026d671e82d520"; + +/// Constructor parameters [`MultisigDeploySpec::encode_params`] hardcodes. An +/// `abi` override must declare exactly these, in this order — otherwise the +/// input JSON below cannot be encoded against it. Overrides that only *add* +/// functions (v2's `submitUpdateCode` / `confirmUpdateCode`) are fine. +const CONSTRUCTOR_INPUTS: [&str; 5] = + ["owners_pubkey", "owners_address", "reqConfirms", "reqConfirmsData", "value"]; + +/// ECC currency id of SHELL. Sent via the flag-16 creation transfer it +/// collapses into the account's native balance — which is fine for the cheap +/// gas top-up that brings the address into existence. The real HELD SHELL is +/// sent AFTER deploy via flag-1 (where it stays ECC[2]). const SHELL_CURRENCY_ID: u32 = 2; -/// Default SHELL top-up for the future address (10 SHELL, u64 raw). -const DEFAULT_GIVER_VALUE: u64 = 10_000_000_000; -/// `sendCurrencyWithFlag` flag used to fund a *not-yet-existing* address: it -/// creates the account (Uninit) and credits the carried ECC. A plain native -/// `value` transfer (flag 1) does not bring a fresh account into existence. +/// SHELL (as ECC) carried by the flag-16 creation transfer. Funding MUST go as +/// ECC, not native `value`: native value doesn't cross a dapp_id boundary and a +/// fresh account is the root of its own dapp, so giver-native would never +/// arrive. Flag 16 collapses this ECC SHELL into the new account's native +/// balance (its gas). A "human" 1,000,000 SHELL (× 10^9 nano = `ECC_TOPUP_GAS` +/// is 1 SHELL). +const DEFAULT_GIVER_VALUE: u64 = 1_000_000 * ECC_TOPUP_GAS; +/// Native VMSHELL `value` accompanying the post-deploy flag-1 ECC top-up — gas +/// for the live account to process it. Mirrors `bin/faucet.rs`. +const ECC_TOPUP_GAS: u64 = 1_000_000_000; +/// `sendCurrencyWithFlag` flag that brings a *not-yet-existing* address into +/// existence (Uninit). A plain flag-1 transfer does NOT create the account. const GIVER_FLAG: u8 = 16; +/// Max external-message sends per second for the giver/deploy path. The node +/// throttles on-chain sends at ~3/s → 429; 2/s leaves a ≥500 ms gap (over the +/// 350 ms floor the consumer asked for, under the node's ceiling). Reads are +/// NOT gated — only the sends in [`deploy_multisig_via_giver`] go through this. +const MAX_SEND_RPS: u32 = 2; + +/// A multisig build to deploy: compiled code plus the ABI it was compiled from. +/// +/// The two are **one unit, not two independent knobs**. On ABI ≥ 2.3 (both +/// builds here are 2.4) the state-init *data* cell is rebuilt from the ABI's +/// `fields` list and then hashed into the address — see +/// `tvm_sdk::ContractImage::update_data`, which routes non-data-map ABIs +/// through `encode_storage_fields(abi_json, …)` and re-derives +/// `state_init.hash()`. So an ABI carries storage layout, not just a calling +/// convention: pairing v1's ABI with v2's code yields a *different* address +/// whose data cell the code does not agree with (measured against +/// `UpdateCustodianMultisigWallet` v2.1.0, whose `fields` add +/// `m_requestsMaskCode` and `m_code`). Hence one struct. +#[derive(Debug, Clone)] +pub struct MultisigCode { + /// Raw `.tvc` bytes of the build. + pub tvc: Vec, + /// Contents of that build's `.abi.json`. + pub abi: String, +} + +impl MultisigCode { + /// `UpdateCustodianMultisigWallet` v2.1.0, vendored in this SDK — code hash + /// `09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded`. Over + /// the wasm boundary the same build is `code: "update_custodian_v2"`. + pub fn update_custodian_v2() -> Self { + Self { tvc: UPDATE_CUSTODIAN_V2_TVC.to_vec(), abi: UPDATE_CUSTODIAN_V2_ABI.to_string() } + } + + /// Resolves a vendored build by its wire name. + fn by_name(name: &str) -> AppResult { + match name { + UPDATE_CUSTODIAN_V2_NAME => Ok(Self::update_custodian_v2()), + other => Err(AppError::new(format!( + "unknown multisig build `{other}` — vendored builds are \ + [{UPDATE_CUSTODIAN_V2_NAME}]; omit `code` for the default build, or pass \ + `{{ tvc_b64, abi }}` for your own", + ))), + } + } +} /// Everything needed to deterministically encode a flat Multisig deploy. The /// owner `keys` sign their own deploy; `owners_pubkey` are the custodians /// (`uint256[]`, each a `0x`-prefixed hex), defaulting to the owner alone. +/// +/// `code` is the contract-build escape hatch: `None` deploys the vendored +/// build, `Some` deploys whatever you hand it (e.g. +/// `UpdateCustodianMultisigWallet` v2 on one network while another stays on the +/// vendored one). It feeds address derivation too, so +/// [`compute_multisig_address`] and [`deploy_multisig`] always agree on the +/// build the spec carries. #[derive(Debug, Clone)] pub struct MultisigDeploySpec { pub keys: KeyPair, @@ -71,15 +180,67 @@ pub struct MultisigDeploySpec { pub req_confirms_data: u8, /// Constructor `value` arg (`uint64`), passed through as a decimal string. pub constructor_value: String, + /// Build override. `None` = the vendored build. + pub code: Option, } impl MultisigDeploySpec { + /// Code this spec deploys: the caller's override, else the vendored build. + fn code_bytes(&self) -> &[u8] { + self.code.as_ref().map_or(MULTISIG_TVC, |code| code.tvc.as_slice()) + } + + /// ABI this spec encodes against: the caller's override, else the vendored + /// one. + fn abi_json(&self) -> &str { + self.code.as_ref().map_or(MULTISIG_ABI, |code| code.abi.as_str()) + } + + /// Rejects an override that tvm_client would only reject much later, deep + /// inside BOC/ABI parsing — or, worse, that would encode cleanly and fail + /// on-chain. Cheap and local: non-empty code, parseable ABI, and a + /// `constructor` matching [`CONSTRUCTOR_INPUTS`]. A `None` override is + /// always valid (the vendored pair is known good, and the test below pins + /// it against this same check). + fn validate(&self) -> AppResult<()> { + let Some(code) = self.code.as_ref() else { return Ok(()) }; + if code.tvc.is_empty() { + return Err(AppError::new("multisig code override has an empty `tvc`")); + } + + let parsed: serde_json::Value = serde_json::from_str(&code.abi).map_err(|e| { + AppError::new(format!("multisig `abi` override is not valid JSON: {e}")) + })?; + let constructor = parsed + .get("functions") + .and_then(|f| f.as_array()) + .and_then(|fns| { + fns.iter().find(|f| f.get("name").and_then(|n| n.as_str()) == Some("constructor")) + }) + .ok_or_else(|| AppError::new("multisig `abi` override declares no `constructor`"))?; + let inputs: Vec<&str> = constructor + .get("inputs") + .and_then(|i| i.as_array()) + .map(|inputs| { + inputs.iter().filter_map(|i| i.get("name").and_then(|n| n.as_str())).collect() + }) + .unwrap_or_default(); + if inputs != CONSTRUCTOR_INPUTS { + return Err(AppError::new(format!( + "multisig `abi` override has an incompatible constructor: got [{}], expected [{}]", + inputs.join(", "), + CONSTRUCTOR_INPUTS.join(", "), + ))); + } + Ok(()) + } + /// Builds the `ParamsOfEncodeMessage` shared by address computation and the /// actual deploy, so both derive from one source of truth. fn encode_params(&self) -> ParamsOfEncodeMessage { - let tvc_b64 = base64::engine::general_purpose::STANDARD.encode(MULTISIG_TVC); + let tvc_b64 = base64::engine::general_purpose::STANDARD.encode(self.code_bytes()); ParamsOfEncodeMessage { - abi: Abi::Json(MULTISIG_ABI.to_string()), + abi: Abi::Json(self.abi_json().to_string()), address: None, deploy_set: Some(DeploySet { tvc: Some(tvc_b64), @@ -123,10 +284,13 @@ fn canonical_address(raw: &str) -> String { /// **Brick 1.** Derive the deterministic address of a not-yet-deployed flat /// Multisig. Pure local crypto — no network round-trip beyond `ctx` setup. +/// Derives from `spec.code` when set, so a build override yields the address +/// that build will actually occupy. pub async fn compute_multisig_address( ctx: Arc, spec: &MultisigDeploySpec, ) -> AppResult { + spec.validate()?; let encoded = encode_message(ctx, spec.encode_params()).await?; Ok(encoded.address) } @@ -148,6 +312,7 @@ pub async fn deploy_multisig( spec: &MultisigDeploySpec, wait_for_active: bool, ) -> AppResult { + spec.validate()?; let encode_params = spec.encode_params(); let address = encode_message(ctx.clone(), encode_params.clone()).await?.address; let dapp_id = dapp_id_of(&address); @@ -163,7 +328,7 @@ pub async fn deploy_multisig( }); } - let result = process_message( + let processed = process_message( ctx.clone(), ParamsOfProcessMessage { message_encode_params: encode_params, @@ -173,9 +338,36 @@ pub async fn deploy_multisig( // No progress events requested; an empty Send future satisfies the bound. |_| async {}, ) - .await?; + .await; - let deploy_tx = result.transaction.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); + let deploy_tx = match processed { + Ok(result) => result.transaction.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()), + // A failed *read-back* is not a failed deploy. `process_message` sends the + // message and then fetches the resulting transaction + // (`blockchain{transaction(hash){boc out_messages{boc}}}`); when that query + // fails, the deploy it just performed is reported as an error. Measured on + // shellnet with the `UpdateCustodianMultisigWallet` v2 build: the deploy + // lands (account Active, `exit_code: 0`, expected code hash) while the + // gateway answers that transaction query with HTTP 502 — reproducible with + // plain curl on the tx hash, because v2's ~7 KB of code makes for a much + // larger transaction BOC than the vendored build's. + // + // So before believing the error, ask the chain (bounded: 10 × 1 s, error + // path only, even when `wait_for_active` is false — a wrong failure is + // worse than a short wait). Only the matching build + owner key can make + // *this* address Active (it's a state-init hash), so Active here can only + // be our own deploy. The tx id is unknowable at that point -> `None`. + Err(err) => { + let landed = account + .wait(ParamsOfWaitAccount { status: AccountStatus::Active, ..Default::default() }) + .await + .is_ok(); + if !landed { + return Err(AppError::from(err)); + } + None + } + }; if wait_for_active { account @@ -205,8 +397,10 @@ pub struct ParamsOfDeployMultisigViaGiver { /// Constructor `value` arg (`uint64` as string). Default `"0"`. #[serde(default)] pub constructor_value: Option, - /// Native top-up of the future address (u64 as string). Default - /// `"10000000000"`. + /// SHELL-ECC funding of the future address via the flag-16 creation + /// transfer (u64 as string; collapses into native balance). Default + /// `"1000000000000000"` (1,000,000 SHELL). NB this rides on ECC, not native + /// `value`, which can't cross dapp_id. #[serde(default)] pub giver_value: Option, /// ECC top-up `{ currency_id: amount(u64-string) }`. Default `{}`. @@ -215,6 +409,80 @@ pub struct ParamsOfDeployMultisigViaGiver { /// Wait for the deployed account to reach Active. Default `true`. #[serde(default)] pub wait_for_active: Option, + /// Build override: a vendored build's name (`"update_custodian_v2"`) or + /// your own `{ tvc_b64, abi }`. Absent → the default vendored build. + #[serde(default)] + pub code: Option, +} + +/// Wire form of a build selection: either a build vendored in this SDK, picked +/// by name, or a caller-supplied pair. The pair is always both halves — see +/// [`MultisigCode`] for why they can't be supplied independently. +#[derive(Debug, Clone)] +pub enum ParamsOfMultisigCode { + /// A build vendored here, e.g. `"update_custodian_v2"`. + Named(String), + /// Your own build: base64 `.tvc` (line wrapping tolerated) plus that + /// build's `.abi.json`, either stringified or as the already-parsed object + /// (JS callers usually hold the latter — `import abi from "…json"`). + Custom { tvc_b64: String, abi: serde_json::Value }, +} + +/// Hand-written so the shape errors name the actual mistake. A derived +/// `#[serde(untagged)]` enum answers every malformed `code` with "data did not +/// match any variant", which is useless precisely where the caller needs help — +/// e.g. sending `{ tvc_b64 }` and forgetting the ABI, the one mistake that +/// would otherwise put a build at an address whose storage layout it disagrees +/// with. +impl<'de> Deserialize<'de> for ParamsOfMultisigCode { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(name) => Ok(Self::Named(name)), + serde_json::Value::Object(mut map) => { + let tvc_b64 = map.remove("tvc_b64"); + let abi = map.remove("abi"); + match (tvc_b64, abi) { + (Some(serde_json::Value::String(tvc_b64)), Some(abi)) => { + Ok(Self::Custom { tvc_b64, abi }) + } + (Some(_), None) => Err(D::Error::custom( + "`code.abi` is missing — a build's code and ABI must be supplied \ + together, from the same build", + )), + (None, Some(_)) => Err(D::Error::custom( + "`code.tvc_b64` is missing — a build's code and ABI must be supplied \ + together, from the same build", + )), + (Some(_), Some(_)) => { + Err(D::Error::custom("`code.tvc_b64` must be a base64 string")) + } + (None, None) => Err(D::Error::custom( + "`code` object must be `{ tvc_b64, abi }`; omit `code` for the default \ + build or pass a vendored build's name", + )), + } + } + other => Err(D::Error::custom(format!( + "`code` must be a vendored build's name or `{{ tvc_b64, abi }}`, got {other}" + ))), + } + } +} + +impl TryFrom for MultisigCode { + type Error = AppError; + + fn try_from(params: ParamsOfMultisigCode) -> AppResult { + match params { + ParamsOfMultisigCode::Named(name) => MultisigCode::by_name(&name), + ParamsOfMultisigCode::Custom { tvc_b64, abi } => { + Ok(Self { tvc: decode_tvc_b64(&tvc_b64)?, abi: abi_to_json_string(abi)? }) + } + } + } } /// Result of [`deploy_multisig_via_giver`]. @@ -249,6 +517,99 @@ fn parse_amount(field: &str, value: &str) -> AppResult { .map_err(|e| AppError::new(format!("invalid {field} amount `{value}`: {e}"))) } +/// Decodes a base64 `.tvc` override. Whitespace is stripped first: `.tvc.b64` +/// files (and anything that round-tripped through a terminal) are usually line +/// wrapped, and strict base64 would reject the newlines. +fn decode_tvc_b64(b64: &str) -> AppResult> { + let compact: String = b64.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD + .decode(&compact) + .map_err(|e| AppError::new(format!("invalid `tvc_b64` (expected a base64 `.tvc`): {e}"))) +} + +/// Normalizes an `abi` override to the JSON text tvm_client wants, accepting +/// both the stringified ABI and the parsed object. +fn abi_to_json_string(abi: serde_json::Value) -> AppResult { + match abi { + serde_json::Value::String(s) => Ok(s), + object @ serde_json::Value::Object(_) => Ok(object.to_string()), + other => Err(AppError::new(format!( + "`abi` override must be a JSON object or a stringified ABI, got {}", + match other { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a boolean", + serde_json::Value::Number(_) => "a number", + _ => "an array", + } + ))), + } +} + +/// 429 / throttling signal. tvm_client strips HTTP headers before surfacing +/// errors, so the only things that survive are the numeric `server_code` (from +/// GraphQL extensions) and the message text — match on those. Used to tag the +/// terminal error as `rate_limited`. +fn is_rate_limited(err: &AppError) -> bool { + let hit = |s: &str| { + let s = s.to_ascii_lowercase(); + s.contains("server_code: 429") || s.contains("too many requests") + }; + hit(&err.message) || err.details.as_deref().is_some_and(hit) +} + +/// Retry predicate for on-chain sends: the HTTP-transient classes +/// (429 / 5xx / 408 / 425 / resets / timeouts) plus the explicit 429 signal. +fn send_should_retry(err: &AppError) -> bool { + crate::infra::is_retryable_http_transient(err) || is_rate_limited(err) +} + +/// Maps the error left after retries are exhausted. A throttled send becomes a +/// typed `rate_limited` error whose message starts with a stable `RateLimited:` +/// prefix, so the UI can show "busy, try later" instead of a generic transport +/// failure. Anything else just gets `context` prepended (legacy behavior). +fn tag_terminal_send_error(err: AppError, context: &str) -> AppError { + if is_rate_limited(&err) { + let mut tagged = AppError::new(format!("RateLimited: {context}: {}", err.message)) + .with_kind("rate_limited"); + if let Some(details) = err.details { + tagged = tagged.with_details(details); + } + tagged + } else { + err.with_context(context) + } +} + +/// Runs one on-chain send under `policy` + the shared rate limiter (acquired +/// before every attempt, so retries count against the rps budget), retagging an +/// exhausted-retry failure via [`tag_terminal_send_error`]. +async fn send_with_retry_policy( + policy: &RetryPolicy, + rl: Option<&RateLimiter>, + context: &str, + op: F, +) -> AppResult +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + with_retry_policy(policy, rl, send_should_retry, op) + .await + .map_err(|e| tag_terminal_send_error(e, context)) +} + +/// [`send_with_retry_policy`] with the default HTTP policy: 5 attempts, 60 s +/// total cap, 500 ms→30 s exponential backoff + jitter. `Retry-After` is +/// invisible at the tvm_client layer (headers stripped), so backoff substitutes +/// — the consumer's frontend is the only layer that can honor `Retry-After`. +async fn send_with_retry(rl: Option<&RateLimiter>, context: &str, op: F) -> AppResult +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + send_with_retry_policy(&RetryPolicy::http_default(), rl, context, op).await +} + /// Fully client-side flat-Multisig deploy on shellnet: compute the address /// (brick 1), fund it from the default giver (brick 2, **shellnet only**), then /// deploy (brick 3). Idempotent — an already-Active address skips funding and @@ -277,6 +638,10 @@ pub async fn deploy_multisig_via_giver( req_confirms: params.req_confirms.unwrap_or(1), req_confirms_data: params.req_confirms_data.unwrap_or(1), constructor_value: params.constructor_value.unwrap_or_else(|| "0".to_string()), + // Build override (absent = vendored build). Decoded and validated before + // any giver money moves: `compute_multisig_address` below runs + // `spec.validate()`, so a bad override fails without spending anything. + code: params.code.map(MultisigCode::try_from).transpose()?, }; let address = compute_multisig_address(ctx.clone(), &spec).await?; @@ -295,38 +660,87 @@ pub async fn deploy_multisig_via_giver( }); } - // Brick 2 — fund the future address from the default (shellnet) giver. - // - // The address does not exist yet. On Acki Nacki a fresh account is brought - // into existence by a flag-16 currency transfer carrying SHELL (ECC[2], the - // gas currency); a plain native `value` transfer does not create it. So the - // gas top-up goes into the ECC map under SHELL, alongside any extra ECC the - // caller asked for. `giver_value` is that SHELL amount. + // Brick 2 — bring the future address into existence + fund it. Flag-16 is + // REQUIRED to create a not-yet-existing account (a native-only / flag-1 + // transfer does NOT — proven: it times out on `Wait … Uninit`). Funding goes + // as SHELL *ECC* (`giver_value`), NOT native `value`: native value doesn't + // cross dapp_id and a fresh account is the root of its own dapp. Flag 16 + // collapses the ECC SHELL into the new account's native balance — that's its + // gas. The real held ECC top-up happens AFTER deploy (Brick 4). let giver_value = match params.giver_value { Some(v) => parse_amount("giver_value", &v)?, None => DEFAULT_GIVER_VALUE, }; - let mut giver_ecc = HashMap::new(); - giver_ecc.insert(SHELL_CURRENCY_ID, giver_value); - // Caller-specified ECC overrides/extends the default SHELL gas top-up. - for (currency, amount) in params.giver_ecc.unwrap_or_default() { - giver_ecc.insert(currency, parse_amount("giver_ecc", &amount)?); - } - send_currency_with_flag_from_default_giver(ctx.clone(), &address, 0, giver_ecc, GIVER_FLAG) - .await - .map_err(|e| { - AppError::from(e) - .with_context("giver top-up failed (giver is available on shellnet only)") - })?; + // Shared limiter for ALL sends below (create → deploy → ECC top-up) plus + // their retries: ≤2 sends/s globally, ≥500 ms apart. Keeps us under the + // node's ~3-sends/s throttle so a multivalue funded deploy doesn't burst. + let rl = Some(RateLimiter::new(MAX_SEND_RPS)); + + let mut create_ecc = HashMap::new(); + create_ecc.insert(SHELL_CURRENCY_ID, giver_value); + send_with_retry( + rl.as_ref(), + "giver account-creation failed (giver is available on shellnet only)", + || { + let ctx = ctx.clone(); + let create_ecc = create_ecc.clone(); + let address = address.clone(); + async move { + send_currency_with_flag_from_default_giver(ctx, &address, 0, create_ecc, GIVER_FLAG) + .await + .map_err(AppError::from) + } + }, + ) + .await?; // Wait until the value message lands and the account exists (Uninit). account .wait(ParamsOfWaitAccount { status: AccountStatus::Uninit, ..Default::default() }) .await?; - // Brick 3 — deploy now that the address is funded. - let outcome = deploy_multisig(ctx, &spec, params.wait_for_active.unwrap_or(true)).await?; + // Brick 3 — deploy now that the address is funded. Retried as a unit: it's + // idempotent (re-checks Active first), so a 429 mid-deploy just re-enters + // and either resends or returns the already-Active outcome. + let wait_for_active = params.wait_for_active.unwrap_or(true); + let outcome = send_with_retry(rl.as_ref(), "multisig deploy failed", || { + let ctx = ctx.clone(); + let spec = spec.clone(); + async move { deploy_multisig(ctx, &spec, wait_for_active).await } + }) + .await?; + + // Brick 4 — held ECC top-up (NACKL/SHELL/USDC) AFTER the multisig is deployed + // (Active), via flag-1 (NOT flag-16). To a live account flag-1 keeps every + // currency held — SHELL (ECC[2]) included — instead of collapsing into native. + let mut giver_ecc = HashMap::new(); + for (currency, amount) in params.giver_ecc.unwrap_or_default() { + giver_ecc.insert(currency, parse_amount("giver_ecc", &amount)?); + } + if !giver_ecc.is_empty() { + send_with_retry( + rl.as_ref(), + "giver ECC top-up failed (giver is available on shellnet only)", + || { + let ctx = ctx.clone(); + let giver_ecc = giver_ecc.clone(); + let address = address.clone(); + async move { + send_currency_with_flag_from_default_giver( + ctx, + &address, + ECC_TOPUP_GAS, + giver_ecc, + 1, + ) + .await + .map_err(AppError::from) + } + }, + ) + .await?; + } Ok(ResultOfDeployMultisigViaGiver { address: outcome.address, @@ -363,3 +777,353 @@ pub async fn multisig_balances( account.fetch().await?; Ok(account.ecc.iter().map(|(k, v)| (*k, v.to_string())).collect()) } + +// Native-only: the send retry/throttle behavior is what the shellnet 429 storm +// hit. The on-chain sends themselves need a live giver, but the retry decision, +// the throttle classifier, and the typed-error tagging are pure and tested +// here. +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use std::sync::atomic::AtomicU32; + use std::sync::atomic::Ordering; + use std::time::Duration; + + use super::*; + + /// A 429 as tvm_client would surface it once stripped to text. + fn err_429() -> AppError { + AppError::new("GraphQL request failed: server_code: 429 Too Many Requests") + } + + /// Spec with a fixed (bogus but well-formed) keypair — these tests never + /// sign, they only exercise build resolution and validation. + fn spec_with(code: Option) -> MultisigDeploySpec { + MultisigDeploySpec { + keys: KeyPair { public: "aa".repeat(32), secret: "bb".repeat(32) }, + owners_pubkey: vec![format!("0x{}", "aa".repeat(32))], + req_confirms: 1, + req_confirms_data: 1, + constructor_value: "0".to_string(), + code, + } + } + + /// Build override with placeholder code and the given ABI. + fn code_with_abi(abi: impl Into) -> Option { + Some(MultisigCode { tvc: vec![1, 2, 3], abi: abi.into() }) + } + + /// Minimal ABI shaped like a *newer* multisig build: same constructor, + /// extra functions (v2 adds `submitUpdateCode` / `confirmUpdateCode`). + fn superset_abi() -> String { + json!({ + "ABI version": 2, + "version": "2.4", + "header": ["pubkey", "time", "expire"], + "functions": [ + { "name": "constructor", "inputs": CONSTRUCTOR_INPUTS + .iter() + .map(|name| json!({ "name": name, "type": "uint64" })) + .collect::>(), "outputs": [] }, + { "name": "submitUpdateCode", + "inputs": [{ "name": "newcode", "type": "cell" }], + "outputs": [{ "name": "codeUpdateId", "type": "uint64" }] }, + ], + }) + .to_string() + } + + /// Fast policy for tests: real backoff math, 1 ms sleeps, no jitter. + fn fast_policy(max_attempts: u32) -> RetryPolicy { + RetryPolicy { + max_attempts, + max_total: None, + base_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(1), + jitter: false, + } + } + + #[test] + fn default_giver_value_is_one_million_shell() { + // Cross-dapp funding rides on ECC, so the multisig's native gas comes + // from this flag-16 SHELL-ECC amount (collapsed to native). ECC_TOPUP_GAS + // is exactly 1 SHELL (10^9 nano), so this pins the zero-count against a + // typo in either constant. + assert_eq!(DEFAULT_GIVER_VALUE, 1_000_000 * ECC_TOPUP_GAS); + assert_eq!(DEFAULT_GIVER_VALUE, 1_000_000_000_000_000); + } + + #[test] + fn absent_override_resolves_to_the_vendored_build() { + let spec = spec_with(None); + assert_eq!(spec.code_bytes(), MULTISIG_TVC); + assert_eq!(spec.abi_json(), MULTISIG_ABI); + spec.validate().expect("the default spec must always be valid"); + } + + #[test] + fn override_replaces_the_vendored_build() { + let spec = spec_with(code_with_abi(superset_abi())); + assert_eq!(spec.code_bytes(), &[1, 2, 3]); + assert_eq!(spec.abi_json(), superset_abi()); + // Extra functions on top of an identical constructor is exactly the v2 + // case: accepted. + spec.validate().expect("superset ABI must be accepted"); + } + + /// Pins [`CONSTRUCTOR_INPUTS`] against the vendored asset: if the two ever + /// drift, every ABI override would be rejected (or a wrong one accepted). + #[test] + fn vendored_abi_satisfies_the_override_constructor_check() { + spec_with(code_with_abi(MULTISIG_ABI)) + .validate() + .expect("vendored ABI must pass the same check overrides face"); + } + + #[test] + fn malformed_overrides_are_rejected_before_any_network_call() { + let err = |spec: MultisigDeploySpec| spec.validate().unwrap_err().message; + + let empty_tvc = MultisigCode { tvc: Vec::new(), abi: MULTISIG_ABI.to_string() }; + assert!(err(spec_with(Some(empty_tvc))).contains("empty `tvc`")); + assert!(err(spec_with(code_with_abi("not json"))).contains("not valid JSON")); + assert!(err(spec_with(code_with_abi(json!({ "functions": [] }).to_string()))) + .contains("no `constructor`")); + + // Right shape, wrong constructor: our hardcoded input JSON couldn't be + // encoded against it, so it must fail here rather than on-chain. + let renamed = json!({ + "functions": [{ + "name": "constructor", + "inputs": [{ "name": "owners", "type": "uint256[]" }], + "outputs": [], + }], + }); + let message = err(spec_with(code_with_abi(renamed.to_string()))); + assert!(message.contains("incompatible constructor"), "got: {message}"); + assert!(message.contains("owners_pubkey"), "must name what was expected, got: {message}"); + } + + /// The wire form can only produce a *pair*: a caller who sends just one + /// half gets an error naming the missing one, not a silently mismatched + /// build. This is the invariant that keeps v2 code away from v1's ABI. + #[test] + fn wire_code_override_requires_both_halves() { + let tvc_b64 = base64::engine::general_purpose::STANDARD.encode(MULTISIG_TVC); + + let complete: ParamsOfMultisigCode = + serde_json::from_value(json!({ "tvc_b64": tvc_b64, "abi": MULTISIG_ABI })).unwrap(); + let code = MultisigCode::try_from(complete).expect("complete pair must convert"); + assert_eq!(code.tvc, MULTISIG_TVC); + assert_eq!(code.abi, MULTISIG_ABI); + + let error = |value: serde_json::Value| { + serde_json::from_value::(value) + .expect_err("half a build must not deserialize") + .to_string() + }; + assert!(error(json!({ "tvc_b64": tvc_b64 })).contains("`code.abi` is missing")); + assert!(error(json!({ "abi": MULTISIG_ABI })).contains("`code.tvc_b64` is missing")); + assert!(error(json!(42)).contains("must be a vendored build's name")); + } + + /// The vendored v2 asset must be the artifact from acki-nacki#2413 — pinned + /// by content hash so a swapped or truncated file fails here instead of + /// putting unknown code on-chain. (The matching *code* hash, which is what + /// the node reports, is asserted in the integration tests.) + #[test] + fn vendored_v2_asset_is_pinned() { + use sha2::Digest; + let digest = sha2::Sha256::digest(UPDATE_CUSTODIAN_V2_TVC); + assert_eq!(hex::encode(digest), UPDATE_CUSTODIAN_V2_TVC_SHA256); + + // And it must satisfy the same override checks a caller's build faces. + spec_with(Some(MultisigCode::update_custodian_v2())) + .validate() + .expect("vendored v2 build must be valid"); + } + + /// `code: "update_custodian_v2"` must resolve to the vendored v2 pair, and + /// an unknown name must say what is available instead of failing later. + #[test] + fn named_builds_resolve_by_wire_name() { + let named: ParamsOfMultisigCode = + serde_json::from_value(json!(UPDATE_CUSTODIAN_V2_NAME)).unwrap(); + let code = MultisigCode::try_from(named).expect("named build must resolve"); + assert_eq!(code.tvc, UPDATE_CUSTODIAN_V2_TVC); + assert_eq!(code.abi, UPDATE_CUSTODIAN_V2_ABI); + // v2 is a genuinely different build from the default. + assert_ne!(code.tvc, MULTISIG_TVC); + assert_ne!(code.abi, MULTISIG_ABI); + + let unknown: ParamsOfMultisigCode = serde_json::from_value(json!("v3")).unwrap(); + let message = MultisigCode::try_from(unknown).unwrap_err().message; + assert!(message.contains("unknown multisig build `v3`"), "got: {message}"); + assert!(message.contains(UPDATE_CUSTODIAN_V2_NAME), "must list what exists: {message}"); + } + + /// v2's ABI is a superset of the default's by function set — but its + /// `fields` add storage slots, which is why the two travel together. + /// Pins the fact behind [`MultisigCode`]'s pairing. + #[test] + fn vendored_v2_abi_adds_functions_and_storage_fields() { + let names = |abi: &str| -> Vec { + let parsed: serde_json::Value = serde_json::from_str(abi).unwrap(); + parsed["functions"] + .as_array() + .unwrap() + .iter() + .map(|f| f["name"].as_str().unwrap().to_string()) + .collect() + }; + let fields = |abi: &str| -> Vec { + let parsed: serde_json::Value = serde_json::from_str(abi).unwrap(); + parsed["fields"] + .as_array() + .unwrap() + .iter() + .map(|f| f["name"].as_str().unwrap().to_string()) + .collect() + }; + + let (default_fns, v2_fns) = (names(MULTISIG_ABI), names(UPDATE_CUSTODIAN_V2_ABI)); + for name in &default_fns { + assert!(v2_fns.contains(name), "v2 must keep `{name}`"); + } + for added in ["submitUpdateCode", "confirmUpdateCode"] { + assert!(v2_fns.contains(&added.to_string()), "v2 must add `{added}`"); + assert!(!default_fns.contains(&added.to_string())); + } + + let (default_fields, v2_fields) = (fields(MULTISIG_ABI), fields(UPDATE_CUSTODIAN_V2_ABI)); + for added in ["m_requestsMaskCode", "m_code"] { + assert!(v2_fields.contains(&added.to_string()), "v2 must add field `{added}`"); + assert!(!default_fields.contains(&added.to_string())); + } + assert_ne!(default_fields, v2_fields, "differing `fields` ⇒ differing address"); + } + + #[test] + fn tvc_b64_decodes_and_tolerates_line_wrapping() { + let raw = MULTISIG_TVC; + let b64 = base64::engine::general_purpose::STANDARD.encode(raw); + assert_eq!(decode_tvc_b64(&b64).unwrap(), raw); + + // Line-wrapped (as `base64 < file` emits) must decode identically. + let wrapped = + b64.as_bytes().chunks(76).map(String::from_utf8_lossy).collect::>().join("\n"); + assert_eq!(decode_tvc_b64(&wrapped).unwrap(), raw); + + let err = decode_tvc_b64("!!! not base64 !!!").unwrap_err().message; + assert!(err.contains("invalid `tvc_b64`"), "got: {err}"); + } + + #[test] + fn abi_override_accepts_both_string_and_object() { + // Stringified: passed through verbatim. + let as_string = json!("{\"functions\":[]}"); + assert_eq!(abi_to_json_string(as_string).unwrap(), "{\"functions\":[]}"); + + // Parsed object (what a JS `import abi from "./x.abi.json"` yields): + // re-serialized, and must survive the round-trip as the same JSON. + let as_object: serde_json::Value = serde_json::from_str(MULTISIG_ABI).unwrap(); + let rendered = abi_to_json_string(as_object.clone()).unwrap(); + assert_eq!(serde_json::from_str::(&rendered).unwrap(), as_object); + + let err = abi_to_json_string(json!([1, 2])).unwrap_err().message; + assert!(err.contains("must be a JSON object or a stringified ABI"), "got: {err}"); + } + + #[test] + fn is_rate_limited_recognizes_429_in_message_and_details() { + assert!(is_rate_limited(&err_429())); + assert!(is_rate_limited( + &AppError::new("boom").with_details("nested: 429 too many requests") + )); + // Not a throttle: + assert!(!is_rate_limited(&AppError::new("connection reset by peer"))); + assert!(!is_rate_limited(&AppError::new("server_code: 500 internal"))); + } + + #[test] + fn send_should_retry_covers_429_and_5xx_but_not_hard_errors() { + assert!(send_should_retry(&err_429())); + assert!(send_should_retry(&AppError::new("server_code: 503 service unavailable"))); + assert!(!send_should_retry(&AppError::new("invalid giver_value amount `x`"))); + } + + #[test] + fn tag_terminal_send_error_marks_throttle_with_prefix() { + let tagged = tag_terminal_send_error(err_429(), "giver account-creation failed"); + assert_eq!(tagged.kind.as_deref(), Some("rate_limited")); + assert!(tagged.message.starts_with("RateLimited:"), "got: {}", tagged.message); + } + + #[test] + fn tag_terminal_send_error_passes_through_non_throttle() { + let tagged = tag_terminal_send_error(AppError::new("hard failure"), "ctx"); + assert_ne!(tagged.kind.as_deref(), Some("rate_limited")); + assert!(tagged.message.starts_with("ctx:"), "got: {}", tagged.message); + } + + #[tokio::test] + async fn retries_throttled_send_then_succeeds() { + let calls = AtomicU32::new(0); + let res: AppResult = send_with_retry_policy(&fast_policy(5), None, "ctx", || { + let n = calls.fetch_add(1, Ordering::SeqCst); + async move { + if n < 2 { + Err(err_429()) + } else { + Ok(7) + } + } + }) + .await; + assert_eq!(res.unwrap(), 7); + assert_eq!(calls.load(Ordering::SeqCst), 3, "two 429s then success"); + } + + #[tokio::test] + async fn exhausted_throttle_returns_typed_rate_limited() { + let calls = AtomicU32::new(0); + let res: AppResult = + send_with_retry_policy(&fast_policy(3), None, "giver ECC top-up failed", || { + calls.fetch_add(1, Ordering::SeqCst); + async move { Err::(err_429()) } + }) + .await; + let err = res.unwrap_err(); + assert_eq!(err.kind.as_deref(), Some("rate_limited")); + assert!(err.message.starts_with("RateLimited:"), "got: {}", err.message); + assert_eq!(calls.load(Ordering::SeqCst), 3, "all attempts spent"); + } + + #[tokio::test] + async fn non_retryable_error_is_not_retried() { + let calls = AtomicU32::new(0); + let res: AppResult = send_with_retry_policy(&fast_policy(5), None, "ctx", || { + calls.fetch_add(1, Ordering::SeqCst); + async move { Err::(AppError::new("hard failure")) } + }) + .await; + assert!(res.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 1, "hard error must not retry"); + } + + #[tokio::test] + async fn rate_limiter_serializes_sends_to_the_configured_interval() { + // 5 rps ⇒ ~200 ms min spacing; 3 acquisitions ⇒ ≥2 intervals elapsed. + let rl = RateLimiter::new(5); + let start = std::time::Instant::now(); + for _ in 0..3 { + rl.acquire().await; + } + assert!( + start.elapsed() >= Duration::from_millis(380), + "3 sends at 5rps should span ≥2 intervals (~400ms), got {:?}", + start.elapsed() + ); + } +} diff --git a/bee_wallet/tests/dex_flows/main.rs b/bee_wallet/tests/dex_flows/main.rs index 92fd11b..2d57425 100644 --- a/bee_wallet/tests/dex_flows/main.rs +++ b/bee_wallet/tests/dex_flows/main.rs @@ -19,6 +19,9 @@ // `common/` is a shared helper module: each test uses a subset, so some // helpers are unused from any single flow's point of view. #![allow(dead_code)] +// Native-only (heavy halo2 + dodex-sdk graph). Empty crate on wasm32 so +// `wasm-pack test --tests` doesn't try to build it. +#![cfg(not(target_arch = "wasm32"))] mod common; mod flows; diff --git a/bee_wallet/tests/integration.rs b/bee_wallet/tests/integration.rs index 7c5d175..f283b7b 100644 --- a/bee_wallet/tests/integration.rs +++ b/bee_wallet/tests/integration.rs @@ -2,6 +2,11 @@ // Run sequentially to avoid flaky failures from queue contention: // // cargo test -p bee-wallet -- --test-threads=1 +// +// Native-only: pulls the tvm/kit graph and its native dev-deps live behind +// `cfg(not(target_arch = "wasm32"))`. Compile to an empty crate on wasm32 so +// `wasm-pack test` (which forces `--tests`) doesn't try to build them. +#![cfg(not(target_arch = "wasm32"))] use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; @@ -4322,6 +4327,8 @@ async fn test_deploy_multisig_via_giver() { giver_value: None, giver_ecc: None, wait_for_active: Some(true), + // Vendored build; the override path is covered by its own test below. + code: None, }) .await .expect("deploy_multisig_via_giver failed"); @@ -4369,6 +4376,7 @@ async fn test_deploy_multisig_via_giver() { giver_value: None, giver_ecc: None, wait_for_active: Some(true), + code: None, }) .await .expect("idempotent re-deploy failed"); @@ -4402,6 +4410,7 @@ async fn test_compute_multisig_address_deterministic() { req_confirms: 1, req_confirms_data: 1, constructor_value: "0".to_string(), + code: None, }; let a = @@ -4420,8 +4429,147 @@ async fn test_compute_multisig_address_deterministic() { req_confirms: 1, req_confirms_data: 1, constructor_value: "0".to_string(), + code: None, }; let c = bee_wallet::compute_multisig_address(ctx, &other_spec).await.expect("compute address c"); assert_ne!(a, c, "different keys must yield a different address"); } + +/// Code hash of the vendored `UpdateCustodianMultisigWallet` v2.1.0 build +/// (acki-nacki#2413, `sold 0.81.0`) — what a node reports for an account +/// running it. +const UPDATE_CUSTODIAN_V2_CODE_HASH: &str = + "09f596d5bb4f63d7f2b18020ee0b7c9e88114dc90010389cc594c67954655ded"; + +/// Brick 1 under a build override: the vendored pair is a *default*, not a +/// wiring. Four properties, measured against the real encoder: +/// +/// 1. handing the vendored pair back explicitly lands on the very same address +/// (byte-faithful plumbing); +/// 2. different code moves the address — this is what proves the override +/// reaches state-init derivation, so a caller deploying another build (e.g. +/// `UpdateCustodianMultisigWallet` v2) funds the address it will occupy; +/// 3. a different *ABI* moves it too: on ABI >= 2.3 the state-init data cell is +/// rebuilt from the ABI's `fields`, which is precisely why `MultisigCode` +/// pairs code with ABI instead of exposing two independent knobs; +/// 4. a malformed override fails locally, before any encode or send. +#[tokio::test] +async fn test_compute_multisig_address_honors_build_override() { + let ctx = create_tvm_context(); + let keys = ackinacki_kit::tvm_client::crypto::generate_random_sign_keys(ctx.clone()) + .expect("generate keys"); + let vendored_tvc = std::fs::read("assets/multisig/Multisig.tvc").expect("read vendored tvc"); + let vendored_abi = + std::fs::read_to_string("assets/multisig/Multisig.abi.json").expect("read vendored abi"); + + let spec = |code: Option| bee_wallet::MultisigDeploySpec { + keys: keys.clone(), + owners_pubkey: vec![format!("0x{}", keys.public)], + req_confirms: 1, + req_confirms_data: 1, + constructor_value: "0".to_string(), + code, + }; + + let default_address = bee_wallet::compute_multisig_address(ctx.clone(), &spec(None)) + .await + .expect("compute default address"); + + // 1. Explicit override == the vendored pair. + let vendored = + bee_wallet::MultisigCode { tvc: vendored_tvc.clone(), abi: vendored_abi.clone() }; + let explicit = bee_wallet::compute_multisig_address(ctx.clone(), &spec(Some(vendored))) + .await + .expect("compute address for explicit vendored override"); + assert_eq!(default_address, explicit, "the explicit vendored pair must match the default"); + + // 2. The other vendored build (v2) is a different build: different address. + let v2 = bee_wallet::MultisigCode::update_custodian_v2(); + let v2_address = bee_wallet::compute_multisig_address(ctx.clone(), &spec(Some(v2.clone()))) + .await + .expect("compute v2 address"); + assert_ne!(default_address, v2_address, "v2 must not land on the default build's address"); + + // 3. v2's code with the *default* ABI derives yet another address — the + // mismatch `MultisigCode` exists to prevent. v2's ABI is a strict superset by + // function set, so if the ABI were only a calling convention these two would + // agree; they don't, because its `fields` add `m_requestsMaskCode`/`m_code` + // and `fields` rebuild the state-init data cell before it is hashed. + let mismatched = bee_wallet::MultisigCode { tvc: v2.tvc.clone(), abi: vendored_abi.clone() }; + let mismatched_address = + bee_wallet::compute_multisig_address(ctx.clone(), &spec(Some(mismatched))) + .await + .expect("compute mismatched address"); + assert_ne!( + v2_address, mismatched_address, + "same code + another build's ABI must NOT resolve to the same address", + ); + + // 4. The vendored v2 code really is the artifact from acki-nacki#2413 — the + // code hash a node will report for it. + let decoded = ackinacki_kit::tvm_client::boc::decode_state_init( + ctx.clone(), + ackinacki_kit::tvm_client::boc::ParamsOfDecodeStateInit { + state_init: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v2.tvc), + boc_cache: None, + }, + ) + .expect("decode v2 state init"); + assert_eq!(decoded.code_hash.as_deref(), Some(UPDATE_CUSTODIAN_V2_CODE_HASH)); + assert_eq!(decoded.compiler_version.as_deref(), Some("sol 0.81.0")); + + // 5. Malformed override: refused locally, before any encode or send. + let empty = bee_wallet::MultisigCode { tvc: Vec::new(), abi: vendored_abi }; + let err = bee_wallet::compute_multisig_address(ctx, &spec(Some(empty))) + .await + .expect_err("empty tvc override must be rejected"); + assert!(err.message.contains("empty `tvc`"), "got: {}", err.message); +} + +/// End-to-end on the *second* vendored build: deploy +/// `UpdateCustodianMultisigWallet` v2 by name (`code: "update_custodian_v2"`, +/// the shape a frontend uses) and confirm the node runs v2's code at the +/// derived address. Guards the whole chain — asset → named lookup → address +/// derivation → funding → deploy. +#[tokio::test] +async fn test_deploy_multisig_via_giver_update_custodian_v2() { + let result = + bee_wallet::deploy_multisig_via_giver(bee_wallet::ParamsOfDeployMultisigViaGiver { + endpoints: shellnet_endpoints(), + keys: None, + owners_pubkey: None, + req_confirms: None, + req_confirms_data: None, + constructor_value: None, + giver_value: None, + giver_ecc: None, + wait_for_active: Some(true), + code: Some(serde_json::from_value(serde_json::json!("update_custodian_v2")).unwrap()), + }) + .await + .expect("v2 deploy via giver failed"); + + println!( + "deployed v2 multisig: address={} already_deployed={} tx={:?}", + result.address, result.already_deployed, result.deploy_tx + ); + + let (dapp, account) = result.address.split_once("::").expect("address must be ::"); + let mut deployed = ackinacki_kit::contracts::account::Account::new( + create_tvm_context(), + &format!("0:{account}"), + dapp.to_string(), + ); + deployed.fetch().await.expect("fetch deployed v2 multisig"); + assert_eq!( + deployed.acc_type, + ackinacki_kit::contracts::account::AccountStatus::Active, + "v2 multisig should be Active after deploy", + ); + assert_eq!( + deployed.code_hash.as_deref(), + Some(UPDATE_CUSTODIAN_V2_CODE_HASH), + "the deployed account must be running v2's code, not the default build's", + ); +}