diff --git a/AGENTS.md b/AGENTS.md index 993e3183..b76829d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,14 @@ Key external dependencies from the Zcash ecosystem: - `zebra-chain`, `zebra-state`, `zebra-rpc` -- chain data types and node RPC - `zaino-*` -- indexer integration +## Code Conventions + +- **Never use magic numbers.** Do not inline a bare numeric (or string) literal + whose meaning is not obvious from context. Give it a `const` with a + doc-commented rationale, and reuse the protocol's named constants (`COIN`, the + ZIP-317 `MARGINAL_FEE`, and the migration engine's exported constants) rather + than re-deriving their values. This applies to production code and tests alike. + ## Build, Test, and Development Commands The three workspaces have separate lockfiles, so every check runs once **per diff --git a/CHANGELOG.md b/CHANGELOG.md index a74ba014..8ca965fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,40 @@ be considered breaking changes. indicating coinbase origin (mirroring `zcashd`'s `listunspent`), so integrators no longer need a `getrawtransaction` round-trip per UTXO to distinguish coinbase from spendable-to-transparent funds. +- `zallet_core::migrate`, an integration point that wires in the + backend-agnostic value-pool migration engine (`zcash_pool_migration_backend`). + It re-exports the engine and provides `SpendableSnapshot`, Zallet's + implementation of the engine's `MigrationBackend` trait for the planning + slice. The engine is still evolving upstream and is pinned to a librustzcash + feature branch, so only the planning path is wired; committing a migration + (building, signing, and persisting the PCZTs) awaits later engine slices. +- A generic pool-to-pool migration JSON-RPC surface (wallet build): the + `z_startpoolmigration`, `z_getpoolmigrationstatus`, `z_advancepoolmigration`, + `z_cancelpoolmigration`, and `z_listpoolmigrations` methods. The surface is + parameterised by a `from_pool`/`to_pool` pair rather than hardcoding a + specific migration; a single supported-migrations table maps each pool pair to + the network upgrade that enables it (Orchard to Ironwood requires NU6.3). These + methods are currently a scaffold: they validate their inputs (pool parsing, the + supported-pair table, and network-upgrade activation) but the migration engine + is not yet wired in, so they return a "not implemented yet" error. +- An external-signer surface for the pool migration (wallet build), so a + hardware or offline signer can sign a migration's transactions out of band: + `z_startpoolmigration` takes an `external_signer` flag that builds the + preparation unsigned and returns its PCZTs; `z_buildpoolmigrationtransfers` + builds the phase-2 transfers unsigned once the preparation is mined; + `z_applypoolmigrationsignature` applies a signed PCZT to its transaction + (moving it to signed, after which `z_advancepoolmigration` proves and + broadcasts it unchanged); and `z_signpoolmigrationpczt` signs a migration PCZT + with the account's spend key for offline / air-gapped signing. Building still + runs in process (it needs only the viewing key and witnesses); only the + signature is external. +- `z_previewpoolmigration` (wallet build), the fully-wired planning preview of + the pool-migration surface. It enumerates the account's spendable source-pool + notes and runs the engine's `plan_migration` to return the proposed plan for + user consent (ZIP 318): the funding notes and their crossing denominations, + each note's transfer broadcast height and expiry, the note-preparation + transaction summary, and the residual left in the source pool. It is + read-only; nothing is built, proved, or broadcast. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 65b89176..09f62f65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,6 +720,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1097,6 +1098,40 @@ dependencies = [ "petgraph 0.7.1", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -1304,7 +1339,7 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "equihash" version = "0.3.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", "corez", @@ -1339,7 +1374,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", ] @@ -3136,6 +3171,34 @@ dependencies = [ "password-hash", ] +[[package]] +name = "pczt" +version = "0.8.0-rc.1" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "blake2b_simd", + "bls12_381", + "document-features", + "ff", + "getset", + "jubjub", + "nonempty", + "orchard 0.15.0", + "pasta_curves", + "postcard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "serde", + "serde_with", + "zcash_note_encryption", + "zcash_primitives 0.29.0", + "zcash_protocol 0.10.0", + "zcash_script", + "zcash_transparent 0.9.0", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4133,6 +4196,34 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serdect" version = "0.2.0" @@ -5680,6 +5771,7 @@ dependencies = [ "nonempty", "once_cell", "orchard 0.15.0", + "pczt", "phf", "proptest", "quote", @@ -5721,6 +5813,8 @@ dependencies = [ "zcash_encoding", "zcash_keys 0.15.0", "zcash_note_encryption", + "zcash_pool_migration_backend", + "zcash_pool_migration_sqlite", "zcash_primitives 0.29.0", "zcash_proofs", "zcash_protocol 0.10.0", @@ -5749,7 +5843,7 @@ dependencies = [ [[package]] name = "zcash_address" version = "0.13.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bs58", @@ -5762,7 +5856,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.24.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -5812,7 +5906,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.22.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bip0039", @@ -5849,6 +5943,7 @@ dependencies = [ "zcash_client_backend", "zcash_encoding", "zcash_keys 0.15.0", + "zcash_pool_migration_sqlite", "zcash_primitives 0.29.0", "zcash_protocol 0.10.0", "zcash_script", @@ -5860,7 +5955,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.4.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "corez", "hex", @@ -5901,7 +5996,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.15.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bip0039", @@ -5944,6 +6039,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "zcash_pool_migration_backend" +version = "0.1.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "incrementalmerkletree", + "libm", + "orchard 0.15.0", + "pczt", + "rand_core 0.6.4", + "shardtree", + "zcash_client_backend", + "zcash_keys 0.15.0", + "zcash_primitives 0.29.0", + "zcash_protocol 0.10.0", +] + +[[package]] +name = "zcash_pool_migration_sqlite" +version = "0.1.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "rusqlite", + "uuid", + "zcash_pool_migration_backend", +] + [[package]] name = "zcash_primitives" version = "0.28.0" @@ -5977,7 +6099,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.29.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -6007,7 +6129,8 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.29.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91fb0b420fb66a765f1fa92ab7a6b631aa54c08415415ef6185256826e74fbd" dependencies = [ "bellman", "blake2b_simd", @@ -6042,7 +6165,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.10.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "corez", "document-features", @@ -6105,7 +6228,7 @@ dependencies = [ [[package]] name = "zcash_transparent" version = "0.9.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bip32", "bs58", @@ -6284,7 +6407,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.9.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "base64 0.22.1", "nom 7.1.3", diff --git a/Cargo.toml b/Cargo.toml index ce5e1c96..a81ba219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,6 +150,30 @@ zcash_note_encryption = "0.4" zip32 = "0.2" bip32 = "0.2" +# Backend-agnostic value-pool migration engine (Orchard -> Ironwood, NU6.3). +# This crate is not yet published to crates.io; it lives on the +# `feat/pool-migration-engine` branch of librustzcash while its API is still +# evolving (it was `zcash_ironwood_migration_backend`, renamed upstream to the +# pool-agnostic `zcash_pool_migration_backend`). +# +# Pinned to a specific commit (not a bare branch) that carries the engine API +# this integration uses: the `MigrationBackend` trait (spendable notes + chain +# tip), the `PoolMigrationRead`/`PoolMigrationWrite` store traits, the +# `WalletMigration` wallet-backed adapter (the `wallet` feature), and +# `engine::{plan_migration, commit_preparation, commit_transfers}`. Bump the rev +# when the integration needs a newer engine API. +zcash_pool_migration_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42", features = ["wallet"] } +# The SQLite store for the migration (the pool_migrations tables + the +# PoolMigrationRead/Write impls over the wallet database). Same rev as the engine. +zcash_pool_migration_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +# PCZT roles used to prove and extract the migration's pre-signed transactions before broadcasting +# them (z_advancepoolmigration). Same rev as the engine, which produces the pre-signed PCZTs. +pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42", features = [ + "prover", + "tx-extractor", + "orchard", +] } + # Zcashd wallet migration zewif = { version = "1.0.0-rc.3" } zewif-zcashd = { version = "0.1.0-rc.3" } @@ -160,19 +184,24 @@ anyhow = "1.0" tonic = "0.14" [patch.crates-io] -# Replace with the crates.io releases once they ship. -equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_transparent = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } +# Temporary patch for `WalletDb::transactionally_with_extension` (adds atomic +# z_importkey account+key writes), merged upstream but not yet released. The API +# lives in zcash_client_sqlite, but the repo is a workspace: pinning only that +# crate pulls its path-dep siblings from git while their crates.io copies remain, +# duplicating the librustzcash stack, so the whole stack is pinned to one rev. +# Replace with the crates.io releases once the next zcash_client_sqlite release +# ships. +equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_transparent = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } [profile.release] debug = "line-tables-only" diff --git a/backends/zaino/Cargo.lock b/backends/zaino/Cargo.lock index 2fc6ad3c..11ffd188 100644 --- a/backends/zaino/Cargo.lock +++ b/backends/zaino/Cargo.lock @@ -1523,7 +1523,7 @@ dependencies = [ [[package]] name = "equihash" version = "0.3.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", "corez", @@ -1559,7 +1559,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", ] @@ -3701,6 +3701,34 @@ dependencies = [ "password-hash", ] +[[package]] +name = "pczt" +version = "0.8.0-rc.1" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "blake2b_simd", + "bls12_381", + "document-features", + "ff", + "getset", + "jubjub", + "nonempty 0.11.0", + "orchard 0.15.0", + "pasta_curves", + "postcard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "serde", + "serde_with", + "zcash_note_encryption", + "zcash_primitives 0.29.0", + "zcash_protocol 0.10.0", + "zcash_script", + "zcash_transparent 0.9.0", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -6918,6 +6946,7 @@ dependencies = [ "nix 0.29.0", "nonempty 0.11.0", "orchard 0.15.0", + "pczt", "phf", "quote", "rand 0.8.7", @@ -6956,6 +6985,8 @@ dependencies = [ "zcash_encoding", "zcash_keys 0.15.0", "zcash_note_encryption", + "zcash_pool_migration_backend", + "zcash_pool_migration_sqlite", "zcash_primitives 0.29.0", "zcash_proofs", "zcash_protocol 0.10.0", @@ -7013,7 +7044,7 @@ dependencies = [ [[package]] name = "zcash_address" version = "0.13.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bs58", @@ -7026,7 +7057,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.24.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -7076,7 +7107,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.22.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bip0039", @@ -7113,6 +7144,7 @@ dependencies = [ "zcash_client_backend", "zcash_encoding", "zcash_keys 0.15.0", + "zcash_pool_migration_sqlite", "zcash_primitives 0.29.0", "zcash_protocol 0.10.0", "zcash_script", @@ -7124,7 +7156,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.4.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "corez", "hex", @@ -7176,7 +7208,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.15.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bip0039", @@ -7219,6 +7251,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "zcash_pool_migration_backend" +version = "0.1.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "incrementalmerkletree", + "libm", + "orchard 0.15.0", + "pczt", + "rand_core 0.6.4", + "shardtree", + "zcash_client_backend", + "zcash_keys 0.15.0", + "zcash_primitives 0.29.0", + "zcash_protocol 0.10.0", +] + +[[package]] +name = "zcash_pool_migration_sqlite" +version = "0.1.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "rusqlite", + "uuid", + "zcash_pool_migration_backend", +] + [[package]] name = "zcash_primitives" version = "0.28.0" @@ -7252,7 +7311,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.29.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -7282,7 +7341,8 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.29.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91fb0b420fb66a765f1fa92ab7a6b631aa54c08415415ef6185256826e74fbd" dependencies = [ "bellman", "blake2b_simd", @@ -7317,7 +7377,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.10.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "corez", "document-features", @@ -7380,7 +7440,7 @@ dependencies = [ [[package]] name = "zcash_transparent" version = "0.9.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bip32", "bs58", @@ -7834,7 +7894,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.9.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "base64 0.22.1", "nom 7.1.3", diff --git a/backends/zaino/Cargo.toml b/backends/zaino/Cargo.toml index 0572ca8f..217adf6d 100644 --- a/backends/zaino/Cargo.toml +++ b/backends/zaino/Cargo.toml @@ -72,19 +72,24 @@ once_cell = "1.2" tempfile = "3" [patch.crates-io] -# Replace with the crates.io releases once they ship. -equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_transparent = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } +# Temporary patch for `WalletDb::transactionally_with_extension` (adds atomic +# z_importkey account+key writes), merged upstream but not yet released. The API +# lives in zcash_client_sqlite, but the repo is a workspace: pinning only that +# crate pulls its path-dep siblings from git while their crates.io copies remain, +# duplicating the librustzcash stack, so the whole stack is pinned to one rev. +# Replace with the crates.io releases once the next zcash_client_sqlite release +# ships. +equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_transparent = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } # TEMPORARY: pin the zaino crates to zingolabs/zaino `dev` (currently 8048bf8d), # which surfaces the Ironwood (NU6.3) note commitment treestate from diff --git a/backends/zaino/supply-chain/config.toml b/backends/zaino/supply-chain/config.toml index 6d6627d8..1466dd74 100644 --- a/backends/zaino/supply-chain/config.toml +++ b/backends/zaino/supply-chain/config.toml @@ -458,14 +458,29 @@ criteria = "safe-to-deploy" version = "0.13.4" criteria = "safe-to-deploy" +[[exemptions.darling]] +version = "0.23.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.darling_core]] version = "0.13.4" criteria = "safe-to-deploy" +[[exemptions.darling_core]] +version = "0.23.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.darling_macro]] version = "0.20.11" criteria = "safe-to-deploy" +[[exemptions.darling_macro]] +version = "0.23.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.dashmap]] version = "6.2.1" criteria = "safe-to-deploy" @@ -1602,10 +1617,20 @@ criteria = "safe-to-deploy" version = "3.17.0" criteria = "safe-to-deploy" +[[exemptions.serde_with]] +version = "3.21.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.serde_with_macros]] version = "3.17.0" criteria = "safe-to-deploy" +[[exemptions.serde_with_macros]] +version = "3.21.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.serdect]] version = "0.2.0" criteria = "safe-to-deploy" diff --git a/backends/zebra/Cargo.lock b/backends/zebra/Cargo.lock index 886f35d9..afd67848 100644 --- a/backends/zebra/Cargo.lock +++ b/backends/zebra/Cargo.lock @@ -1430,7 +1430,7 @@ dependencies = [ [[package]] name = "equihash" version = "0.3.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", "corez", @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", ] @@ -3517,6 +3517,34 @@ dependencies = [ "password-hash", ] +[[package]] +name = "pczt" +version = "0.8.0-rc.1" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "blake2b_simd", + "bls12_381", + "document-features", + "ff", + "getset", + "jubjub", + "nonempty", + "orchard 0.15.0", + "pasta_curves", + "postcard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "serde", + "serde_with", + "zcash_note_encryption", + "zcash_primitives 0.29.0", + "zcash_protocol 0.10.0", + "zcash_script", + "zcash_transparent 0.9.0", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -6494,6 +6522,7 @@ dependencies = [ "nix 0.29.0", "nonempty", "orchard 0.15.0", + "pczt", "phf", "quote", "rand 0.8.7", @@ -6532,6 +6561,8 @@ dependencies = [ "zcash_encoding", "zcash_keys 0.15.0", "zcash_note_encryption", + "zcash_pool_migration_backend", + "zcash_pool_migration_sqlite", "zcash_primitives 0.29.0", "zcash_proofs", "zcash_protocol 0.10.0", @@ -6592,7 +6623,7 @@ dependencies = [ [[package]] name = "zcash_address" version = "0.13.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bs58", @@ -6605,7 +6636,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.24.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -6655,7 +6686,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.22.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bip0039", @@ -6692,6 +6723,7 @@ dependencies = [ "zcash_client_backend", "zcash_encoding", "zcash_keys 0.15.0", + "zcash_pool_migration_sqlite", "zcash_primitives 0.29.0", "zcash_protocol 0.10.0", "zcash_script", @@ -6703,7 +6735,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.4.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "corez", "hex", @@ -6755,7 +6787,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.15.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bech32 0.11.1", "bip0039", @@ -6798,6 +6830,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "zcash_pool_migration_backend" +version = "0.1.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "incrementalmerkletree", + "libm", + "orchard 0.15.0", + "pczt", + "rand_core 0.6.4", + "shardtree", + "zcash_client_backend", + "zcash_keys 0.15.0", + "zcash_primitives 0.29.0", + "zcash_protocol 0.10.0", +] + +[[package]] +name = "zcash_pool_migration_sqlite" +version = "0.1.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" +dependencies = [ + "rusqlite", + "uuid", + "zcash_pool_migration_backend", +] + [[package]] name = "zcash_primitives" version = "0.28.0" @@ -6831,7 +6890,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.29.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -6861,7 +6920,8 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.29.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91fb0b420fb66a765f1fa92ab7a6b631aa54c08415415ef6185256826e74fbd" dependencies = [ "bellman", "blake2b_simd", @@ -6896,7 +6956,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.10.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "corez", "document-features", @@ -6959,7 +7019,7 @@ dependencies = [ [[package]] name = "zcash_transparent" version = "0.9.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "bip32", "bs58", @@ -7413,7 +7473,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.9.0-rc.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=f45d49b32392b9aed7ef017047b3995fe5fd6044#f45d49b32392b9aed7ef017047b3995fe5fd6044" +source = "git+https://github.com/zcash/librustzcash.git?rev=f6dbc06b9311689ede149f883bb427805e590a42#f6dbc06b9311689ede149f883bb427805e590a42" dependencies = [ "base64 0.22.1", "nom 7.1.3", diff --git a/backends/zebra/Cargo.toml b/backends/zebra/Cargo.toml index 786fa97f..8769d729 100644 --- a/backends/zebra/Cargo.toml +++ b/backends/zebra/Cargo.toml @@ -75,19 +75,24 @@ rpc-cli = ["zallet-core/rpc-cli"] tokio-console = ["zallet-core/tokio-console"] [patch.crates-io] -# Replace with the crates.io releases once they ship. -equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zcash_transparent = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } -zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "f45d49b32392b9aed7ef017047b3995fe5fd6044" } +# Temporary patch for `WalletDb::transactionally_with_extension` (adds atomic +# z_importkey account+key writes), merged upstream but not yet released. The API +# lives in zcash_client_sqlite, but the repo is a workspace: pinning only that +# crate pulls its path-dep siblings from git while their crates.io copies remain, +# duplicating the librustzcash stack, so the whole stack is pinned to one rev. +# Replace with the crates.io releases once the next zcash_client_sqlite release +# ships. +equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zcash_transparent = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } +zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "f6dbc06b9311689ede149f883bb427805e590a42" } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = [ diff --git a/book/src/rpc/methods.md b/book/src/rpc/methods.md index df5f46fd..53989454 100644 --- a/book/src/rpc/methods.md +++ b/book/src/rpc/methods.md @@ -119,6 +119,72 @@ that require the availability of private keys, such as sending funds. Issuing the `walletpassphrase` command while the wallet is already unlocked will set a new unlock time that overrides the old one. +## `z_advancepoolmigration` + +*Only available in wallet builds of Zallet.* + +Advances a previously-scheduled pool migration by one step. + +Detects newly mined transactions, proves and broadcasts the next due pre-signed +transaction, and once the preparation is mined builds the phase-2 transfers. Advances +one step per call, so a caller polls it. + +#### Arguments +- `account` (string or numeric, required): The UUID or ZIP 32 index of the account + whose migration to advance. +- `migration_id` (string, required): The identifier returned by + `z_startpoolmigration`. + +## `z_applypoolmigrationsignature` + +*Only available in wallet builds of Zallet.* + +Applies an externally-signed PCZT to a migration transaction. + +Stores the signed PCZT an external (hardware or offline) signer returned against the +migration transaction it was built for, moving that transaction from awaiting-signature +to signed so z_advancepoolmigration can prove and broadcast it. The PCZT is matched to +its transaction by the id returned alongside the unsigned PCZT. + +#### Arguments +- `migration_id` (string, required): The identifier returned by + `z_startpoolmigration`. +- `transaction_id` (numeric, required): The id of the migration transaction the signed + PCZT is for, from the `unsigned_transactions` list. +- `pczt` (string, required): The signed migration PCZT, base64 encoded. + +## `z_buildpoolmigrationtransfers` + +*Only available in wallet builds of Zallet.* + +Builds a migration's phase-2 transfers UNSIGNED, for an external signer. + +The external-signer counterpart of the transfer building that z_advancepoolmigration +does in process: it detects newly mined preparation transactions and, once the whole +preparation is mined, builds the transfers but leaves them unsigned, returning their +PCZTs in `unsigned_transactions` to sign on the device and apply back with +z_applypoolmigrationsignature. An external migration uses this rather than +z_advancepoolmigration for the preparation-to-transfers step. + +#### Arguments +- `account` (string or numeric, required): The UUID or ZIP 32 index of the account + whose migration transfers to build. +- `migration_id` (string, required): The identifier returned by + `z_startpoolmigration`. + +## `z_cancelpoolmigration` + +*Only available in wallet builds of Zallet.* + +Cancels a previously-scheduled pool migration. + +NOTE: This is currently a scaffold. The identifier is validated, but the migration +engine is not yet wired in, so the call returns a "not implemented yet" error. + +#### Arguments +- `migration_id` (string, required): The identifier returned by + `z_startpoolmigration`. + ## `z_converttex` Converts a transparent P2PKH Zcash address to a TEX address. @@ -306,6 +372,19 @@ The operation will remain in memory. - `operationid` (array, optional) A list of operation ids we are interested in. If not provided, examine all operations known to the node. +## `z_getpoolmigrationstatus` + +*Only available in wallet builds of Zallet.* + +Returns the status of a previously-scheduled pool migration. + +NOTE: This is currently a scaffold. The identifier is validated, but the migration +engine is not yet wired in, so the call returns a "not implemented yet" error. + +#### Arguments +- `migration_id` (string, required): The identifier returned by + `z_startpoolmigration`. + ## `z_gettotalbalance` *Only available in wallet builds of Zallet.* @@ -375,6 +454,15 @@ Returns the list of operation ids currently known to the wallet. #### Arguments - `status` (string, optional) Filter result by the operation's state e.g. "success". +## `z_listpoolmigrations` + +*Only available in wallet builds of Zallet.* + +Lists the pool migrations known to the wallet. + +NOTE: This is currently a scaffold. The migration engine is not yet wired in, so +the call returns a "not implemented yet" error. + ## `z_listtransactions` Returns a list of the wallet's transactions, optionally filtered by account and block @@ -439,6 +527,30 @@ returned, even though they are not immediately spendable. the mempool), but this does not support negative values in general. A “future” height will fall back to the current height. +## `z_previewpoolmigration` + +*Only available in wallet builds of Zallet.* + +Previews the migration plan for migrating an account's balance between two value +pools, without scheduling or broadcasting anything. + +Enumerates the account's spendable notes in `from_pool` and runs the migration +engine's planning slice to show how the balance would be decomposed into the +self-funding notes that cross the turnstile (the crossing denominations and their +transfer schedule), the note-preparation transactions that would mint them, and +the residual left behind. This is a read-only planning preview: unlike the rest of +the migration surface it is fully wired, because it only plans and does not build, +prove, or broadcast any transaction. + +#### Arguments +- `account` (string or numeric, required): Either the UUID or the ZIP 32 account + index of the account, as returned by `z_getnewaccount`. +- `from_pool` (string, required): The value pool to migrate funds from + ("sapling", "orchard", or "ironwood"). +- `to_pool` (string, required): The value pool to migrate funds to. +- `minconf` (numeric, optional, default=1): Only include source-pool notes + confirmed at least this many times. + ## `z_recoveraccounts` *Only available in wallet builds of Zallet.* @@ -603,6 +715,49 @@ An object matching `zcashd`'s `z_shieldcoinbase` shape: - `shieldingValue` (numeric, ZEC): Total value being shielded. - `opid` (string): Operation id. +## `z_signpoolmigrationpczt` + +*Only available in wallet builds of Zallet.* + +Signs a migration PCZT with the account's spend authorization. + +For offline / air-gapped signing, and the software stand-in for on-device signing: it +takes an unsigned migration PCZT (from z_startpoolmigration with external_signer, or +z_buildpoolmigrationtransfers), adds only the account's Orchard spend-authorization +signature, and returns the signed PCZT to apply with z_applypoolmigrationsignature. It +does not prove, extract, or broadcast. A hardware wallet signs on the device instead. + +#### Arguments +- `account` (string or numeric, required): The UUID or ZIP 32 index of the account + whose spend key signs the PCZT. +- `pczt` (string, required): The unsigned migration PCZT, base64 encoded. + +## `z_startpoolmigration` + +*Only available in wallet builds of Zallet.* + +Schedules a migration of shielded funds from one value pool to another. + +The migration is generic pool-to-pool: the supported pool pairs, and the network +upgrade that enables each one, are declared in a single table (migrating from the +Orchard pool to the Ironwood pool requires NU6.3). The pool pair is validated +against that table and the enabling upgrade is required to be active. + +Builds and pre-signs the note-preparation transactions and persists the committed +migration; proving and broadcasting happen later via z_advancepoolmigration. + +#### Arguments +- `account` (string or numeric, required): Either the UUID or the ZIP 32 account index + of the account whose balance to migrate. +- `from_pool` (string, required): The value pool to migrate funds from + ("sapling", "orchard", or "ironwood"). +- `to_pool` (string, required): The value pool to migrate funds to. +- `external_signer` (boolean, optional, default=false): When true, build the + preparation transactions UNSIGNED for an external (hardware or offline) signer + and return their PCZTs in `unsigned_transactions`, to sign on the device and + apply back with `z_applypoolmigrationsignature`. When false (the default) the + preparation is pre-signed in process. + ## `z_viewtransaction` Returns detailed information about in-wallet transaction `txid`. diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 8a03449c..36d795d9 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -394,6 +394,21 @@ criteria = "safe-to-deploy" version = "0.8.1" criteria = "safe-to-deploy" +[[exemptions.darling]] +version = "0.23.0" +criteria = "safe-to-deploy" +suggest = false + +[[exemptions.darling_core]] +version = "0.23.0" +criteria = "safe-to-deploy" +suggest = false + +[[exemptions.darling_macro]] +version = "0.23.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.deadpool]] version = "0.12.3" criteria = "safe-to-deploy" @@ -1262,6 +1277,16 @@ criteria = "safe-to-deploy" version = "1.1.1" criteria = "safe-to-deploy" +[[exemptions.serde_with]] +version = "3.21.0" +criteria = "safe-to-deploy" +suggest = false + +[[exemptions.serde_with_macros]] +version = "3.21.0" +criteria = "safe-to-deploy" +suggest = false + [[exemptions.serdect]] version = "0.2.0" criteria = "safe-to-deploy" diff --git a/zallet-core/Cargo.toml b/zallet-core/Cargo.toml index 5cd73877..25eab689 100644 --- a/zallet-core/Cargo.toml +++ b/zallet-core/Cargo.toml @@ -122,6 +122,9 @@ zcash_client_sqlite = { workspace = true, features = [ "orchard", "transparent-inputs", ] } +zcash_pool_migration_backend.workspace = true +zcash_pool_migration_sqlite.workspace = true +pczt.workspace = true zcash_note_encryption.workspace = true zip32.workspace = true diff --git a/zallet-core/src/components/json_rpc/methods.rs b/zallet-core/src/components/json_rpc/methods.rs index d137eade..c0080647 100644 --- a/zallet-core/src/components/json_rpc/methods.rs +++ b/zallet-core/src/components/json_rpc/methods.rs @@ -19,6 +19,14 @@ use { tokio::sync::RwLock, }; +#[cfg(zallet_build = "wallet")] +mod advance_pool_migration; +#[cfg(zallet_build = "wallet")] +mod apply_pool_migration_signature; +#[cfg(zallet_build = "wallet")] +mod build_pool_migration_transfers; +#[cfg(zallet_build = "wallet")] +mod cancel_pool_migration; mod convert_tex; mod decode_raw_transaction; mod decode_script; @@ -34,6 +42,8 @@ mod get_new_account; mod get_notes_count; #[cfg(zallet_build = "wallet")] mod get_operation; +#[cfg(zallet_build = "wallet")] +mod get_pool_migration_status; mod get_raw_transaction; #[cfg(zallet_build = "wallet")] mod get_wallet_info; @@ -46,6 +56,8 @@ mod list_accounts; mod list_addresses; #[cfg(zallet_build = "wallet")] mod list_operation_ids; +#[cfg(zallet_build = "wallet")] +mod list_pool_migrations; mod list_transactions; mod list_unified_receivers; #[cfg(zallet_build = "wallet")] @@ -55,7 +67,15 @@ mod lock_wallet; #[cfg(zallet_build = "wallet")] mod openrpc; #[cfg(zallet_build = "wallet")] +mod pool_migration; +#[cfg(zallet_build = "wallet")] +mod preview_pool_migration; +#[cfg(zallet_build = "wallet")] mod recover_accounts; +#[cfg(zallet_build = "wallet")] +mod sign_pool_migration_pczt; +#[cfg(zallet_build = "wallet")] +mod start_pool_migration; mod stop; #[cfg(zallet_build = "wallet")] mod unlock_wallet; @@ -739,6 +759,175 @@ pub(crate) trait WalletRpc { zaddr: &str, ivk: Option, ) -> z_export_viewing_key::Response; + + /// Schedules a migration of shielded funds from one value pool to another. + /// + /// The migration is generic pool-to-pool: the supported pool pairs, and the network + /// upgrade that enables each one, are declared in a single table (migrating from the + /// Orchard pool to the Ironwood pool requires NU6.3). The pool pair is validated + /// against that table and the enabling upgrade is required to be active. + /// + /// Builds and pre-signs the note-preparation transactions and persists the committed + /// migration; proving and broadcasting happen later via z_advancepoolmigration. + /// + /// # Arguments + /// - `account` (string or numeric, required): Either the UUID or the ZIP 32 account index + /// of the account whose balance to migrate. + /// - `from_pool` (string, required): The value pool to migrate funds from + /// ("sapling", "orchard", or "ironwood"). + /// - `to_pool` (string, required): The value pool to migrate funds to. + /// - `external_signer` (boolean, optional, default=false): When true, build the + /// preparation transactions UNSIGNED for an external (hardware or offline) signer + /// and return their PCZTs in `unsigned_transactions`, to sign on the device and + /// apply back with `z_applypoolmigrationsignature`. When false (the default) the + /// preparation is pre-signed in process. + #[method(name = "z_startpoolmigration")] + async fn start_pool_migration( + &self, + account: JsonValue, + from_pool: String, + to_pool: String, + external_signer: Option, + ) -> start_pool_migration::Response; + + /// Previews the migration plan for migrating an account's balance between two value + /// pools, without scheduling or broadcasting anything. + /// + /// Enumerates the account's spendable notes in `from_pool` and runs the migration + /// engine's planning slice to show how the balance would be decomposed into the + /// self-funding notes that cross the turnstile (the crossing denominations and their + /// transfer schedule), the note-preparation transactions that would mint them, and + /// the residual left behind. This is a read-only planning preview: unlike the rest of + /// the migration surface it is fully wired, because it only plans and does not build, + /// prove, or broadcast any transaction. + /// + /// # Arguments + /// - `account` (string or numeric, required): Either the UUID or the ZIP 32 account + /// index of the account, as returned by `z_getnewaccount`. + /// - `from_pool` (string, required): The value pool to migrate funds from + /// ("sapling", "orchard", or "ironwood"). + /// - `to_pool` (string, required): The value pool to migrate funds to. + /// - `minconf` (numeric, optional, default=1): Only include source-pool notes + /// confirmed at least this many times. + #[method(name = "z_previewpoolmigration")] + async fn preview_pool_migration( + &self, + account: JsonValue, + from_pool: String, + to_pool: String, + minconf: Option, + ) -> preview_pool_migration::Response; + + /// Returns the status of a previously-scheduled pool migration. + /// + /// NOTE: This is currently a scaffold. The identifier is validated, but the migration + /// engine is not yet wired in, so the call returns a "not implemented yet" error. + /// + /// # Arguments + /// - `migration_id` (string, required): The identifier returned by + /// `z_startpoolmigration`. + #[method(name = "z_getpoolmigrationstatus")] + async fn get_pool_migration_status( + &self, + migration_id: String, + ) -> get_pool_migration_status::Response; + + /// Advances a previously-scheduled pool migration by one step. + /// + /// Detects newly mined transactions, proves and broadcasts the next due pre-signed + /// transaction, and once the preparation is mined builds the phase-2 transfers. Advances + /// one step per call, so a caller polls it. + /// + /// # Arguments + /// - `account` (string or numeric, required): The UUID or ZIP 32 index of the account + /// whose migration to advance. + /// - `migration_id` (string, required): The identifier returned by + /// `z_startpoolmigration`. + #[method(name = "z_advancepoolmigration")] + async fn advance_pool_migration( + &self, + account: JsonValue, + migration_id: String, + ) -> advance_pool_migration::Response; + + /// Builds a migration's phase-2 transfers UNSIGNED, for an external signer. + /// + /// The external-signer counterpart of the transfer building that z_advancepoolmigration + /// does in process: it detects newly mined preparation transactions and, once the whole + /// preparation is mined, builds the transfers but leaves them unsigned, returning their + /// PCZTs in `unsigned_transactions` to sign on the device and apply back with + /// z_applypoolmigrationsignature. An external migration uses this rather than + /// z_advancepoolmigration for the preparation-to-transfers step. + /// + /// # Arguments + /// - `account` (string or numeric, required): The UUID or ZIP 32 index of the account + /// whose migration transfers to build. + /// - `migration_id` (string, required): The identifier returned by + /// `z_startpoolmigration`. + #[method(name = "z_buildpoolmigrationtransfers")] + async fn build_pool_migration_transfers( + &self, + account: JsonValue, + migration_id: String, + ) -> build_pool_migration_transfers::Response; + + /// Signs a migration PCZT with the account's spend authorization. + /// + /// For offline / air-gapped signing, and the software stand-in for on-device signing: it + /// takes an unsigned migration PCZT (from z_startpoolmigration with external_signer, or + /// z_buildpoolmigrationtransfers), adds only the account's Orchard spend-authorization + /// signature, and returns the signed PCZT to apply with z_applypoolmigrationsignature. It + /// does not prove, extract, or broadcast. A hardware wallet signs on the device instead. + /// + /// # Arguments + /// - `account` (string or numeric, required): The UUID or ZIP 32 index of the account + /// whose spend key signs the PCZT. + /// - `pczt` (string, required): The unsigned migration PCZT, base64 encoded. + #[method(name = "z_signpoolmigrationpczt")] + async fn sign_pool_migration_pczt( + &self, + account: JsonValue, + pczt: String, + ) -> sign_pool_migration_pczt::Response; + + /// Applies an externally-signed PCZT to a migration transaction. + /// + /// Stores the signed PCZT an external (hardware or offline) signer returned against the + /// migration transaction it was built for, moving that transaction from awaiting-signature + /// to signed so z_advancepoolmigration can prove and broadcast it. The PCZT is matched to + /// its transaction by the id returned alongside the unsigned PCZT. + /// + /// # Arguments + /// - `migration_id` (string, required): The identifier returned by + /// `z_startpoolmigration`. + /// - `transaction_id` (numeric, required): The id of the migration transaction the signed + /// PCZT is for, from the `unsigned_transactions` list. + /// - `pczt` (string, required): The signed migration PCZT, base64 encoded. + #[method(name = "z_applypoolmigrationsignature")] + async fn apply_pool_migration_signature( + &self, + migration_id: String, + transaction_id: u32, + pczt: String, + ) -> apply_pool_migration_signature::Response; + + /// Cancels a previously-scheduled pool migration. + /// + /// NOTE: This is currently a scaffold. The identifier is validated, but the migration + /// engine is not yet wired in, so the call returns a "not implemented yet" error. + /// + /// # Arguments + /// - `migration_id` (string, required): The identifier returned by + /// `z_startpoolmigration`. + #[method(name = "z_cancelpoolmigration")] + async fn cancel_pool_migration(&self, migration_id: String) -> cancel_pool_migration::Response; + + /// Lists the pool migrations known to the wallet. + /// + /// NOTE: This is currently a scaffold. The migration engine is not yet wired in, so + /// the call returns a "not implemented yet" error. + #[method(name = "z_listpoolmigrations")] + async fn list_pool_migrations(&self) -> list_pool_migrations::Response; } pub(crate) struct RpcImpl { @@ -1160,4 +1349,113 @@ impl WalletRpcServer for WalletRpcImpl { ) -> z_export_viewing_key::Response { z_export_viewing_key::call(self.wallet().await?.as_ref(), &self.keystore, zaddr, ivk).await } + + async fn start_pool_migration( + &self, + account: JsonValue, + from_pool: String, + to_pool: String, + external_signer: Option, + ) -> start_pool_migration::Response { + start_pool_migration::call( + self.wallet().await?.as_ref(), + &self.keystore, + account, + &from_pool, + &to_pool, + external_signer, + ) + .await + } + + async fn preview_pool_migration( + &self, + account: JsonValue, + from_pool: String, + to_pool: String, + minconf: Option, + ) -> preview_pool_migration::Response { + preview_pool_migration::call( + self.wallet().await?.as_ref(), + &self.keystore, + account, + &from_pool, + &to_pool, + minconf, + ) + .await + } + + async fn get_pool_migration_status( + &self, + migration_id: String, + ) -> get_pool_migration_status::Response { + get_pool_migration_status::call(self.wallet().await?.as_ref(), &migration_id) + } + + async fn advance_pool_migration( + &self, + account: JsonValue, + migration_id: String, + ) -> advance_pool_migration::Response { + advance_pool_migration::call( + self.wallet().await?.as_ref(), + &self.keystore, + self.chain().await?, + account, + &migration_id, + ) + .await + } + + async fn build_pool_migration_transfers( + &self, + account: JsonValue, + migration_id: String, + ) -> build_pool_migration_transfers::Response { + build_pool_migration_transfers::call( + self.wallet().await?.as_ref(), + &self.keystore, + account, + &migration_id, + ) + .await + } + + async fn sign_pool_migration_pczt( + &self, + account: JsonValue, + pczt: String, + ) -> sign_pool_migration_pczt::Response { + sign_pool_migration_pczt::call( + self.wallet().await?.as_ref(), + &self.keystore, + account, + &pczt, + ) + .await + } + + async fn apply_pool_migration_signature( + &self, + migration_id: String, + transaction_id: u32, + pczt: String, + ) -> apply_pool_migration_signature::Response { + apply_pool_migration_signature::call( + self.wallet().await?.as_ref(), + &migration_id, + transaction_id, + &pczt, + ) + .await + } + + async fn cancel_pool_migration(&self, migration_id: String) -> cancel_pool_migration::Response { + cancel_pool_migration::call(self.wallet().await?.as_ref(), &migration_id) + } + + async fn list_pool_migrations(&self) -> list_pool_migrations::Response { + list_pool_migrations::call(self.wallet().await?.as_ref()) + } } diff --git a/zallet-core/src/components/json_rpc/methods/advance_pool_migration.rs b/zallet-core/src/components/json_rpc/methods/advance_pool_migration.rs new file mode 100644 index 00000000..734e22c1 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/advance_pool_migration.rs @@ -0,0 +1,94 @@ +//! `z_advancepoolmigration`: advance a pool migration by one step. +//! +//! Advancing drives a committed migration forward one step per call (so a caller polls it): it +//! detects newly mined transactions, proves and broadcasts the next due pre-signed transaction, and +//! once the preparation is mined builds the phase-2 transfers. Proving is done against the migration's +//! Orchard circuit; broadcasting sends the extracted transaction to the mempool. + +use documented::Documented; +use jsonrpsee::core::{JsonValue, RpcResult}; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_client_backend::data_api::WalletRead; + +use super::pool_migration::{ + MIGRATION_ID, MigrationPhase, MigrationProgress, decrypt_account_usk, map_advance_error, + migration_progress, no_such_migration, validate_migration_id, +}; +use crate::components::chain::Chain; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::components::json_rpc::utils::parse_account_parameter; +use crate::components::keystore::KeyStore; +use crate::migrate::{AdvanceOutcome, advance_blocking, record_broadcast, transaction_txid_bytes}; + +/// Response to a `z_advancepoolmigration` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = AdvancePoolMigration; + +pub(super) const PARAM_ACCOUNT_DESC: &str = + "Either the UUID or ZIP 32 account index of the account whose migration to advance."; +pub(super) const PARAM_MIGRATION_ID_DESC: &str = "The identifier returned by z_startpoolmigration."; + +/// The result of advancing a pool migration by one step. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct AdvancePoolMigration { + /// Opaque identifier for the migration. + migration_id: String, + /// The migration's lifecycle phase after advancing. + phase: MigrationPhase, + /// The migration's progress after advancing. + progress: MigrationProgress, + /// A short description of what this step did. + status: String, +} + +pub(crate) async fn call( + wallet: &DbConnection, + keystore: &KeyStore, + chain: C, + account: JsonValue, + migration_id: &str, +) -> Response { + validate_migration_id(migration_id)?; + if migration_id != MIGRATION_ID { + return Err(no_such_migration()); + } + let account_id = parse_account_parameter(wallet, keystore, &account).await?; + // Decrypt the spending key before the blocking section (the phase-2 transfers are signed there). + let usk = decrypt_account_usk(wallet, keystore, account_id).await?; + + let chain_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InWarmup.with_static("Wallet sync required"))?; + let tip = u32::from(chain_height); + + // Blocking: load the migration, detect newly mined transactions, and decide + build the next + // transaction to broadcast (or build the transfers, or report what it is waiting for). + let AdvanceOutcome { + mut state, + to_broadcast, + message, + } = wallet + .with_raw_mut(|conn, network| advance_blocking(conn, network, account_id, usk, tip)) + .map_err(map_advance_error)?; + + // Broadcast the built transaction (async), then record its broadcast and persist. + if let Some((tx, tx_id)) = to_broadcast { + chain.broadcast_transaction(&tx).await.map_err(|e| { + LegacyCode::Misc.with_message(format!("broadcasting the transaction failed: {e}")) + })?; + let txid = transaction_txid_bytes(&tx); + wallet + .with_raw_mut(|conn, _| record_broadcast(conn, &mut state, tx_id, txid)) + .map_err(map_advance_error)?; + } + + Ok(AdvancePoolMigration { + migration_id: MIGRATION_ID.to_string(), + phase: MigrationPhase::from_status(state.status), + progress: migration_progress(&state), + status: message, + }) +} diff --git a/zallet-core/src/components/json_rpc/methods/apply_pool_migration_signature.rs b/zallet-core/src/components/json_rpc/methods/apply_pool_migration_signature.rs new file mode 100644 index 00000000..67fb3cb6 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/apply_pool_migration_signature.rs @@ -0,0 +1,80 @@ +//! `z_applypoolmigrationsignature`: apply an externally-signed PCZT to a migration transaction. +//! +//! Stores the signed PCZT an external (hardware or offline) signer returned against the migration +//! transaction it was built for, moving that transaction from `AwaitingSignature` to `Signed` so +//! `z_advancepoolmigration` can prove and broadcast it. The PCZT is matched to its transaction by the +//! id that `z_startpoolmigration` (with `external_signer`) or `z_buildpoolmigrationtransfers` returned +//! alongside the unsigned PCZT. + +use base64ct::{Base64, Encoding}; +use documented::Documented; +use jsonrpsee::core::RpcResult; +use jsonrpsee::types::ErrorObjectOwned; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_pool_migration_backend::engine::MigrationTxId; + +use super::pool_migration::{ + MIGRATION_ID, MigrationPhase, MigrationProgress, migration_progress, no_such_migration, + validate_migration_id, +}; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::migrate::{load_migration, persist_migration}; + +/// Response to a `z_applypoolmigrationsignature` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = ApplyPoolMigrationSignature; + +pub(super) const PARAM_MIGRATION_ID_DESC: &str = "The identifier returned by z_startpoolmigration."; +pub(super) const PARAM_TRANSACTION_ID_DESC: &str = "The id of the migration transaction the signed PCZT is for, from the unsigned_transactions list."; +pub(super) const PARAM_PCZT_DESC: &str = "The signed migration PCZT, base64 encoded (from an external signer or z_signpoolmigrationpczt)."; + +/// The result of applying an external signature to a migration transaction. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct ApplyPoolMigrationSignature { + /// Opaque identifier for the migration. + migration_id: String, + /// The migration's lifecycle phase after applying the signature. + phase: MigrationPhase, + /// The migration's progress after applying the signature. + progress: MigrationProgress, + /// A short description of what this step did. + status: String, +} + +pub(crate) async fn call( + wallet: &DbConnection, + migration_id: &str, + transaction_id: u32, + pczt: &str, +) -> Response { + validate_migration_id(migration_id)?; + if migration_id != MIGRATION_ID { + return Err(no_such_migration()); + } + let bytes = Base64::decode_vec(pczt) + .map_err(|_| LegacyCode::InvalidParameter.with_static("Malformed base64 PCZT"))?; + + // Load the migration, apply the signed PCZT to its transaction, and persist. The load and the + // store write run on one connection, sequentially. + let state = wallet.with_raw_mut(|conn, _| -> Result<_, ErrorObjectOwned> { + let mut state = load_migration(conn) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(no_such_migration)?; + if !state.apply_signature(MigrationTxId(transaction_id), bytes) { + return Err(LegacyCode::InvalidParameter + .with_static("no migration transaction with that id is awaiting a signature")); + } + persist_migration(conn, &state) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + Ok(state) + })?; + + Ok(ApplyPoolMigrationSignature { + migration_id: MIGRATION_ID.to_string(), + phase: MigrationPhase::from_status(state.status), + progress: migration_progress(&state), + status: "applied the external signature".to_string(), + }) +} diff --git a/zallet-core/src/components/json_rpc/methods/build_pool_migration_transfers.rs b/zallet-core/src/components/json_rpc/methods/build_pool_migration_transfers.rs new file mode 100644 index 00000000..794b03ea --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/build_pool_migration_transfers.rs @@ -0,0 +1,87 @@ +//! `z_buildpoolmigrationtransfers`: build a migration's phase-2 transfers UNSIGNED, for an external +//! signer. +//! +//! The external-signer counterpart of the in-process transfer building that `z_advancepoolmigration` +//! performs: it detects newly mined preparation transactions and, once the whole preparation is mined, +//! builds the transfer transactions but leaves them UNSIGNED, returning their PCZTs to sign on the +//! device (applied back with `z_applypoolmigrationsignature`). Nothing is proved or broadcast here. An +//! external migration uses this rather than `z_advancepoolmigration` for the preparation-to-transfers +//! step, so the wallet never signs the transfers in process. + +use documented::Documented; +use jsonrpsee::core::{JsonValue, RpcResult}; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_client_backend::data_api::WalletRead; + +use super::pool_migration::{ + MIGRATION_ID, MigrationPhase, MigrationProgress, UnsignedMigrationTransaction, + decrypt_account_usk, encode_unsigned, map_advance_error, migration_progress, no_such_migration, + validate_migration_id, +}; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::components::json_rpc::utils::parse_account_parameter; +use crate::components::keystore::KeyStore; +use crate::migrate::build_transfers_unsigned_blocking; + +/// Response to a `z_buildpoolmigrationtransfers` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = BuildPoolMigrationTransfers; + +pub(super) const PARAM_ACCOUNT_DESC: &str = + "Either the UUID or ZIP 32 account index of the account whose migration transfers to build."; +pub(super) const PARAM_MIGRATION_ID_DESC: &str = "The identifier returned by z_startpoolmigration."; + +/// The result of building a migration's transfers for an external signer. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct BuildPoolMigrationTransfers { + /// Opaque identifier for the migration. + migration_id: String, + /// The migration's lifecycle phase after building. + phase: MigrationPhase, + /// The migration's progress after building. + progress: MigrationProgress, + /// The unsigned transfer PCZTs to sign externally. Empty until the whole preparation is mined; a + /// non-empty list means the transfers were built and are awaiting signatures. + unsigned_transactions: Vec, + /// A short description of what this step did. + status: String, +} + +pub(crate) async fn call( + wallet: &DbConnection, + keystore: &KeyStore, + account: JsonValue, + migration_id: &str, +) -> Response { + validate_migration_id(migration_id)?; + if migration_id != MIGRATION_ID { + return Err(no_such_migration()); + } + let account_id = parse_account_parameter(wallet, keystore, &account).await?; + // The key builds the transfer PCZTs (viewing key and witnesses); it does not sign them. + let usk = decrypt_account_usk(wallet, keystore, account_id).await?; + + let chain_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InWarmup.with_static("Wallet sync required"))?; + let tip = u32::from(chain_height); + + // Blocking: detect newly mined preparation transactions and, if the preparation is fully mined, + // build the transfers unsigned and persist the migration. + let (state, unsigned, message) = wallet + .with_raw_mut(|conn, network| { + build_transfers_unsigned_blocking(conn, network, account_id, usk, tip) + }) + .map_err(map_advance_error)?; + + Ok(BuildPoolMigrationTransfers { + migration_id: MIGRATION_ID.to_string(), + phase: MigrationPhase::from_status(state.status), + progress: migration_progress(&state), + unsigned_transactions: encode_unsigned(unsigned), + status: message, + }) +} diff --git a/zallet-core/src/components/json_rpc/methods/cancel_pool_migration.rs b/zallet-core/src/components/json_rpc/methods/cancel_pool_migration.rs new file mode 100644 index 00000000..61d223ef --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/cancel_pool_migration.rs @@ -0,0 +1,56 @@ +//! `z_cancelpoolmigration`: cancel a pool migration. + +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_pool_migration_backend::engine::MigrationStatus; + +use super::pool_migration::{ + MIGRATION_ID, MigrationPhase, no_such_migration, validate_migration_id, +}; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::migrate::{load_migration, persist_migration}; + +/// Response to a `z_cancelpoolmigration` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = CancelPoolMigration; + +pub(super) const PARAM_MIGRATION_ID_DESC: &str = "The identifier returned by z_startpoolmigration."; + +/// The result of cancelling a pool migration. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct CancelPoolMigration { + /// Opaque identifier for the cancelled migration. + migration_id: String, + /// The migration's lifecycle phase after cancellation. + phase: MigrationPhase, +} + +pub(crate) fn call(wallet: &DbConnection, migration_id: &str) -> Response { + validate_migration_id(migration_id)?; + if migration_id != MIGRATION_ID { + return Err(no_such_migration()); + } + // Mark the stored migration as failed, which reports as the cancelled phase. Any preparation + // transactions already broadcast cannot be undone on chain; cancelling stops the migration from + // building or broadcasting anything further. + let phase = wallet + .with_raw_mut( + |conn, _| -> Result, zcash_pool_migration_sqlite::Error> { + let Some(mut state) = load_migration(conn)? else { + return Ok(None); + }; + state.status = MigrationStatus::Failed; + persist_migration(conn, &state)?; + Ok(Some(MigrationPhase::from_status(state.status))) + }, + ) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(no_such_migration)?; + Ok(CancelPoolMigration { + migration_id: MIGRATION_ID.to_string(), + phase, + }) +} diff --git a/zallet-core/src/components/json_rpc/methods/get_pool_migration_status.rs b/zallet-core/src/components/json_rpc/methods/get_pool_migration_status.rs new file mode 100644 index 00000000..26f05fec --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/get_pool_migration_status.rs @@ -0,0 +1,72 @@ +//! `z_getpoolmigrationstatus`: report the status of a pool migration. + +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; + +use zcash_client_backend::data_api::WalletRead; + +use super::pool_migration::{ + MIGRATION_ENABLING_UPGRADE, MIGRATION_FROM_POOL, MIGRATION_ID, MIGRATION_TO_POOL, + MigrationPhase, MigrationProgress, MigrationTransactionStatus, Pool, migration_progress, + migration_transactions, no_such_migration, validate_migration_id, +}; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::migrate::load_migration; + +/// Response to a `z_getpoolmigrationstatus` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = PoolMigrationStatus; + +pub(super) const PARAM_MIGRATION_ID_DESC: &str = "The identifier returned by z_startpoolmigration."; + +/// The status of a pool migration. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct PoolMigrationStatus { + /// Opaque identifier for the migration. + migration_id: String, + /// The value pool funds are being migrated from. + from_pool: Pool, + /// The value pool funds are being migrated to. + to_pool: Pool, + /// The network upgrade that enables this migration (for example `"Nu6.3"`). + enabling_upgrade: String, + /// The migration's current lifecycle phase. + phase: MigrationPhase, + /// The migration's progress so far. + progress: MigrationProgress, + /// Every transaction the migration comprises, with its lifecycle state and, for a transaction a + /// wallet can act on now, the next action (or what it is waiting on). This lets a wallet render + /// progress and drive signing/broadcasting deterministically from persisted state, which a + /// multi-layer migration on a mobile wallet requires. + transactions: Vec, +} + +pub(crate) fn call(wallet: &DbConnection, migration_id: &str) -> Response { + validate_migration_id(migration_id)?; + if migration_id != MIGRATION_ID { + return Err(no_such_migration()); + } + // The height the next transaction would build at (tip + 1), used to decide which scheduled + // transactions are due. Before the wallet has synced a chain tip, treat nothing as due. + let target_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .map(|h| u32::from(h) + 1) + .unwrap_or(0); + let state = wallet + .with_raw_mut(|conn, _| load_migration(conn)) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(no_such_migration)?; + Ok(PoolMigrationStatus { + migration_id: MIGRATION_ID.to_string(), + from_pool: MIGRATION_FROM_POOL, + to_pool: MIGRATION_TO_POOL, + enabling_upgrade: MIGRATION_ENABLING_UPGRADE.to_string(), + phase: MigrationPhase::from_status(state.status), + progress: migration_progress(&state), + transactions: migration_transactions(&state, target_height), + }) +} diff --git a/zallet-core/src/components/json_rpc/methods/list_pool_migrations.rs b/zallet-core/src/components/json_rpc/methods/list_pool_migrations.rs new file mode 100644 index 00000000..641565d6 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/list_pool_migrations.rs @@ -0,0 +1,53 @@ +//! `z_listpoolmigrations`: list the migrations known to the wallet. + +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; + +use super::pool_migration::{ + MIGRATION_FROM_POOL, MIGRATION_ID, MIGRATION_TO_POOL, MigrationPhase, Pool, +}; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::migrate::load_migration; + +/// Response to a `z_listpoolmigrations` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = PoolMigrationList; + +/// A single entry in the list of known migrations. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct PoolMigrationSummary { + /// Opaque identifier for the migration. + migration_id: String, + /// The value pool funds are being migrated from. + from_pool: Pool, + /// The value pool funds are being migrated to. + to_pool: Pool, + /// The migration's current lifecycle phase. + phase: MigrationPhase, +} + +/// The list of migrations known to the wallet. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct PoolMigrationList { + /// Every migration the wallet is tracking (at most one at a time). + migrations: Vec, +} + +pub(crate) fn call(wallet: &DbConnection) -> Response { + let migrations = wallet + .with_raw_mut(|conn, _| load_migration(conn)) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .map(|state| { + vec![PoolMigrationSummary { + migration_id: MIGRATION_ID.to_string(), + from_pool: MIGRATION_FROM_POOL, + to_pool: MIGRATION_TO_POOL, + phase: MigrationPhase::from_status(state.status), + }] + }) + .unwrap_or_default(); + Ok(PoolMigrationList { migrations }) +} diff --git a/zallet-core/src/components/json_rpc/methods/pool_migration.rs b/zallet-core/src/components/json_rpc/methods/pool_migration.rs new file mode 100644 index 00000000..80a1c106 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/pool_migration.rs @@ -0,0 +1,496 @@ +//! Shared types and validation for the generic value-pool migration RPC surface. +//! +//! The migration surface is a pool-to-pool workflow (`z_startpoolmigration` and the +//! companion status, advance, cancel, and list methods) spread across sibling method +//! modules. This module holds what they share: the pool-agnostic [`Pool`] type, the +//! [`SUPPORTED_MIGRATIONS`] table that is the single extension point for new pool +//! pairs, the fixed migration identifier and pools, the plan/progress/phase response +//! shapes and their mapping from the engine's state, and the input validation. +//! +//! `z_startpoolmigration` builds, pre-signs, and persists the migration; the status +//! and list methods read the persisted state; cancel marks it cancelled; and +//! `z_advancepoolmigration` proves, broadcasts, and (for a multi-layer preparation) +//! builds each later transaction as its dependencies mine, driving the migration to +//! completion. + +use base64ct::{Base64, Encoding}; +use documented::Documented; +use jsonrpsee::core::RpcResult; +use jsonrpsee::types::ErrorObjectOwned; +use schemars::JsonSchema; +use secrecy::ExposeSecret; +use serde::Serialize; +use zcash_client_backend::data_api::{Account, WalletRead}; +use zcash_client_sqlite::AccountUuid; +use zcash_keys::keys::UnifiedSpendingKey; +use zcash_pool_migration_backend::engine::{ + MigrationState, MigrationStatus, MigrationTxKind, MigrationTxState, UnsignedMigrationTx, +}; +use zcash_pool_migration_backend::state::{ + Blocker, NextAction, TransactionStatus as EngineTransactionStatus, +}; +use zcash_protocol::consensus::{NetworkUpgrade, Parameters}; + +use crate::components::keystore::KeyStore; +use crate::components::{database::DbConnection, json_rpc::server::LegacyCode}; +use crate::migrate::{AdvanceError, CommitFailure}; + +/// The identifier of the wallet's pool migration. The store holds at most one migration at a time, +/// so a single fixed identifier names it: `z_startpoolmigration` returns this, and the status, +/// advance, and cancel methods accept it. +pub(crate) const MIGRATION_ID: &str = "orchard-to-ironwood"; + +/// The only supported migration is Orchard -> Ironwood, so a stored migration's pools and enabling +/// upgrade are fixed rather than recorded per migration. +pub(crate) const MIGRATION_FROM_POOL: Pool = Pool::Orchard; +pub(crate) const MIGRATION_TO_POOL: Pool = Pool::Ironwood; +pub(crate) const MIGRATION_ENABLING_UPGRADE: NetworkUpgrade = NetworkUpgrade::Nu6_3; + +/// Wire name of the Sapling value pool. +const POOL_NAME_SAPLING: &str = "sapling"; +/// Wire name of the Orchard value pool. +const POOL_NAME_ORCHARD: &str = "orchard"; +/// Wire name of the Ironwood value pool. +const POOL_NAME_IRONWOOD: &str = "ironwood"; + +/// A Zcash shielded value pool that can take part in a pool-to-pool migration. +/// +/// Serialized as its lowercase wire name (`"sapling"`, `"orchard"`, `"ironwood"`). New +/// pools are added here and, if they can be migrated, wired into +/// [`SUPPORTED_MIGRATIONS`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Pool { + /// The Sapling shielded value pool. + Sapling, + /// The Orchard shielded value pool. + Orchard, + /// The Ironwood shielded value pool (NU6.3, ZIP 2005). + Ironwood, +} + +impl Pool { + /// Returns the lowercase wire name of this pool. + pub(crate) fn name(self) -> &'static str { + match self { + Pool::Sapling => POOL_NAME_SAPLING, + Pool::Orchard => POOL_NAME_ORCHARD, + Pool::Ironwood => POOL_NAME_IRONWOOD, + } + } + + /// Parses a pool from its wire name, returning an `InvalidParameter` RPC error for + /// any unrecognized value. + pub(crate) fn parse(label: &str, value: &str) -> RpcResult { + match value { + POOL_NAME_SAPLING => Ok(Pool::Sapling), + POOL_NAME_ORCHARD => Ok(Pool::Orchard), + POOL_NAME_IRONWOOD => Ok(Pool::Ironwood), + other => Err(LegacyCode::InvalidParameter.with_message(format!( + "{label}: unknown value pool {other:?}; expected one of \ + {POOL_NAME_SAPLING:?}, {POOL_NAME_ORCHARD:?}, {POOL_NAME_IRONWOOD:?}", + ))), + } + } +} + +/// One supported pool-to-pool migration and the network upgrade that enables it. +struct SupportedMigration { + /// The value pool funds are migrated from. + from: Pool, + /// The value pool funds are migrated to. + to: Pool, + /// The network upgrade that must be active for this migration to run. + enabling_upgrade: NetworkUpgrade, +} + +/// The single source of truth for which pool-to-pool migrations exist and which +/// network upgrade each one requires. +/// +/// This is the one extension point for supporting new migrations: adding a pool pair +/// here makes it selectable through the whole RPC surface. Migrating from the Orchard +/// pool to the Ironwood pool requires NU6.3 (ZIP 2005). +const SUPPORTED_MIGRATIONS: &[SupportedMigration] = &[SupportedMigration { + from: Pool::Orchard, + to: Pool::Ironwood, + enabling_upgrade: NetworkUpgrade::Nu6_3, +}]; + +/// Looks up a supported migration by its ordered pool pair. +fn supported_migration(from: Pool, to: Pool) -> Option<&'static SupportedMigration> { + SUPPORTED_MIGRATIONS + .iter() + .find(|m| m.from == from && m.to == to) +} + +/// Parses and validates a pool pair, returning the parsed pools and the network +/// upgrade that enables the migration. +/// +/// Validates that the pair is present in [`SUPPORTED_MIGRATIONS`] and that its enabling +/// upgrade is active at the wallet's current chain height, returning an +/// `InvalidParameter` RPC error otherwise. +pub(crate) fn validate_pool_pair( + wallet: &DbConnection, + from_pool: &str, + to_pool: &str, +) -> RpcResult<(Pool, Pool, NetworkUpgrade)> { + let from_pool = Pool::parse("from_pool", from_pool)?; + let to_pool = Pool::parse("to_pool", to_pool)?; + + if from_pool == to_pool { + return Err(LegacyCode::InvalidParameter.with_message(format!( + "from_pool and to_pool must differ; both were {:?}", + from_pool.name(), + ))); + } + + let migration = supported_migration(from_pool, to_pool).ok_or_else(|| { + LegacyCode::InvalidParameter.with_message(format!( + "migrating from the {:?} pool to the {:?} pool is not supported", + from_pool.name(), + to_pool.name(), + )) + })?; + + let params = wallet.params(); + let activation = params.activation_height(migration.enabling_upgrade); + + let chain_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InWarmup.with_static("Wallet sync required"))?; + + match activation { + Some(height) if chain_height >= height => { + Ok((from_pool, to_pool, migration.enabling_upgrade)) + } + _ => Err(LegacyCode::InvalidParameter.with_message(format!( + "migrating from the {:?} pool to the {:?} pool requires network upgrade {} \ + to be active", + from_pool.name(), + to_pool.name(), + migration.enabling_upgrade, + ))), + } +} + +/// Decrypts the account's unified spending key. This is the async step that must run BEFORE the +/// blocking build/prove section (no `.await` may occur while the database write lock is held). +/// Mirrors the send path: find the account's ZIP-32 derivation, decrypt its seed, and derive the +/// spending key. +pub(crate) async fn decrypt_account_usk( + wallet: &DbConnection, + keystore: &KeyStore, + account_id: AccountUuid, +) -> RpcResult { + let account = wallet + .get_account(account_id) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InvalidParameter.with_static("no such account"))?; + let derivation = account.source().key_derivation().ok_or_else(|| { + LegacyCode::InvalidAddressOrKey + .with_static("the account has no spending key to migrate with") + })?; + let seed = keystore + .decrypt_seed(derivation.seed_fingerprint()) + .await + .map_err(|e| match e.kind() { + crate::error::ErrorKind::Generic if e.to_string() == "Wallet is locked" => { + LegacyCode::WalletUnlockNeeded.with_message(e.to_string()) + } + _ => LegacyCode::Database.with_message(e.to_string()), + })?; + UnifiedSpendingKey::from_seed( + wallet.params(), + seed.expose_secret(), + derivation.account_index(), + ) + .map_err(|e| LegacyCode::InvalidAddressOrKey.with_message(e.to_string())) +} + +/// One built-but-unsigned migration transaction, as returned to a client driving an EXTERNAL +/// (hardware or offline) signer: its stable id within the migration and its unsigned PCZT, base64 +/// encoded. The client signs the PCZT out of band (on the device) and hands the signed PCZT back via +/// `z_applypoolmigrationsignature`, matched to the transaction by this `id`. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct UnsignedMigrationTransaction { + /// The stable identifier of the transaction within the migration. + pub id: u32, + /// The unsigned PCZT to sign externally, base64 encoded. + pub pczt: String, +} + +/// Base64-encodes the engine's unsigned PCZTs into the RPC response shape, preserving each +/// transaction's id so the signed PCZT can be matched back on the way in. +pub(crate) fn encode_unsigned( + unsigned: Vec, +) -> Vec { + unsigned + .into_iter() + .map(|u| UnsignedMigrationTransaction { + id: u.id.0, + pczt: Base64::encode_string(&u.pczt), + }) + .collect() +} + +/// Validates that a migration identifier is well-formed (currently just non-empty). +pub(crate) fn validate_migration_id(migration_id: &str) -> RpcResult<()> { + if migration_id.trim().is_empty() { + return Err(LegacyCode::InvalidParameter.with_static("migration_id must not be empty")); + } + Ok(()) +} + +/// The lifecycle phase of a pool migration. +#[derive(Clone, Copy, Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MigrationPhase { + /// The migration has been scheduled but no transactions have been created. + Scheduled, + /// The migration is creating and broadcasting transactions. + InProgress, + /// Every planned transaction has been mined. + Completed, + /// The migration was cancelled before completing. + Cancelled, +} + +impl MigrationPhase { + /// The lifecycle phase corresponding to a stored migration's overall status. + pub(crate) fn from_status(status: MigrationStatus) -> Self { + match status { + MigrationStatus::Planning | MigrationStatus::Committed => MigrationPhase::Scheduled, + MigrationStatus::InProgress => MigrationPhase::InProgress, + MigrationStatus::Complete => MigrationPhase::Completed, + MigrationStatus::Failed => MigrationPhase::Cancelled, + } + } +} + +/// Builds the response plan summary from the number of transactions a migration comprises. +pub(crate) fn migration_plan(transaction_count: u32) -> MigrationPlan { + MigrationPlan { transaction_count } +} + +/// Summarizes a migration's progress: how many of its transactions have been mined, out of the +/// total the migration comprises. +pub(crate) fn migration_progress(state: &MigrationState) -> MigrationProgress { + let total_transactions = state.transactions.len() as u32; + let completed_transactions = state + .transactions + .iter() + .filter(|t| matches!(t.state, MigrationTxState::Mined { .. })) + .count() as u32; + MigrationProgress { + completed_transactions, + total_transactions, + } +} + +/// Whether this is a preparation (note-splitting) or a transfer (pool-crossing) transaction. +#[derive(Clone, Copy, Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MigrationTxRole { + /// A same-pool note-preparation transaction that mints self-funding notes. + Preparation, + /// A transfer that crosses one funding note into the destination pool. + Transfer, +} + +/// The lifecycle state of one migration transaction. +#[derive(Clone, Copy, Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MigrationTxLifecycle { + /// A placeholder that is not yet built or signed (it awaits its dependencies). + Planned, + /// Built and awaiting an external signature: its unsigned PCZT has been exported to a hardware or + /// offline signer, and the wallet is waiting for the signed PCZT to be applied. + AwaitingSignature, + /// Built and pre-signed, ready to prove and broadcast once it is due. + Signed, + /// Proved against a real anchor, ready to broadcast. + Proved, + /// Broadcast to the network, awaiting confirmation. + Broadcast, + /// Mined into a block. + Mined, + /// Expired before it could be mined; the wallet rebuilds and re-signs it. + Expired, +} + +/// Why a migration transaction is not yet actionable. +#[derive(Clone, Copy, Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MigrationTxBlocker { + /// Waiting for its dependency transactions (an earlier preparation layer, or the whole + /// preparation) to mine, so its input notes become witnessable in a new anchor bucket. A + /// multi-layer preparation signs and broadcasts each layer in a separate anchor bucket, so a + /// later layer cannot be built until its predecessor has mined. + Dependencies, + /// Built and due only at a later height (the privacy broadcast schedule): waiting for the chain + /// tip to reach its scheduled height. + Schedule, + /// Built as an unsigned PCZT and waiting for an external (hardware or offline) signer to return + /// the signed PCZT, which the wallet then applies before proving and broadcasting. + Signature, +} + +/// The action a wallet takes next on a ready migration transaction. +#[derive(Clone, Copy, Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MigrationTxAction { + /// Build and pre-sign this placeholder now that its dependencies are mined (a later preparation + /// layer, or the transfers once the whole preparation is mined). + BuildAndSign, + /// Prove and broadcast this pre-signed transaction now that its dependencies are mined and it is + /// due. + ProveAndBroadcast, +} + +/// The status of one migration transaction, as a wallet renders it and decides the next step. This +/// is the machine-readable companion to the human status string: a mobile wallet, which cannot +/// pre-sign a multi-layer migration up front and may be restarted between layers, uses it to show +/// the user which transaction to sign or broadcast next and what the rest are waiting on. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct MigrationTransactionStatus { + /// Stable identifier of this transaction within the migration. + id: u32, + /// Whether this is a preparation or a transfer transaction. + kind: MigrationTxRole, + /// For a preparation transaction, its layer, which is also its anchor bucket: layer 0 is signed + /// first, and each later layer is signed only once its predecessor has mined. Absent for + /// transfers. + layer: Option, + /// For a transfer, which crossing it performs. Absent for preparation transactions. + crossing: Option, + /// The transaction's current lifecycle state. + state: MigrationTxLifecycle, + /// The transactions that must be mined before this one can be built or broadcast. + depends_on: Vec, + /// The height at or after which this transaction is due to broadcast. + scheduled_height: u32, + /// Whether the wallet can act on this transaction right now. + ready: bool, + /// The action available now, when `ready` is true. + action: Option, + /// Why the transaction is not yet actionable, when it is waiting (and not already broadcast or + /// mined). + blocked_on: Option, + /// The transaction id, once broadcast (hex, big-endian display form). + txid: Option, + /// The height it was mined at, once mined. + mined_height: Option, +} + +/// Maps one engine transaction status to the JSON-RPC shape: flattens the engine's `kind` into +/// `layer`/`crossing`, maps the lifecycle and reason enums to their serde-friendly counterparts, and +/// renders the txid as a big-endian hex string. +fn to_rpc_status(es: EngineTransactionStatus) -> MigrationTransactionStatus { + let (kind, layer, crossing) = match es.kind { + MigrationTxKind::Preparation { layer, .. } => { + (MigrationTxRole::Preparation, Some(layer as u32), None) + } + MigrationTxKind::Transfer { crossing } => { + (MigrationTxRole::Transfer, None, Some(crossing as u32)) + } + }; + let state = match es.state { + MigrationTxState::Planned => MigrationTxLifecycle::Planned, + MigrationTxState::AwaitingSignature => MigrationTxLifecycle::AwaitingSignature, + MigrationTxState::Signed => MigrationTxLifecycle::Signed, + MigrationTxState::Proved => MigrationTxLifecycle::Proved, + MigrationTxState::Broadcast { .. } => MigrationTxLifecycle::Broadcast, + MigrationTxState::Mined { .. } => MigrationTxLifecycle::Mined, + MigrationTxState::Expired => MigrationTxLifecycle::Expired, + }; + let action = es.action.map(|a| match a { + NextAction::BuildAndSign => MigrationTxAction::BuildAndSign, + NextAction::ProveAndBroadcast => MigrationTxAction::ProveAndBroadcast, + }); + let blocked_on = es.blocked_on.map(|b| match b { + Blocker::Dependencies => MigrationTxBlocker::Dependencies, + Blocker::Schedule => MigrationTxBlocker::Schedule, + Blocker::Signature => MigrationTxBlocker::Signature, + }); + let txid = es.txid.map(|mut bytes| { + bytes.reverse(); + hex::encode(bytes) + }); + MigrationTransactionStatus { + id: es.id.0, + kind, + layer, + crossing, + state, + depends_on: es.depends_on.iter().map(|d| d.0).collect(), + scheduled_height: es.scheduled_height, + ready: es.ready, + action, + blocked_on, + txid, + mined_height: es.mined_height, + } +} + +/// Builds the per-transaction status view for the RPC at `target_height` (the height the next +/// transaction would build at, i.e. `chain_tip + 1`), by mapping the engine's shared +/// `transaction_statuses` decision logic into the JSON-RPC shape. The engine owns the ready/blocked +/// rules so a wallet (the mobile wallet, or zallet here) renders the same next-actions from state. +pub(crate) fn migration_transactions( + state: &MigrationState, + target_height: u32, +) -> Vec { + state + .transaction_statuses(target_height) + .into_iter() + .map(to_rpc_status) + .collect() +} + +/// The RPC error for a migration id that does not name the wallet's migration, or when no migration +/// is stored. +pub(crate) fn no_such_migration() -> ErrorObjectOwned { + LegacyCode::InvalidParameter.with_static("no such migration") +} + +/// Maps a migration build/commit failure to an RPC error. +pub(crate) fn map_commit_failure(failure: CommitFailure) -> ErrorObjectOwned { + match failure { + CommitFailure::NothingToMigrate => LegacyCode::InvalidParameter + .with_static("the account has no spendable source-pool balance to migrate"), + CommitFailure::NoMigrationInProgress => { + LegacyCode::InvalidParameter.with_static("no migration is in progress") + } + CommitFailure::AlreadyInProgress => LegacyCode::InvalidParameter + .with_static("a migration is already in progress; cancel it before starting another"), + CommitFailure::Other(message) => LegacyCode::Misc.with_message(message), + } +} + +/// Maps a migration advance/build failure to an RPC error. +pub(crate) fn map_advance_error(err: AdvanceError) -> ErrorObjectOwned { + match err { + AdvanceError::NoMigration => no_such_migration(), + AdvanceError::Store(message) => LegacyCode::Database.with_message(message), + AdvanceError::Commit(failure) => map_commit_failure(failure), + AdvanceError::Prove(e) => LegacyCode::Misc.with_message(e.to_string()), + AdvanceError::Unsupported(message) => LegacyCode::InvalidParameter.with_message(message), + } +} + +/// The plan produced when a migration is committed. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct MigrationPlan { + /// The number of transactions the migration is expected to require. + transaction_count: u32, +} + +/// Progress of an in-flight migration. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct MigrationProgress { + /// The number of planned transactions that have been mined so far. + completed_transactions: u32, + /// The total number of transactions the migration requires. + total_transactions: u32, +} diff --git a/zallet-core/src/components/json_rpc/methods/preview_pool_migration.rs b/zallet-core/src/components/json_rpc/methods/preview_pool_migration.rs new file mode 100644 index 00000000..59e4ed88 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/preview_pool_migration.rs @@ -0,0 +1,369 @@ +//! `z_previewpoolmigration`: preview the migration plan for a pool migration. +//! +//! This method enumerates the account's spendable source-pool (Orchard) notes and runs the +//! backend-agnostic migration engine's PLANNING slice +//! (`zcash_pool_migration_backend::engine::plan_migration`) to show how that balance would be +//! decomposed into the self-funding notes that cross the Orchard -> Ironwood turnstile, when each +//! note's transfer is scheduled to broadcast, and when it expires. +//! +//! It is READ-ONLY: it computes and returns a plan but schedules, builds, proves, and broadcasts +//! nothing. `z_startpoolmigration` commits the plan (building and pre-signing the PCZTs) and +//! `z_advancepoolmigration` drives it. This preview is the plan a wallet shows the user for consent +//! (ZIP 318 requires consent to the pool-crossing amounts before any funds leave the pool). + +use documented::Documented; +use jsonrpsee::core::{JsonValue, RpcResult}; +use rand::rngs::OsRng; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_client_backend::{ + data_api::{InputSource, WalletRead, wallet::TargetHeight}, + fees::orchard::InputView as _, +}; +use zcash_protocol::ShieldedPool; + +use super::pool_migration::{Pool, validate_pool_pair}; +use crate::{ + components::{ + database::DbConnection, + json_rpc::{server::LegacyCode, utils::parse_account_parameter}, + keystore::KeyStore, + }, + migrate::{ + SpendableSnapshot, + engine::{ + engine::{MigrationError, MigrationPlan, plan_migration}, + note_splitting::{FeePolicy, Zip317FeePolicy}, + preparation::{PREP_TX_ACTIONS, PreparationPlan}, + }, + }, +}; + +/// Response to a `z_previewpoolmigration` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = PoolMigrationPreview; + +pub(super) const PARAM_ACCOUNT_DESC: &str = + "Either the UUID or ZIP 32 account index of the account whose balance to migrate."; +pub(super) const PARAM_FROM_POOL_DESC: &str = + "The value pool to migrate funds from (\"sapling\", \"orchard\", or \"ironwood\")."; +pub(super) const PARAM_TO_POOL_DESC: &str = + "The value pool to migrate funds to (\"sapling\", \"orchard\", or \"ironwood\")."; +pub(super) const PARAM_MINCONF_DESC: &str = + "Only include source-pool notes confirmed at least this many times (default 1)."; + +/// The default minimum number of confirmations a source-pool note must have to be planned over. +const DEFAULT_MINCONF: u32 = 1; + +/// The proposed migration plan for moving an account's balance between two pools. +/// +/// Read-only preview of the plan produced by the migration engine. Nothing is scheduled or +/// broadcast; the fields describe what a migration run would prepare, cross, and leave behind. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct PoolMigrationPreview { + /// The value pool funds would be migrated from. + from_pool: Pool, + /// The value pool funds would be migrated to. + to_pool: Pool, + /// The network upgrade that enables this migration (for example `"Nu6.3"`). + enabling_upgrade: String, + /// The account's total spendable balance in the source pool (in zatoshis): the sum of the + /// spendable source-pool notes the plan decomposes. + account_balance_zat: u64, + /// The fee (in zatoshis) reserved for each note-preparation transaction: the ZIP-317 fee of a + /// padded preparation transaction (its fixed action count times the marginal fee). + prep_fee_zat: u64, + /// The total value (in zatoshis) that would migrate to the destination pool: the sum of the + /// crossing values of the funding notes the plan actually mints (after reconciling the split + /// against the preparation fees). + total_migratable_zat: u64, + /// Residual (in zatoshis) left in the source pool by the note split because it could not form a + /// whole self-funding note (or the note cap was reached). This is the note-split residual only; + /// the preparation transactions may leave further residual notes (see + /// [`PreviewPreparation::residual_note_count`]). + source_change_zat: u64, + /// The number of self-funding notes the plan would mint (equal to `funding_notes.len()`). + funding_note_count: u32, + /// The per-note breakdown, one entry per funding note the plan mints, paired with that note's + /// transfer schedule. + funding_notes: Vec, + /// A summary of the note-split decomposition before it was reconciled against the preparation + /// fees (the funding notes above are this split minus any denominations dropped to fit the + /// fees). + note_split: PreviewNoteSplit, + /// A summary of the note-preparation transactions that would mint the funding notes. + preparation: PreviewPreparation, +} + +/// One funding note in a [`PoolMigrationPreview`], with its transfer schedule. +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct PreviewFundingNote { + /// The value (in zatoshis) of the self-funding note minted in the source pool: its crossing + /// value plus the fee buffer that lets it pay its own migration-transfer fee. + output_zat: u64, + /// The denomination value (in zatoshis) that crosses the turnstile when this note is spent. + crossing_zat: u64, + /// The block height at which this note's migration transfer is scheduled to be broadcast. + broadcast_height: u32, + /// The block height at (and after) which this note's migration transfer is no longer valid. + expiry_height: u32, +} + +/// A summary of the note-split decomposition in a [`PoolMigrationPreview`], before reconciliation +/// against the preparation fees. +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct PreviewNoteSplit { + /// The number of denominations the raw split produced. + note_count: u32, + /// The total value (in zatoshis) the raw split would migrate (the sum of its crossing values). + total_migratable_zat: u64, + /// The crossing values (in zatoshis) the raw split chose, one per denomination. + crossing_values: Vec, +} + +/// A summary of the note-preparation transactions in a [`PoolMigrationPreview`]. +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct PreviewPreparation { + /// The number of sequential dependency layers of preparation transactions. + layer_count: u32, + /// The total number of preparation transactions across all layers. + transaction_count: u32, + /// The number of residual notes the preparation leaves in the source pool (at most one worth a + /// fee, plus any sub-fee dust). + residual_note_count: u32, +} + +pub(crate) async fn call( + wallet: &DbConnection, + keystore: &KeyStore, + account: JsonValue, + from_pool: &str, + to_pool: &str, + minconf: Option, +) -> Response { + let (from_pool, to_pool, enabling_upgrade) = validate_pool_pair(wallet, from_pool, to_pool)?; + let account_id = parse_account_parameter(wallet, keystore, &account).await?; + + let minconf = minconf.unwrap_or(DEFAULT_MINCONF); + + // The chain tip: the height the schedule's delays accumulate from, and the basis for the target + // height at which notes are selected. Absent it, the wallet has not synced far enough to plan. + let chain_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InWarmup.with_static("Wallet sync required"))?; + let target_height = TargetHeight::from(chain_height + 1); + + // Enumerate the account's spendable source-pool (Orchard) notes as individual values: the engine + // decomposes their total, and the preparation planner needs the per-note values. Mirrors the note + // selection and confirmation filter used by `z_listunspent`. + let received = wallet + .select_unspent_notes(account_id, &[ShieldedPool::Orchard], target_height, &[]) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + + let mut orchard_note_values = Vec::new(); + for note in received.orchard().iter() { + let mined_height = wallet + .get_tx_height(*note.txid()) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + // Include only notes mined with at least `minconf` confirmations; skip unmined notes. + match mined_height { + Some(h) if h <= target_height.saturating_sub(minconf) => { + orchard_note_values.push(u64::from(note.value())); + } + _ => {} + } + } + + let account_balance_zat: u64 = orchard_note_values.iter().sum(); + + // The ZIP-317 fee of a padded note-preparation transaction, which the note split and the + // preparation planner both reserve. Built from the crate's own constants (no magic numbers). + let prep_fee_zat = PREP_TX_ACTIONS as u64 * Zip317FeePolicy.marginal_fee_zatoshi(); + + // No `.await` past this point: build the snapshot backend and run the pure planner synchronously. + let backend = SpendableSnapshot::new(orchard_note_values, u32::from(chain_height)); + let mut rng = OsRng; + let plan = plan_migration(&backend, prep_fee_zat, &mut rng).map_err(map_plan_error)?; + + Ok(preview_from_plan( + from_pool, + to_pool, + enabling_upgrade.to_string(), + account_balance_zat, + &plan, + )) +} + +/// Maps a migration-planning error to an RPC error. +fn map_plan_error( + err: MigrationError, +) -> jsonrpsee::types::ErrorObjectOwned { + match err { + MigrationError::NothingToMigrate => LegacyCode::InvalidParameter + .with_static("the account has no spendable source-pool balance to migrate"), + MigrationError::Preparation(e) => LegacyCode::InvalidParameter.with_message(format!( + "the spendable notes cannot fund the migration: {e}" + )), + // The planning snapshot's read methods are infallible, so this arm is unreachable. + MigrationError::Backend(e) => match e {}, + } +} + +/// Assembles the RPC response from a planned migration. +/// +/// Kept pure (no wallet access) so the response contract can be unit-tested directly against a plan. +fn preview_from_plan( + from_pool: Pool, + to_pool: Pool, + enabling_upgrade: String, + account_balance_zat: u64, + plan: &MigrationPlan, +) -> PoolMigrationPreview { + // Each funding note holds its crossing value plus a fixed transfer fee buffer, so the crossing + // value is the funding note less that buffer. + let buffer = Zip317FeePolicy.transfer_fee_buffer_zatoshi(); + + let funding_notes = plan + .funding_notes() + .iter() + .zip(plan.schedule()) + .map(|(&output_zat, schedule)| PreviewFundingNote { + output_zat, + crossing_zat: output_zat.saturating_sub(buffer), + broadcast_height: schedule.broadcast_height(), + expiry_height: schedule.expiry_height(), + }) + .collect::>(); + + let total_migratable_zat = funding_notes.iter().map(|n| n.crossing_zat).sum(); + + PoolMigrationPreview { + from_pool, + to_pool, + enabling_upgrade, + account_balance_zat, + prep_fee_zat: plan.note_split().prep_fee_zatoshi(), + total_migratable_zat, + source_change_zat: plan.note_split().change().unwrap_or(0), + funding_note_count: funding_notes.len() as u32, + funding_notes, + note_split: PreviewNoteSplit { + note_count: plan.note_split().crossing_values().len() as u32, + total_migratable_zat: plan.note_split().total_migratable_zatoshi(), + crossing_values: plan.note_split().crossing_values().to_vec(), + }, + preparation: preparation_summary(plan.preparation()), + } +} + +/// Summarizes a preparation plan for the preview. +fn preparation_summary(preparation: &PreparationPlan) -> PreviewPreparation { + PreviewPreparation { + layer_count: preparation.layer_count() as u32, + transaction_count: preparation.transaction_count() as u32, + residual_note_count: preparation.residual_count() as u32, + } +} + +#[cfg(test)] +mod tests { + use rand::rngs::OsRng; + use zcash_protocol::value::COIN; + + use super::*; + use crate::migrate::engine::engine::MigrationBackend; + + /// A minimal planning backend for tests: a fixed set of spendable note values and a chain tip. + /// Mirrors `SpendableSnapshot`; planning is infallible. + struct MockBackend { + notes: Vec, + tip: u32, + } + + impl MigrationBackend for MockBackend { + type Error = core::convert::Infallible; + + fn spendable_orchard_note_values(&self) -> Result, Self::Error> { + Ok(self.notes.clone()) + } + + fn chain_tip_height(&self) -> Result { + Ok(self.tip) + } + } + + /// The ZIP-317 fee the preview reserves, as `call` computes it. + fn prep_fee() -> u64 { + PREP_TX_ACTIONS as u64 * Zip317FeePolicy.marginal_fee_zatoshi() + } + + #[test] + fn preview_reports_a_scheduled_conserving_plan() { + // Decompose a sample Orchard balance held as one large note and check the response mirrors + // the plan: every funding note has a positive crossing its output covers by exactly the fee + // buffer, each is scheduled, and the migratable total is the sum of the crossings. + let notes = vec![723 * COIN]; + let account_balance_zat: u64 = notes.iter().sum(); + let backend = MockBackend { + notes, + tip: 2_000_000, + }; + let mut rng = OsRng; + let plan = plan_migration(&backend, prep_fee(), &mut rng).expect("a funded balance plans"); + + let preview = preview_from_plan( + Pool::Orchard, + Pool::Ironwood, + "Nu6.3".to_string(), + account_balance_zat, + &plan, + ); + + assert_eq!(preview.account_balance_zat, account_balance_zat); + assert_eq!(preview.prep_fee_zat, prep_fee()); + assert_eq!( + preview.funding_note_count as usize, + preview.funding_notes.len() + ); + assert!(preview.funding_note_count > 0); + + // One schedule entry per funding note (the engine guarantees this). + assert_eq!(preview.funding_notes.len(), plan.schedule().len()); + + let buffer = Zip317FeePolicy.transfer_fee_buffer_zatoshi(); + let mut crossings_sum = 0u64; + for note in &preview.funding_notes { + assert!(note.crossing_zat > 0, "every crossing value is positive"); + assert_eq!( + note.output_zat, + note.crossing_zat + buffer, + "the output funds the crossing plus exactly the fee buffer" + ); + assert!( + note.expiry_height > note.broadcast_height, + "expiry follows broadcast" + ); + crossings_sum += note.crossing_zat; + } + assert_eq!(preview.total_migratable_zat, crossings_sum); + + // The migratable value never exceeds the input balance. + assert!(preview.total_migratable_zat <= preview.account_balance_zat); + // Reconciliation only drops funding notes, so at most as many as the raw split. + assert!(preview.funding_notes.len() <= preview.note_split.note_count as usize); + } + + #[test] + fn empty_balance_has_nothing_to_migrate() { + let backend = MockBackend { + notes: Vec::new(), + tip: 2_000_000, + }; + let mut rng = OsRng; + assert!(matches!( + plan_migration(&backend, prep_fee(), &mut rng), + Err(MigrationError::NothingToMigrate) + )); + } +} diff --git a/zallet-core/src/components/json_rpc/methods/sign_pool_migration_pczt.rs b/zallet-core/src/components/json_rpc/methods/sign_pool_migration_pczt.rs new file mode 100644 index 00000000..0bae9a82 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/sign_pool_migration_pczt.rs @@ -0,0 +1,58 @@ +//! `z_signpoolmigrationpczt`: sign a migration PCZT with the account's spend authorization. +//! +//! For offline / air-gapped signing (build the migration on one wallet, sign on another holding the +//! key), and the software stand-in a hardware device performs on-device: it takes an unsigned +//! migration PCZT (from `z_startpoolmigration` with `external_signer`, or +//! `z_buildpoolmigrationtransfers`), adds only the account's Orchard spend-authorization signature (the +//! Signer role), and returns the signed PCZT to apply with `z_applypoolmigrationsignature`. It does not +//! prove, extract, or broadcast. A hardware wallet performs the equivalent signing on the device +//! instead of calling this. + +use base64ct::{Base64, Encoding}; +use documented::Documented; +use jsonrpsee::core::{JsonValue, RpcResult}; +use schemars::JsonSchema; +use serde::Serialize; + +use super::pool_migration::decrypt_account_usk; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::components::json_rpc::utils::parse_account_parameter; +use crate::components::keystore::KeyStore; +use crate::migrate::sign_migration_pczt; + +/// Response to a `z_signpoolmigrationpczt` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = SignPoolMigrationPczt; + +pub(super) const PARAM_ACCOUNT_DESC: &str = + "Either the UUID or ZIP 32 account index of the account whose spend key signs the PCZT."; +pub(super) const PARAM_PCZT_DESC: &str = "The unsigned migration PCZT to sign, base64 encoded (from z_startpoolmigration with \ + external_signer, or z_buildpoolmigrationtransfers)."; + +/// The result of signing a migration PCZT. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct SignPoolMigrationPczt { + /// The signed PCZT, base64 encoded, to apply with z_applypoolmigrationsignature. + pczt: String, +} + +pub(crate) async fn call( + wallet: &DbConnection, + keystore: &KeyStore, + account: JsonValue, + pczt: &str, +) -> Response { + let account_id = parse_account_parameter(wallet, keystore, &account).await?; + let usk = decrypt_account_usk(wallet, keystore, account_id).await?; + + let bytes = Base64::decode_vec(pczt) + .map_err(|_| LegacyCode::InvalidParameter.with_static("Malformed base64 PCZT"))?; + // Signing is CPU-light and touches no wallet database, so it needs no blocking section. + let signed = sign_migration_pczt(&usk, &bytes) + .map_err(|e| LegacyCode::Misc.with_message(e.to_string()))?; + + Ok(SignPoolMigrationPczt { + pczt: Base64::encode_string(&signed), + }) +} diff --git a/zallet-core/src/components/json_rpc/methods/start_pool_migration.rs b/zallet-core/src/components/json_rpc/methods/start_pool_migration.rs new file mode 100644 index 00000000..cb920c70 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/start_pool_migration.rs @@ -0,0 +1,128 @@ +//! `z_startpoolmigration`: start a generic pool-to-pool migration. +//! +//! Builds and pre-signs the note-preparation transactions that split the account's source-pool +//! balance into the self-funding notes that cross the turnstile, records the transfer schedule, and +//! persists the whole committed migration (as pre-signed PCZTs plus metadata) so it can be advanced +//! and broadcast later. Nothing is proved or broadcast here. + +use documented::Documented; +use jsonrpsee::core::{JsonValue, RpcResult}; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_client_backend::data_api::WalletRead; + +use super::pool_migration::{ + MIGRATION_ID, MigrationPlan, Pool, UnsignedMigrationTransaction, decrypt_account_usk, + encode_unsigned, map_commit_failure, migration_plan, validate_pool_pair, +}; +use crate::components::database::DbConnection; +use crate::components::json_rpc::server::LegacyCode; +use crate::components::json_rpc::utils::parse_account_parameter; +use crate::components::keystore::KeyStore; +use crate::migrate::{ + CommitFailure, build_preparation_unsigned_over_wallet, commit_preparation_over_wallet, + load_migration, persist_migration, +}; + +/// Response to a `z_startpoolmigration` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = StartPoolMigration; + +pub(super) const PARAM_ACCOUNT_DESC: &str = + "Either the UUID or ZIP 32 account index of the account whose balance to migrate."; +pub(super) const PARAM_FROM_POOL_DESC: &str = + "The value pool to migrate funds from (\"sapling\", \"orchard\", or \"ironwood\")."; +pub(super) const PARAM_TO_POOL_DESC: &str = + "The value pool to migrate funds to (\"sapling\", \"orchard\", or \"ironwood\")."; +pub(super) const PARAM_EXTERNAL_SIGNER_DESC: &str = "When true, build the preparation transactions unsigned for an external (hardware or offline) \ + signer and return their PCZTs to sign on the device; when false (default) pre-sign in process."; + +/// The result of starting a pool migration. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct StartPoolMigration { + /// Opaque identifier for the migration, used with the status, advance, and cancel methods. + migration_id: String, + /// The value pool funds are being migrated from. + from_pool: Pool, + /// The value pool funds are being migrated to. + to_pool: Pool, + /// The network upgrade that enables this migration (for example `"Nu6.3"`). + enabling_upgrade: String, + /// The plan describing how the migration will be carried out. + plan: MigrationPlan, + /// When the migration was started for an EXTERNAL signer (`external_signer=true`), the unsigned + /// preparation PCZTs to sign on the device; each is applied back with + /// `z_applypoolmigrationsignature`. Absent for the default in-process-signing path (where the + /// preparation is already pre-signed). + #[serde(skip_serializing_if = "Option::is_none")] + unsigned_transactions: Option>, +} + +pub(crate) async fn call( + wallet: &DbConnection, + keystore: &KeyStore, + account: JsonValue, + from_pool: &str, + to_pool: &str, + external_signer: Option, +) -> Response { + let (from_pool, to_pool, enabling_upgrade) = validate_pool_pair(wallet, from_pool, to_pool)?; + let account_id = parse_account_parameter(wallet, keystore, &account).await?; + let external_signer = external_signer.unwrap_or(false); + + // The transactions build at the height after the current chain tip. + let chain_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InWarmup.with_static("Wallet sync required"))?; + let target_height = u32::from(chain_height) + 1; + + // Decrypt the account's spending key. This is the only async step, and it happens BEFORE the + // blocking build section (no `.await` may occur inside `with_raw_mut`). For an external signer, + // the key still builds the PCZTs (it derives the account's viewing key and witnesses); it just + // does not sign them, leaving that to the device. + let usk = decrypt_account_usk(wallet, keystore, account_id).await?; + + // Build the preparation over the wallet, then persist the resulting migration. Both the engine's + // wallet access and the store write run on one connection, sequentially. In the default path the + // transactions are pre-signed; for an external signer they are left unsigned (in + // `AwaitingSignature`) and their PCZTs are returned for the device to sign. + let (state, unsigned_transactions) = wallet + .with_raw_mut(|conn, network| -> Result<_, CommitFailure> { + // Refuse to overwrite an in-progress migration: its transactions would be lost. A + // terminal (complete or failed) migration may be replaced. + if let Some(existing) = + load_migration(conn).map_err(|e| CommitFailure::Other(e.to_string()))? + { + if !existing.is_terminal() { + return Err(CommitFailure::AlreadyInProgress); + } + } + let (state, unsigned) = if external_signer { + let (state, unsigned) = build_preparation_unsigned_over_wallet( + conn, + network, + account_id, + usk, + target_height, + )?; + (state, Some(encode_unsigned(unsigned))) + } else { + let state = + commit_preparation_over_wallet(conn, network, account_id, usk, target_height)?; + (state, None) + }; + persist_migration(conn, &state).map_err(|e| CommitFailure::Other(e.to_string()))?; + Ok((state, unsigned)) + }) + .map_err(map_commit_failure)?; + + Ok(StartPoolMigration { + migration_id: MIGRATION_ID.to_string(), + from_pool, + to_pool, + enabling_upgrade: enabling_upgrade.to_string(), + plan: migration_plan(state.transactions.len() as u32), + unsigned_transactions, + }) +} diff --git a/zallet-core/src/lib.rs b/zallet-core/src/lib.rs index 56aed57f..16c6d448 100644 --- a/zallet-core/src/lib.rs +++ b/zallet-core/src/lib.rs @@ -62,6 +62,7 @@ pub mod components; pub mod config; pub mod error; mod i18n; +pub mod migrate; pub mod network; mod prelude; mod task; diff --git a/zallet-core/src/migrate.rs b/zallet-core/src/migrate.rs new file mode 100644 index 00000000..9b5d69e2 --- /dev/null +++ b/zallet-core/src/migrate.rs @@ -0,0 +1,691 @@ +//! Orchard-to-Ironwood value-pool migration wiring. +//! +//! This module wires the backend-agnostic pool-migration engine into Zallet. The +//! engine crate is still evolving upstream (it lives on a librustzcash feature +//! branch and is not yet released to crates.io), so this module keeps the +//! integration seam small: the rest of Zallet reaches the engine through the +//! [`engine`] re-export rather than depending on the external crate name +//! directly. +//! +//! It also provides [`SpendableSnapshot`], Zallet's implementation of the +//! engine's `MigrationBackend` trait for the PLANNING slice. The engine's +//! `plan_migration` needs only the account's spendable source-pool note values +//! and the chain-tip height, so the snapshot captures both from the wallet up +//! front and serves them back to the engine. Capturing a snapshot (rather than +//! querying the wallet from inside the trait methods) keeps all wallet I/O and its +//! error handling in the RPC layer, and lets the pure planner run synchronously +//! after the last `.await`. +//! +//! Persistence is a separate concern: the engine's `PoolMigrationRead` / +//! `PoolMigrationWrite` store traits (implemented over the wallet database by +//! `zcash_pool_migration_sqlite`) hold a committed migration, and the +//! `MigrationBackend` trait this snapshot implements is now just the planning +//! inputs. Committing a migration uses the `WalletMigration` adapter over the +//! wallet, not this snapshot. + +use core::convert::Infallible; +use core::fmt; +use std::sync::OnceLock; + +use orchard::circuit::{OrchardCircuitVersion, ProvingKey, VerifyingKey}; +use orchard::keys::SpendAuthorizingKey; +use pczt::Pczt; +use pczt::roles::prover::Prover; +use pczt::roles::tx_extractor::TransactionExtractor; +use rand::rngs::OsRng; +use rusqlite::Connection; +use zcash_client_backend::data_api::WalletRead; +use zcash_client_sqlite::{AccountUuid, WalletDb, util::SystemClock}; +use zcash_keys::keys::UnifiedSpendingKey; +use zcash_pool_migration_backend::build::sign_pczt; +use zcash_pool_migration_backend::engine::{ + CommitError, MigrationBackend, MigrationError, MigrationState, MigrationStatus, MigrationTxId, + MigrationTxKind, MigrationTxState, PoolMigrationRead, PoolMigrationWrite, UnsignedMigrationTx, + build_preparation_unsigned, build_transfers_unsigned, commit_pending_preparation, + commit_preparation, commit_transfers, plan_migration, +}; +// The migration state machine lives in the engine (the mobile wallet drives it the same way); zallet +// only performs the wallet I/O around the engine's decisions. The decision and transition logic are +// methods on `MigrationState` (`next_step`, `mark_mined`, `mark_broadcast`, `is_terminal`, ...). +use zcash_pool_migration_backend::note_splitting::{FeePolicy, Zip317FeePolicy}; +use zcash_pool_migration_backend::preparation::PREP_TX_ACTIONS; +use zcash_pool_migration_backend::state::AdvanceStep; +use zcash_pool_migration_backend::wallet::WalletMigration; +use zcash_pool_migration_sqlite::PoolMigrations; +use zcash_primitives::transaction::components::orchard::bundle_version_for_branch; +use zcash_primitives::transaction::{Transaction, TxId}; +use zcash_protocol::consensus::{BlockHeight, BranchId}; + +use crate::network::Network; + +/// The backend-agnostic value-pool migration engine. +/// +/// Re-exported from the `zcash_pool_migration_backend` crate (formerly +/// `zcash_ironwood_migration_backend`, renamed upstream to a pool-agnostic name). +/// Its API is not yet stable; treat this re-export as the integration seam and +/// avoid coupling Zallet code to specific items until the engine is released. +pub use zcash_pool_migration_backend as engine; + +/// A point-in-time snapshot of the inputs the migration engine needs to PLAN a +/// migration for one account: the values of the account's spendable source-pool +/// (Orchard) notes, and the chain-tip height. +/// +/// This is Zallet's `MigrationBackend` for planning. The caller gathers both +/// values from the wallet (mapping any wallet error to an RPC error), then hands +/// the snapshot to `engine::plan_migration`. It holds only already-read values, +/// so it is infallible; committing a migration uses the `WalletMigration` +/// adapter over the wallet, not this snapshot. +pub struct SpendableSnapshot { + orchard_note_values: Vec, + chain_tip_height: u32, +} + +impl SpendableSnapshot { + /// Builds a snapshot from the spendable source-pool note values and the + /// chain-tip height the caller has already read from the wallet. + pub fn new(orchard_note_values: Vec, chain_tip_height: u32) -> Self { + Self { + orchard_note_values, + chain_tip_height, + } + } +} + +impl MigrationBackend for SpendableSnapshot { + type Error = Infallible; + + fn spendable_orchard_note_values(&self) -> Result, Self::Error> { + Ok(self.orchard_note_values.clone()) + } + + fn chain_tip_height(&self) -> Result { + Ok(self.chain_tip_height) + } +} + +/// Why committing (building/pre-signing) a migration failed, in terms the RPC layer maps to +/// user-facing errors without depending on the engine's error shapes. +pub enum CommitFailure { + /// The account has no spendable source-pool balance to migrate. + NothingToMigrate, + /// There is no committed migration to act on (nothing was loaded from the store). + NoMigrationInProgress, + /// A migration is already in progress; starting another would overwrite its pre-signed + /// transactions. The caller must cancel the current migration first. + AlreadyInProgress, + /// Any other build/backend failure, rendered to a string. + Other(String), +} + +fn map_plan_error(err: MigrationError) -> CommitFailure { + match err { + MigrationError::NothingToMigrate => CommitFailure::NothingToMigrate, + MigrationError::Preparation(e) => CommitFailure::Other(format!( + "the spendable notes cannot fund the migration: {e}" + )), + MigrationError::Backend(e) => CommitFailure::Other(e.to_string()), + } +} + +fn map_commit_error(err: CommitError) -> CommitFailure { + match err { + CommitError::NoMigrationInProgress => CommitFailure::NoMigrationInProgress, + CommitError::Backend(e) => CommitFailure::Other(e.to_string()), + CommitError::Build(m) => CommitFailure::Other(m), + } +} + +/// An in-memory migration store used to run the engine's commit functions WITHOUT letting them +/// write to SQLite. `commit_preparation` / `commit_transfers` return the resulting +/// [`MigrationState`], so the caller runs the engine against this store, then persists the returned +/// state to the real SQLite store separately. This keeps the engine's wallet access and the store's +/// database access on ONE connection, used sequentially (never two live borrows at once). +#[derive(Default)] +pub struct InMemoryStore { + state: Option, +} + +impl InMemoryStore { + /// An in-memory store pre-seeded with a loaded migration (for `commit_transfers`, which reads + /// the in-progress migration before filling in the transfers). + pub fn seeded(state: Option) -> Self { + Self { state } + } +} + +impl PoolMigrationRead for InMemoryStore { + type Error = Infallible; + + fn get_migration(&self) -> Result, Self::Error> { + Ok(self.state.clone()) + } +} + +impl PoolMigrationWrite for InMemoryStore { + fn put_migration(&mut self, state: &MigrationState) -> Result<(), Self::Error> { + self.state = Some(state.clone()); + Ok(()) + } + + fn update_transaction( + &mut self, + id: MigrationTxId, + tx_state: MigrationTxState, + ) -> Result<(), Self::Error> { + if let Some(state) = &mut self.state { + if let Some(tx) = state.transactions.iter_mut().find(|t| t.id == id) { + tx.state = tx_state; + } + } + Ok(()) + } +} + +/// The ZIP-317 fee reserved per note-preparation transaction (the fixed padded action count times +/// the marginal fee), as the engine's planning and preparation both compute it. +fn prep_fee_zatoshi() -> u64 { + PREP_TX_ACTIONS as u64 * Zip317FeePolicy.marginal_fee_zatoshi() +} + +/// Loads the persisted migration from the SQLite store over `conn`. +pub fn load_migration( + conn: &mut Connection, +) -> Result, zcash_pool_migration_sqlite::Error> { + PoolMigrations::new(&mut *conn).get_migration() +} + +/// Persists a migration to the SQLite store over `conn`, replacing any existing one. +pub fn persist_migration( + conn: &mut Connection, + state: &MigrationState, +) -> Result<(), zcash_pool_migration_sqlite::Error> { + PoolMigrations::new(&mut *conn).put_migration(state) +} + +/// Plans and commits the PREPARATION of a migration over the wallet: builds and pre-signs every +/// preparation transaction and records the transfer placeholders, returning the resulting state +/// (NOT persisted; the caller persists it). Runs the engine synchronously; call inside the blocking +/// database section, after the spending key has been decrypted. +pub fn commit_preparation_over_wallet( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + target_height: u32, +) -> Result { + let mut wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + let mut migration = WalletMigration::new(&mut wallet, account, usk, InMemoryStore::default()); + let mut rng = OsRng; + let plan = plan_migration(&migration, prep_fee_zatoshi(), &mut rng).map_err(map_plan_error)?; + commit_preparation(network, target_height, &mut migration, &plan, &mut rng) + .map_err(map_commit_error) +} + +/// Commits the TRANSFERS of an in-progress migration over the wallet: builds and pre-signs the +/// phase-2 transfer transactions (whose funding notes the mined preparation created), returning the +/// updated state (NOT persisted; the caller persists it). `loaded` is the migration read from the +/// store. Runs the engine synchronously; call inside the blocking database section. +pub fn commit_transfers_over_wallet( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + target_height: u32, + loaded: MigrationState, +) -> Result { + let mut wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + let store = InMemoryStore::seeded(Some(loaded)); + let mut migration = WalletMigration::new(&mut wallet, account, usk, store); + let mut rng = OsRng; + commit_transfers(network, target_height, &mut migration, &mut rng).map_err(map_commit_error) +} + +/// Commits the next ready PREPARATION LAYER of an in-progress multi-layer migration over the wallet: +/// a later preparation layer spends the feeder notes minted by the layer before it, which are +/// witnessable only once that layer is mined, so the layer is built here rather than up front. Builds +/// and pre-signs every transaction of the earliest still-unbuilt layer whose predecessor has mined, +/// returning the updated state (NOT persisted; the caller persists it). `loaded` is the migration read +/// from the store. If no layer is ready it returns the state unchanged. Runs the engine synchronously; +/// call inside the blocking database section. +pub fn commit_pending_preparation_over_wallet( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + target_height: u32, + loaded: MigrationState, +) -> Result { + let mut wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + let store = InMemoryStore::seeded(Some(loaded)); + let mut migration = WalletMigration::new(&mut wallet, account, usk, store); + let mut rng = OsRng; + commit_pending_preparation(network, target_height, &mut migration, &mut rng) + .map_err(map_commit_error) +} + +/// Plans and builds the PREPARATION of a migration for an EXTERNAL signer: builds every layer-0 +/// preparation transaction but leaves it UNSIGNED, returning the resulting state (NOT persisted; the +/// caller persists it) together with the unsigned PCZTs to route to the signing device. Mirrors +/// [`commit_preparation_over_wallet`] but leaves the transactions in `AwaitingSignature`; the caller +/// applies the signed PCZTs with [`MigrationState::apply_signature`]. Runs the engine synchronously; +/// call inside the blocking database section. +pub fn build_preparation_unsigned_over_wallet( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + target_height: u32, +) -> Result<(MigrationState, Vec), CommitFailure> { + let mut wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + let mut migration = WalletMigration::new(&mut wallet, account, usk, InMemoryStore::default()); + let mut rng = OsRng; + let plan = plan_migration(&migration, prep_fee_zatoshi(), &mut rng).map_err(map_plan_error)?; + build_preparation_unsigned(network, target_height, &mut migration, &plan, &mut rng) + .map_err(map_commit_error) +} + +/// Builds the TRANSFERS of an in-progress migration for an EXTERNAL signer: builds the phase-2 +/// transfer transactions (whose funding notes the mined preparation created) but leaves them +/// UNSIGNED, returning the updated state (NOT persisted; the caller persists it) together with the +/// unsigned PCZTs to route to the signing device. Mirrors [`commit_transfers_over_wallet`] but leaves +/// the transactions in `AwaitingSignature`. `loaded` is the migration read from the store. Runs the +/// engine synchronously; call inside the blocking database section. +pub fn build_transfers_unsigned_over_wallet( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + target_height: u32, + loaded: MigrationState, +) -> Result<(MigrationState, Vec), CommitFailure> { + let mut wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + let store = InMemoryStore::seeded(Some(loaded)); + let mut migration = WalletMigration::new(&mut wallet, account, usk, store); + let mut rng = OsRng; + build_transfers_unsigned(network, target_height, &mut migration, &mut rng) + .map_err(map_commit_error) +} + +/// Signs a migration PCZT with the account's Orchard spend authorization, for offline / air-gapped or +/// external-device signing. Parses the unsigned PCZT, adds only the spend-auth signature (the Signer +/// role; it neither proves nor extracts), and returns the serialized signed PCZT to apply to the +/// migration via [`MigrationState::apply_signature`]. This is the counterpart the external signer runs +/// on the built PCZT that [`build_preparation_unsigned_over_wallet`] / +/// [`build_transfers_unsigned_over_wallet`] produced; a hardware wallet performs the same step on the +/// device. +pub fn sign_migration_pczt( + usk: &UnifiedSpendingKey, + pczt_bytes: &[u8], +) -> Result, SignFailure> { + let pczt = + Pczt::parse(pczt_bytes).map_err(|e| SignFailure(format!("parsing the PCZT: {e:?}")))?; + let ask = SpendAuthorizingKey::from(usk.orchard()); + let signed = + sign_pczt(pczt, &ask).map_err(|e| SignFailure(format!("signing the PCZT: {e:?}")))?; + signed + .serialize() + .map_err(|e| SignFailure(format!("serializing the signed PCZT: {e:?}"))) +} + +/// A failure to sign a migration PCZT with the account's spend authorization. +pub struct SignFailure(pub String); + +impl fmt::Display for SignFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Why proving or extracting a migration transaction failed. +pub enum BroadcastError { + /// The stored PCZT could not be parsed. + Parse(String), + /// Proving the PCZT failed. + Prove(String), + /// Extracting the transaction from the proved PCZT failed. + Extract(String), +} + +impl fmt::Display for BroadcastError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BroadcastError::Parse(m) => write!(f, "parsing the migration transaction failed: {m}"), + BroadcastError::Prove(m) => write!(f, "proving the migration transaction failed: {m}"), + BroadcastError::Extract(m) => { + write!(f, "extracting the migration transaction failed: {m}") + } + } + } +} + +/// The Orchard circuit version the migration's transactions are proved and verified against, derived +/// from the consensus branch at `height`. A migration runs at NU6.3+, whose Orchard protocol +/// revision fixes the circuit version; the same version applies to the Orchard preparation bundles +/// and the Ironwood transfer bundles. +fn orchard_circuit_version(params: &Network, height: BlockHeight) -> OrchardCircuitVersion { + let branch = BranchId::for_height(params, height); + bundle_version_for_branch(branch, orchard::ValuePool::Orchard) + .expect("a migration runs at NU6.3+, which has an Orchard bundle version") + .circuit_version() +} + +/// The Orchard proving key, built once and cached (building it is expensive). The same key proves +/// both the Orchard preparation bundles and the Ironwood transfer bundles. All of a migration's +/// transactions share one circuit version (its consensus branch), so caching a single key is sound. +fn orchard_proving_key(version: OrchardCircuitVersion) -> &'static ProvingKey { + static PROVING_KEY: OnceLock = OnceLock::new(); + PROVING_KEY.get_or_init(|| ProvingKey::build(version)) +} + +/// The Orchard verifying key, built once and cached. It verifies both the Orchard and the Ironwood +/// bundles when the transaction is extracted. +fn orchard_verifying_key(version: OrchardCircuitVersion) -> &'static VerifyingKey { + static VERIFYING_KEY: OnceLock = OnceLock::new(); + VERIFYING_KEY.get_or_init(|| VerifyingKey::build(version)) +} + +/// Proves a stored, pre-signed but unproven migration PCZT and extracts the broadcastable +/// transaction. `height` selects the circuit version (the migration's consensus branch). Proving is +/// CPU-heavy, so call this inside the blocking database section; the extracted transaction is then +/// broadcast asynchronously by the caller. +pub fn prove_and_extract( + params: &Network, + height: BlockHeight, + pczt_bytes: &[u8], +) -> Result { + let version = orchard_circuit_version(params, height); + let pczt = Pczt::parse(pczt_bytes).map_err(|e| BroadcastError::Parse(format!("{e:?}")))?; + let mut prover = Prover::new(pczt); + if prover.requires_orchard_proof() { + prover = prover + .create_orchard_proof(orchard_proving_key(version)) + .map_err(|e| BroadcastError::Prove(format!("{e:?}")))?; + } + if prover.requires_ironwood_proof() { + prover = prover + .create_ironwood_proof(orchard_proving_key(version)) + .map_err(|e| BroadcastError::Prove(format!("{e:?}")))?; + } + let pczt = prover.finish(); + TransactionExtractor::new(pczt) + .with_orchard(orchard_verifying_key(version)) + .extract() + .map_err(|e| BroadcastError::Extract(format!("{e:?}"))) +} + +/// Why advancing a migration one step failed. +pub enum AdvanceError { + /// No migration is stored. + NoMigration, + /// A store (load/persist) failure. + Store(String), + /// Building the phase-2 transfers failed. + Commit(CommitFailure), + /// Proving or extracting a transaction failed. + Prove(BroadcastError), + /// The requested step is not supported for this migration (for example external signing of a + /// multi-layer preparation, which the seam does not yet cover). + Unsupported(String), +} + +/// The outcome of the blocking half of advancing a migration one step. +pub struct AdvanceOutcome { + /// The migration state after the step. If `to_broadcast` is set, this is NOT yet persisted (the + /// caller broadcasts, records the txid, and persists); otherwise it is already persisted. + pub state: MigrationState, + /// A proved, extracted transaction to broadcast next, with the id of the migration transaction it + /// corresponds to. + pub to_broadcast: Option<(Transaction, MigrationTxId)>, + /// A short description of what the step did. + pub message: String, +} + +/// The txid bytes of a transaction, for recording its broadcast in the store. +pub fn transaction_txid_bytes(tx: &Transaction) -> [u8; 32] { + *tx.txid().as_ref() +} + +/// Records that the migration transaction `tx_id` was broadcast with `txid` (via the engine's state +/// transition, which also recomputes the overall status), and persists the state. +pub fn record_broadcast( + conn: &mut Connection, + state: &mut MigrationState, + tx_id: MigrationTxId, + txid: [u8; 32], +) -> Result<(), AdvanceError> { + state.mark_broadcast(tx_id, txid); + persist_migration(conn, state).map_err(|e| AdvanceError::Store(e.to_string())) +} + +/// The blocking half of advancing a migration one step: load it, detect newly mined transactions, +/// then ask the engine for the next step (`state::next_step`, the same decision the mobile wallet +/// makes from state alone) and perform the wallet I/O it calls for: prove+extract the next +/// broadcastable transaction (returned for the caller to broadcast), build+sign the next ready +/// preparation layer, build+sign the transfers, or report what it is waiting for. zallet only does +/// the I/O; the decision lives in the engine. Runs inside the database write lock; the caller +/// broadcasts asynchronously. `tip` is the current chain tip; transactions build and become due at +/// `tip + 1`. +pub fn advance_blocking( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + tip: u32, +) -> Result { + let target_height = tip + 1; + + let mut state = load_migration(conn) + .map_err(|e| AdvanceError::Store(e.to_string()))? + .ok_or(AdvanceError::NoMigration)?; + + // A terminal migration (complete, or cancelled/failed) is never advanced: report its status and + // do nothing, so a cancelled migration cannot be driven further or resurrected. + if state.is_terminal() { + let message = if matches!(state.status, MigrationStatus::Complete) { + "the migration is complete" + } else { + "the migration was cancelled" + }; + return Ok(AdvanceOutcome { + state, + to_broadcast: None, + message: message.to_string(), + }); + } + + // Detect newly mined transactions: a broadcast transaction the wallet now sees at a height. + // Collect the (id, height) pairs while the wallet is borrowed, then apply the engine's mining + // transition (which recomputes the overall status). + let mut newly_mined: Vec<(MigrationTxId, u32)> = Vec::new(); + { + let wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + for tx in state.transactions.iter() { + if let MigrationTxState::Broadcast { txid } = tx.state { + if let Some(height) = wallet + .get_tx_height(TxId::from_bytes(txid)) + .map_err(|e| AdvanceError::Store(e.to_string()))? + { + newly_mined.push((tx.id, u32::from(height))); + } + } + } + } + for (id, height) in newly_mined { + state.mark_mined(id, height); + } + + // Decide the next step from state alone (the same decision the mobile wallet makes), then perform + // the wallet I/O it calls for. + match state.next_step(target_height) { + // Prove and extract the next ready transaction; the caller broadcasts it. + AdvanceStep::Broadcast { id } => { + let tx_ref = state + .transactions + .iter() + .find(|t| t.id == id) + .ok_or_else(|| AdvanceError::Store("the next transaction is missing".into()))?; + let bytes = tx_ref.pczt.clone().ok_or_else(|| { + AdvanceError::Store("a signed transaction is missing its PCZT".into()) + })?; + let kind = match tx_ref.kind { + MigrationTxKind::Preparation { .. } => "preparation", + MigrationTxKind::Transfer { .. } => "transfer", + }; + let tx = prove_and_extract(network, BlockHeight::from(tip), &bytes) + .map_err(AdvanceError::Prove)?; + Ok(AdvanceOutcome { + state, + to_broadcast: Some((tx, id)), + message: format!("broadcasting a {kind} transaction"), + }) + } + // Build and pre-sign the next ready preparation layer, whose predecessor has now mined so its + // feeder notes are witnessable. This turns its transactions into broadcastable ones. + AdvanceStep::BuildPreparationLayer { layer } => { + let mut updated = commit_pending_preparation_over_wallet( + conn, + network, + account, + usk, + target_height, + state, + ) + .map_err(AdvanceError::Commit)?; + updated.recompute_status(); + persist_migration(conn, &updated).map_err(|e| AdvanceError::Store(e.to_string()))?; + Ok(AdvanceOutcome { + state: updated, + to_broadcast: None, + message: format!("built preparation layer {layer}"), + }) + } + // Build and pre-sign the transfers, now that the whole preparation is mined. + AdvanceStep::BuildTransfers => { + let mut updated = + commit_transfers_over_wallet(conn, network, account, usk, target_height, state) + .map_err(AdvanceError::Commit)?; + updated.recompute_status(); + persist_migration(conn, &updated).map_err(|e| AdvanceError::Store(e.to_string()))?; + Ok(AdvanceOutcome { + state: updated, + to_broadcast: None, + message: "built the transfer transactions".into(), + }) + } + // Nothing to build or broadcast this step: persist the mining updates and report progress. + AdvanceStep::Waiting | AdvanceStep::Complete => { + persist_migration(conn, &state).map_err(|e| AdvanceError::Store(e.to_string()))?; + let pending = state + .transactions + .iter() + .filter(|t| !matches!(t.state, MigrationTxState::Mined { .. })) + .count(); + let message = if pending == 0 { + "the migration is complete".to_string() + } else { + format!("waiting for {pending} transaction(s) to mine") + }; + Ok(AdvanceOutcome { + state, + to_broadcast: None, + message, + }) + } + } +} + +/// The blocking half of building an EXTERNAL migration's transfers, the external-signer counterpart of +/// the transfer-building that [`advance_blocking`] does in process. It loads the migration, detects +/// newly mined preparation transactions (the same detection `advance_blocking` performs, so an +/// external client uses this instead of advancing and never triggers in-process transfer signing), and +/// once the whole preparation is mined builds the phase-2 transfers UNSIGNED, returning their PCZTs to +/// route to the signing device. If the preparation is not yet fully mined it returns no PCZTs and +/// leaves the migration waiting; a multi-layer preparation whose next step is a later unbuilt layer is +/// reported as unsupported for external signing (the seam covers layer-0 preparation and transfers). +/// Persists the state. Runs inside the database write lock. `tip` is the current chain tip; +/// transactions build at `tip + 1`. +pub fn build_transfers_unsigned_blocking( + conn: &mut Connection, + network: &Network, + account: AccountUuid, + usk: UnifiedSpendingKey, + tip: u32, +) -> Result<(MigrationState, Vec, String), AdvanceError> { + let target_height = tip + 1; + + let mut state = load_migration(conn) + .map_err(|e| AdvanceError::Store(e.to_string()))? + .ok_or(AdvanceError::NoMigration)?; + + if state.is_terminal() { + return Ok(( + state, + Vec::new(), + "the migration is no longer in progress".to_string(), + )); + } + + // Detect newly mined transactions (identical to `advance_blocking`). + let mut newly_mined: Vec<(MigrationTxId, u32)> = Vec::new(); + { + let wallet = WalletDb::from_connection(&mut *conn, *network, SystemClock, OsRng); + for tx in state.transactions.iter() { + if let MigrationTxState::Broadcast { txid } = tx.state { + if let Some(height) = wallet + .get_tx_height(TxId::from_bytes(txid)) + .map_err(|e| AdvanceError::Store(e.to_string()))? + { + newly_mined.push((tx.id, u32::from(height))); + } + } + } + } + for (id, height) in newly_mined { + state.mark_mined(id, height); + } + + match state.next_step(target_height) { + // The whole preparation is mined: build the transfers unsigned for the external signer. + AdvanceStep::BuildTransfers => { + let (mut updated, unsigned) = build_transfers_unsigned_over_wallet( + conn, + network, + account, + usk, + target_height, + state, + ) + .map_err(AdvanceError::Commit)?; + updated.recompute_status(); + persist_migration(conn, &updated).map_err(|e| AdvanceError::Store(e.to_string()))?; + Ok(( + updated, + unsigned, + "built the unsigned transfer transactions".to_string(), + )) + } + // A later preparation layer still needs building: external signing does not cover multi-layer + // preparation yet. + AdvanceStep::BuildPreparationLayer { layer } => Err(AdvanceError::Unsupported(format!( + "external signing of a multi-layer preparation is not yet supported (preparation layer \ + {layer} still needs building)" + ))), + // The preparation is not yet fully mined (or nothing is ready to build): persist the mining + // updates and report what the migration is waiting for. + AdvanceStep::Broadcast { .. } | AdvanceStep::Waiting | AdvanceStep::Complete => { + persist_migration(conn, &state).map_err(|e| AdvanceError::Store(e.to_string()))?; + let pending_prep = state + .transactions + .iter() + .filter(|t| matches!(t.kind, MigrationTxKind::Preparation { .. })) + .filter(|t| !matches!(t.state, MigrationTxState::Mined { .. })) + .count(); + let message = if pending_prep == 0 { + "the preparation is mined; no transfers are pending".to_string() + } else { + format!("waiting for {pending_prep} preparation transaction(s) to mine") + }; + Ok((state, Vec::new(), message)) + } + } +}