diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8fb51ddb..93c95127 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,10 +14,14 @@ Commands actually run, with their outcome: +The feature runner excludes the `tinywallet-client` native integration tests, +which need the module staged by `scripts/test-e2e.sh`. Use the shell runner on +Unix and the PowerShell runner on Windows. + - [ ] `cargo fmt --all -- --check` - [ ] `cargo clippy --all-targets --all-features -- -D warnings` - [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` +- [ ] `bash scripts/test-features.sh` (or `scripts/test-features.ps1` on Windows) ## Tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a55a2726..97e7012e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,30 @@ jobs: [ {"packages":["tinywallet","tinywallet-crypto"],"all_targets":true,"all_features":true}, {"packages":["tinywallet-bus"],"features":["wire"]}, + {"packages":["tinywallet-bus"],"all_targets":true,"all_features":true}, + {"packages":["tinywallet-client"],"all_targets":true,"all_features":true}, {"packages":["tinywallet-x402"],"features":["wire","eip712","abi"],"all_targets":true,"no_default_features":true}, + {"packages":["tinywallet-module"],"all_targets":true,"all_features":true}, {"packages":["tinywallet-web3"],"all_targets":true} ] module-e2e-script: scripts/test-e2e.sh module-e2e-toolchain: '1.88.0' feature-test-script: scripts/test-features.sh + wallet-ledger-msrv: + name: Wallet x402 ledger MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + submodules: recursive + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # stable + with: + toolchain: '1.88.0' + - name: Test the x402 ledger consumer at its minimum Rust version + run: cargo test --locked -p tinywallet-x402 --features ledger + wallet-dependency-boundaries: name: Wallet dependency boundaries runs-on: ubuntu-latest @@ -43,6 +60,8 @@ jobs: run: | python3 scripts/check_bus_boundaries_tests.py python3 scripts/release_workflow_tests.py + python3 scripts/test_reviewed_release_workflow.py + python3 scripts/test_native_e2e_topology.py python3 scripts/check-bus-boundaries.py - name: Implementation crates pull no signing stack shell: bash @@ -71,3 +90,21 @@ jobs: echo "quote/ and seams/ must stay free of crypto vocabulary" >&2 exit 1 fi + + wallet-storage-contract: + name: Wallet storage collection contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + submodules: recursive + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # stable + with: + toolchain: '1.88.0' + - name: Compile the excluded canonical-type consumer + run: cargo test --locked --manifest-path proof-workspaces/storage-contract/Cargo.toml + - name: Keep the storage contract's normal/build graph data-only + run: | + python3 scripts/check_bus_boundaries_tests.py + python3 scripts/check-bus-boundaries.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bd5e266..49d002a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,10 +2,21 @@ name: Release on: workflow_dispatch: inputs: + mode: + description: Ordinary tag release or reviewed OpenHuman epic release + type: choice + required: true + default: standard + options: [standard, reviewed-epic] tag: description: Existing version tag (vX.Y.Z) committed through a reviewed PR type: string required: true + reviewed_pr: + description: Positive canonical PR number for reviewed-epic mode; zero is reserved for the standard path + type: string + required: false + default: '0' push: tags: ['v*'] permissions: {} @@ -33,6 +44,7 @@ jobs: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" module-release: needs: resolve + if: github.event_name == 'push' || inputs.mode == 'standard' permissions: contents: write uses: tinyhumansai/.github/.github/workflows/module-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 @@ -40,3 +52,45 @@ jobs: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} module-crate: tinywallet-module + + validate-reviewed-dispatch: + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'reviewed-epic' + needs: [resolve] + permissions: {} + outputs: + reviewed_pr: ${{ steps.validate.outputs.reviewed_pr }} + runs-on: ubuntu-latest + steps: + - id: validate + env: + REVIEWED_PR: ${{ inputs.reviewed_pr }} + shell: bash + run: | + set -euo pipefail + [[ "${GITHUB_REPOSITORY:-}" == "tinyhumansai/tinywallet" ]] || { + echo "reviewed releases run only in tinyhumansai/tinywallet" >&2 + exit 2 + } + [[ "${GITHUB_REF:-}" == "refs/heads/epic/8056-modules-wiring" ]] || { + echo "reviewed releases run only from epic/8056-modules-wiring" >&2 + exit 2 + } + [[ "${REVIEWED_PR:-}" =~ ^[1-9][0-9]*$ ]] || { + echo "reviewed_pr must be a positive integer" >&2 + exit 2 + } + printf 'reviewed_pr=%s\n' "$REVIEWED_PR" >> "$GITHUB_OUTPUT" + + module-reviewed-release: + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'reviewed-epic' + needs: [resolve, validate-reviewed-dispatch] + permissions: + contents: write + pull-requests: read + checks: read + actions: read + uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@6ed83b04aab2d8977ec62e966cb914531e534fa4 + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + reviewed-pr: ${{ fromJSON(needs.validate-reviewed-dispatch.outputs.reviewed_pr || '0') }} diff --git a/.gitignore b/.gitignore index 01087018..a9acb87e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build output /target/ +/proof-workspaces/storage-contract/target/ **/*.rs.bk *.pdb __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 0d13b5af..15617cee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,20 +87,26 @@ the crate-wide `Result` from fallible public APIs. ## Build And Test -Run every command from the repository root. These four are the contract; CI -runs exactly them, so a green local run should mean a green CI run. +Run every command from the repository root. CI builds and lints the workspace +directly. The `tinywallet-client` native integration tests need a staged +release module, so the feature runner excludes them. Run them with +`scripts/test-e2e.sh` or `scripts/test-e2e.ps1`. ```sh cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features -cargo test --all-features +bash scripts/test-features.sh ``` Supporting commands: - `cargo fmt --all` — format before committing. - `cargo test ` — run a focused subset while iterating. +- `scripts/test-features.sh` (or `scripts/test-features.ps1` on Windows) — run + workspace tests excluding `tinywallet-client`, then the client's library + tests. A bare `cargo test --all-features` would include native client tests + without their staged module and is not the supported full test command. - `cargo run --example basic` — run the bundled example. - `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with `RUSTDOCFLAGS="-D warnings"`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 472e6b11..d06866cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,9 +15,14 @@ git submodule update --init --recursive cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features -cargo test --all-features +bash scripts/test-features.sh ``` +On Windows, run `scripts/test-features.ps1`. These runners exclude the +`tinywallet-client` native integration tests, then run the client's library +tests. Use `scripts/test-e2e.sh` or `scripts/test-e2e.ps1` for tests that need +the wallet module staged. + The bundled example should also run: ```sh @@ -55,7 +60,7 @@ regardless. - [ ] `cargo fmt --all -- --check` - [ ] `cargo clippy --all-targets --all-features -- -D warnings` - [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` +- [ ] `bash scripts/test-features.sh` (or `scripts/test-features.ps1` on Windows) - [ ] tests added or updated for behavior changes - [ ] documentation updated for public API, architecture, or usage changes - [ ] the pull request is focused on one logical change diff --git a/Cargo.lock b/Cargo.lock index bb1d04f5..84dbe110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,12 +263,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - [[package]] name = "chacha20" version = "0.10.2" @@ -786,11 +780,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -994,6 +986,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6f143e9349267b80ae071a6ec25d4797c45756d8b2671a8b24b7b25b81ab94" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a5c321208537711696af55bc2ab5dad939e4f6e0aef92dd6a2555deceedf3a" + [[package]] name = "icu_collections" version = "2.4.0" @@ -1002,6 +1016,7 @@ checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -1016,6 +1031,7 @@ checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1070,6 +1086,8 @@ checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1248,18 +1266,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1352,6 +1358,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -1476,6 +1484,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1986,11 +2000,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -2001,13 +2017,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.3.6" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -2016,7 +2032,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -2028,8 +2044,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-api" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "bytes", @@ -2042,8 +2058,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-blob" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "bytes", @@ -2056,8 +2072,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-bus" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "serde", "serde_json", @@ -2067,8 +2083,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-busproxy" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "bytes", @@ -2085,8 +2101,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-connect" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "rusqlite", "serde_json", @@ -2097,8 +2113,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-document" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "data-encoding", @@ -2115,8 +2131,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-kv" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "serde", @@ -2127,8 +2143,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-queue" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "data-encoding", @@ -2143,8 +2159,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "tinystoragedrivers-api", @@ -2154,8 +2170,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-testkit" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "async-trait", "bytes", @@ -2175,8 +2191,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-types" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da319e41dfe17bba4afb1831fe0cf0eef154" dependencies = [ "serde", "serde_json", @@ -2190,6 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2242,7 +2259,18 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", - "tinystoragedrivers-types", +] + +[[package]] +name = "tinywallet-client" +version = "0.27.0" +dependencies = [ + "serde", + "serde_json", + "tinybus", + "tinywallet-bus", + "tokio", + "zeroize", ] [[package]] @@ -2499,12 +2527,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typed-path" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" - [[package]] name = "typenum" version = "1.20.1" @@ -3001,6 +3023,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -3009,6 +3032,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3025,22 +3049,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "zip" -version = "9.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" -dependencies = [ - "crc32fast", - "flate2", - "getrandom 0.4.3", - "indexmap", - "memchr", - "nix", - "typed-path", - "windows-sys 0.61.2", -] - [[package]] name = "zlib-rs" version = "0.6.8" @@ -3055,18 +3063,18 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 658ddf59..54b505ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "crates/tinywallet-crypto", "crates/tinywallet-x402", "crates/tinywallet-bus", + "crates/tinywallet-client", "crates/tinywallet-web3", "crates/tinywallet-module", ] @@ -59,11 +60,14 @@ resolver = "3" # one package and `tinytools::Tool` stays one type. Keep the rev equal to the # tinytools gitlink tinyagents pins. tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "41cc80ac9acf37a14f20be94f60ee552749f1540" } +# TinyBus host SDK used by the separately shipped typed client crate. Pin the +# fork revision that matches the vendored ABI and TSD's bus transport. +tinybus = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", rev = "85345114f3066e94d5d70161ea8dc43252b83ae6", default-features = false, features = ["macros", "modules"] } # Runtime-neutral contracts shared with the host's stable TSD release. -tinystoragedrivers-types = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } -tinystoragedrivers-api = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "c447da3" } +tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "c447da3" } # The module connects to host-owned storage through the TinyBus adapter. -tinystoragedrivers-busproxy = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "c447da3" } [dependencies] # The wire contract. Split out so a host that has moved signing into the @@ -108,7 +112,7 @@ coins-bip32 = { version = "0.17", optional = true } # Keccak-256 for the EVM and Tron addresses key derivation produces. # # PINNED TO 0.10, and not by accident — do not let dependabot move it. -# `coins-bip32` 0.8 (via `coins-core`) depends on `sha3` 0.10 and is not going +# `coins-bip32` (via `coins-core`) depends on `sha3` 0.10 and is not going # to move, so 0.10 is already in the graph of every consumer that enables # `key`. Asking for 0.12 here does not replace it, it ADDS a second copy: cargo # cannot unify across a major, so a host ends up compiling `sha3` twice plus @@ -248,6 +252,11 @@ tinybus = { path = "vendor/tinybus/crates/tinybus" } tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } tinybus-macros = { path = "vendor/tinybus/crates/tinybus-macros" } +[patch."https://github.com/senamakel/tinybus"] +tinybus = { path = "vendor/tinybus/crates/tinybus" } +tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } +tinybus-macros = { path = "vendor/tinybus/crates/tinybus-macros" } + [workspace.lints.rust] unsafe_code = "forbid" missing_docs = "warn" diff --git a/README.md b/README.md index f6f148b5..fb8411b4 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,23 @@ The repository builds six libraries and one loadable module. | `tinywallet-crypto` | the `Chain` enum, address validation, reference data, the `rpc::Transport` seam, and the Tron protobuf reader and verification | hashes and codecs only — no native build | | `tinywallet-x402` | the x402 wire types, EIP-712 hashing and ERC-20 calldata (`wire`, `eip712`, `abi`) | `tinywallet-crypto`, keccak, serde | | `tinywallet-web3` | the wallet, swap, bridge and dapp flows and their agent tools (`tools`), behind host seams: `WalletSigner`, `WalletAccounts`, `RpcEndpoints`, `Web3Backend`, `QuoteScope` | `tinywallet-crypto`, `tinywallet-bus` (`wire`), `tinywallet-x402` (`abi`) — never `bitcoin`, `k256` or `coins-*` | -| `tinywallet-bus` | identifiers, errors, transfers, wire DTOs, member names and contract version | serde, thiserror only | +| `tinywallet-bus` | identifiers, errors, transfers, wire DTOs, member names, storage collections and contract version | serde, thiserror and TinyStorageDrivers types | +| `tinywallet-client` | typed host calls for wallet, ERC-20, Web3 and x402 members | `tinybus` and `tinywallet-bus`; no wallet implementation | | `tinywallet` (root) | key derivation, transaction building and signing, chain queries; re-exports the crates above | `bitcoin` and its native `secp256k1` build, `coins-bip39`, `ed25519-dalek` | | `tinywallet-module` | the TinyBus adapter, built as a `cdylib` | all of the above | -Hosts use `tinywallet-bus` alone and execute validation, transaction and payment -behavior through the compiled module. The bus depends only on serde and -thiserror. Implementations consume and re-export the same shared vocabulary, -so `tinywallet::Chain` and `tinywallet_bus::Chain` are identical types. The -root library retains its historical implementation paths for library users; -hosts do not import those algorithms. See +Hosts use `tinywallet-client` for typed module calls and execute validation, +transaction and payment behavior through the compiled module. The client +depends on TinyBus and the wire contract, but none of the wallet implementation +crates. Its confidential methods use TinyBus strict attestation against the +host registry's paired archive and library pins. TinyWallet returns sanitized +error kinds on that path; callers needing the detailed remote wire name and +message can use the regular-call failure's explicit fields, whose Debug and +Display remain redacted. The bus contract itself remains transport-free. +Implementations consume and re-export the same shared vocabulary, so +`tinywallet::Chain` and `tinywallet_bus::Chain` are identical types. The root +library retains its historical implementation paths for library users; hosts +do not import those algorithms. See [the minimal contract specification](docs/specs/minimal-bus-contract.md). ## Layout @@ -110,6 +117,9 @@ crates/tinywallet-bus/src/ ├── names/ # BUS_NAME, OBJECT_PATH, one constant per member ├── version/ # CONTRACT_VERSION and its binding rule └── wire/ # the host/module request and response types +crates/tinywallet-client/src/ +├── lib.rs # typed call facade and strict confidential routing +└── lib_tests.rs # routing, wire-shape and error-redaction tests crates/tinywallet-crypto/src/ ├── lib.rs ├── error/ # crate-wide `Error` and `Result` @@ -158,10 +168,22 @@ git submodule update --init --recursive cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-features +bash scripts/test-features.sh cargo run --example basic ``` +On Windows, use `scripts/test-features.ps1`. The feature runners stage the +locally built wallet module before all-feature tests run; a bare +`cargo test --all-features` does not prepare the native fixture. + +`scripts/test-e2e.sh` (or `scripts/test-e2e.ps1`) also stages the built wallet +module as a locally pinned archive and library, then runs the typed client's +verified-peer and separate-process unattested tests. Running either native +client test target directly requires the same `TINYWALLET_CLIENT_TEST_*` +artifact variables; the target fails when they are missing. The dedicated +runner also executes `attested_errors`, which verifies detailed `InvalidInput` +preservation over strict delivery. + Run the gated builds too — they are the only thing that catches code that compiles only when a feature is on. The lib tests are feature-aware, so the same matrix also exercises the `ChainNotCompiled` contract (a disabled chain diff --git a/crates/tinywallet-bus/Cargo.toml b/crates/tinywallet-bus/Cargo.toml index 75015878..06dbd226 100644 --- a/crates/tinywallet-bus/Cargo.toml +++ b/crates/tinywallet-bus/Cargo.toml @@ -20,9 +20,6 @@ serde = { version = "1", optional = true } serde_json = { version = "1", optional = true } # Error vocabulary derives; no crypto execution crosses this dependency. thiserror = "2" -# Hosts read the module's collection declarations from this contract without -# linking the implementation or its migration behavior. -tinystoragedrivers-types = { workspace = true } [dev-dependencies] serde = { version = "1", features = ["derive"] } diff --git a/crates/tinywallet-bus/README.md b/crates/tinywallet-bus/README.md index c0da6f3d..505e650e 100644 --- a/crates/tinywallet-bus/README.md +++ b/crates/tinywallet-bus/README.md @@ -1,9 +1,13 @@ # tinywallet-bus The minimal TinyBus contract for TinyWallet: names, version, shared chain -identifiers, error vocabulary, transfer types and request/result DTOs. Normal -and build dependencies are limited to serde, serde_json and thiserror plus their approved -serialization and derive closure. No feature links crypto, x402, web3, transport or native libraries. +identifiers, error vocabulary, transfer types, request/result DTOs, and the +x402 ledger's storage collection declarations and stored record vocabulary. +Collection declarations are bus-owned names, without a storage backend, +client, or transport dependency. + +The bus contract supports Rust 1.88. The excluded collection-contract consumer +checks the public collection names without depending on a storage crate. Network enums in the bus are identifiers only. RPC endpoints, explorer URLs, Solana mint addresses, and display labels remain implementation-owned reference @@ -76,3 +80,8 @@ Contract 1.12 adds optional `additional_secrets` to confidential `X402PreparePayment`. When omitted or empty, the request keeps its prior JSON shape. The module parses the challenge, selects the payment chain, and uses only the matching confidential secret; hosts do not parse payment challenges. + +The bus exposes `storage::collections()` for the host's wallet storage grant. +The declarations include the current `wallet.budgets` and +`wallet.migrations` collections. Migration behavior and the payment stream +remain owned by the x402 ledger implementation. diff --git a/crates/tinywallet-bus/src/chain/mod.rs b/crates/tinywallet-bus/src/chain/mod.rs index 9a554a00..198d1940 100644 --- a/crates/tinywallet-bus/src/chain/mod.rs +++ b/crates/tinywallet-bus/src/chain/mod.rs @@ -63,6 +63,46 @@ impl Chain { } } +/// Resolve the endpoint key for a chain selected by a host. +/// +/// EVM requires a chain ID that appears in [`crate::wire::EvmNetwork::ALL`]. +/// The chain ID is ignored for Bitcoin, Solana, and Tron. This function only +/// identifies the endpoint; it does not resolve a URL or perform transport. +/// +/// # Examples +/// +/// ``` +/// # #[cfg(feature = "wire")] +/// # { +/// use tinywallet_bus::chain::{endpoint_key, Chain}; +/// use tinywallet_bus::wire::{EvmNetwork, RpcEndpointKey}; +/// assert_eq!( +/// endpoint_key(Chain::Evm, Some(1)), +/// Some(RpcEndpointKey::Evm { +/// network: EvmNetwork::EthereumMainnet, +/// }) +/// ); +/// # } +/// ``` +#[cfg(feature = "wire")] +#[must_use] +pub fn endpoint_key( + chain: Chain, + evm_chain_id: Option, +) -> Option { + use crate::wire::{EvmNetwork, RpcEndpointKey}; + + match chain { + Chain::Evm => EvmNetwork::ALL + .into_iter() + .find(|network| Some(network.chain_id()) == evm_chain_id) + .map(|network| RpcEndpointKey::Evm { network }), + Chain::Btc => Some(RpcEndpointKey::Btc), + Chain::Solana => Some(RpcEndpointKey::Solana), + Chain::Tron => Some(RpcEndpointKey::Tron), + } +} + impl fmt::Display for Chain { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) diff --git a/crates/tinywallet-bus/src/chain/mod_tests.rs b/crates/tinywallet-bus/src/chain/mod_tests.rs index 7267b710..0f558df6 100644 --- a/crates/tinywallet-bus/src/chain/mod_tests.rs +++ b/crates/tinywallet-bus/src/chain/mod_tests.rs @@ -6,6 +6,9 @@ use std::str::FromStr; use super::{Chain, UnknownChain}; +#[cfg(feature = "wire")] +use crate::wire::{EvmNetwork, RpcEndpointKey}; + #[test] fn as_str_round_trips_through_from_str() { for chain in Chain::ALL { @@ -55,3 +58,28 @@ fn an_unknown_name_is_reported_with_the_input() { UnknownChain("dogecoin".to_string()) ); } + +#[cfg(feature = "wire")] +#[test] +fn endpoint_keys_cover_known_chains_and_reject_unknown_evm_ids() { + for network in EvmNetwork::ALL { + assert_eq!( + super::endpoint_key(Chain::Evm, Some(network.chain_id())), + Some(RpcEndpointKey::Evm { network }) + ); + } + assert_eq!(super::endpoint_key(Chain::Evm, None), None); + assert_eq!(super::endpoint_key(Chain::Evm, Some(u64::MAX)), None); + assert_eq!( + super::endpoint_key(Chain::Btc, Some(u64::MAX)), + Some(RpcEndpointKey::Btc) + ); + assert_eq!( + super::endpoint_key(Chain::Solana, None), + Some(RpcEndpointKey::Solana) + ); + assert_eq!( + super::endpoint_key(Chain::Tron, None), + Some(RpcEndpointKey::Tron) + ); +} diff --git a/crates/tinywallet-bus/src/intent/mod.rs b/crates/tinywallet-bus/src/intent/mod.rs new file mode 100644 index 00000000..9cd62b02 --- /dev/null +++ b/crates/tinywallet-bus/src/intent/mod.rs @@ -0,0 +1,311 @@ +//! Pure comparisons between prepared wallet quotes and execution intents. +//! +//! These functions describe whether a supplied intent agrees with a quote. +//! They do not retain or consume host approval, authorize execution, or call a +//! wallet implementation. + +use crate::TronTransfer; + +const HEX: &[u8; 16] = b"0123456789abcdef"; +use crate::wire::{ + AuthorizeWeb3ExecutionRequest, NativeWalletExecutionIntent, PreparedKind, PreparedTransaction, + TransactionSpec, WalletChain, Web3ExecutionIntent, Web3QuoteKind, Web3QuoteResponse, +}; + +/// Check that a native-wallet signing intent preserves the facts in a prepared +/// transfer quote. +/// +/// This pure comparison covers the recipient and amount for each supported +/// chain, and the transfer calldata for EVM tokens. It does not consume a host +/// approval or authorize execution. +#[must_use] +pub fn native_intent_matches_quote( + quote: &PreparedTransaction, + intent: &NativeWalletExecutionIntent, +) -> bool { + match intent { + NativeWalletExecutionIntent::Transaction { transaction } => match transaction { + TransactionSpec::Btc { + from, + to, + amount_sat, + .. + } => { + quote.chain == WalletChain::Btc + && quote.kind == PreparedKind::NativeTransfer + && from == "e.from_address + && to == "e.to_address + && quote.amount_raw.parse::().ok() == Some(*amount_sat) + } + TransactionSpec::Evm { + to, + value_wei, + data_hex, + chain_id, + .. + } => { + let network = quote + .evm_network + .unwrap_or(crate::wire::EvmNetwork::EthereumMainnet); + if quote.chain != WalletChain::Evm || network.chain_id() != *chain_id { + return false; + } + match quote.kind { + PreparedKind::NativeTransfer => { + to.eq_ignore_ascii_case("e.to_address) + && value_wei == "e.amount_raw + && data_hex.is_empty() + } + PreparedKind::TokenTransfer => { + let Some(contract) = quote.token_address.as_deref() else { + return false; + }; + to.eq_ignore_ascii_case(contract) + && value_wei == "0" + && evm_transfer_data_matches( + data_hex, + "e.to_address, + "e.amount_raw, + ) + } + } + } + TransactionSpec::Solana { + from, to, lamports, .. + } => { + quote.chain == WalletChain::Solana + && quote.kind == PreparedKind::NativeTransfer + && from == "e.from_address + && to == "e.to_address + && quote.amount_raw.parse::().ok() == Some(*lamports) + } + TransactionSpec::Tron { + expected_to, + transfer, + .. + } => { + if quote.chain != WalletChain::Tron { + return false; + } + match (quote.kind, transfer) { + (PreparedKind::NativeTransfer, TronTransfer::Native { amount_sun }) => { + expected_to == "e.to_address + && quote.amount_raw.parse::().ok() == Some(*amount_sun) + } + // A TRC-20 transfer pays the token contract; the recipient + // and amount travel in the call parameter. + (PreparedKind::TokenTransfer, TronTransfer::Trc20 { parameter_hex }) => { + quote.token_address.as_deref() == Some(expected_to.as_str()) + && trc20_parameter_matches( + parameter_hex, + "e.to_address, + "e.amount_raw, + ) + } + _ => false, + } + } + }, + NativeWalletExecutionIntent::SolanaMessage { message_hex } => { + quote.chain == WalletChain::Solana + && quote.kind == PreparedKind::TokenTransfer + && valid_hex(message_hex) + } + } +} + +/// Check that a Web3 execution request preserves the operation and transaction +/// facts in a previously prepared quote. +/// +/// EVM addresses compare case-insensitively. Calldata and decimal value strings +/// compare exactly. This function does not retain or consume a quote-bound +/// permission and does not authorize execution. +#[must_use] +pub fn web3_approval_matches_request( + quote: &Web3QuoteResponse, + request: &AuthorizeWeb3ExecutionRequest, +) -> bool { + let operation_matches = matches!( + (quote.kind, request.operation.as_str()), + (Web3QuoteKind::Swap, "swap") + | (Web3QuoteKind::Bridge, "bridge") + | (Web3QuoteKind::DappCall, "dapp_call") + ); + if quote.quote_id != request.quote_id || !operation_matches { + return false; + } + + match (&request.transaction, quote.signing_chain) { + ( + Web3ExecutionIntent::Evm { + network, + to, + data, + value, + chain_id, + .. + }, + WalletChain::Evm, + ) => { + let expected_network = quote + .quote + .get("network") + .and_then(serde_json::Value::as_str); + if expected_network.is_some_and(|expected| expected != network) + || !known_evm_chain(network, *chain_id) + { + return false; + } + let transaction = quote.quote.get("tx"); + match quote.kind { + Web3QuoteKind::DappCall => { + let expected_to = quote + .quote + .get("contractAddress") + .and_then(serde_json::Value::as_str); + let expected_data = quote + .quote + .get("calldata") + .and_then(serde_json::Value::as_str); + let expected_value = quote + .quote + .get("valueRaw") + .and_then(serde_json::Value::as_str); + expected_to.is_some_and(|expected| expected.eq_ignore_ascii_case(to)) + && expected_data.is_some_and(|expected| data.as_deref() == Some(expected)) + && expected_value.is_some_and(|expected| expected == value) + } + Web3QuoteKind::Swap | Web3QuoteKind::Bridge => { + transaction.is_some_and(|transaction| { + transaction + .get("to") + .and_then(serde_json::Value::as_str) + .is_some_and(|expected| expected.eq_ignore_ascii_case(to)) + && transaction.get("data").and_then(serde_json::Value::as_str) + == data.as_deref() + && decimal_json(transaction.get("value")).as_deref() + == Some(value.as_str()) + }) + } + } + } + (Web3ExecutionIntent::Solana { tx_blob_hex }, WalletChain::Solana) => { + let expected = quote + .quote + .get("tx") + .and_then(|transaction| transaction.get("data")) + .and_then(serde_json::Value::as_str); + matches!(quote.kind, Web3QuoteKind::Swap | Web3QuoteKind::Bridge) + && expected == Some(tx_blob_hex.as_str()) + } + _ => false, + } +} + +fn known_evm_chain(network: &str, chain_id: u64) -> bool { + crate::wire::EvmNetwork::ALL + .into_iter() + .any(|known| known.as_str() == network && known.chain_id() == chain_id) +} + +fn decimal_json(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn evm_transfer_data_matches(data: &str, recipient: &str, amount: &str) -> bool { + let Some(address) = recipient + .strip_prefix("0x") + .or_else(|| recipient.strip_prefix("0X")) + else { + return false; + }; + if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return false; + } + let Some(amount_word) = decimal_u256_word(amount) else { + return false; + }; + let expected = format!("a9059cbb{:0>64}{amount_word}", address.to_ascii_lowercase()); + let actual = data + .strip_prefix("0x") + .or_else(|| data.strip_prefix("0X")) + .unwrap_or(data); + actual.eq_ignore_ascii_case(&expected) +} + +/// The TRC-20 `transfer(address,uint256)` parameter (no selector) for paying +/// `amount` to the base58check Tron address `recipient`. +fn trc20_parameter_matches(parameter: &str, recipient: &str, amount: &str) -> bool { + let (Some(address), Some(amount_word)) = + (tron_address_body_hex(recipient), decimal_u256_word(amount)) + else { + return false; + }; + parameter.eq_ignore_ascii_case(&format!("{address:0>64}{amount_word}")) +} + +/// The 20 address bytes after the `0x41` prefix of a base58check Tron address, +/// as lowercase hex. The checksum is not rechecked: the quote's address was +/// validated when it was prepared, and this crate links no hashing. +fn tron_address_body_hex(address: &str) -> Option { + const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let mut bytes = [0u8; 25]; + for digit in address.bytes() { + let mut carry = u16::try_from(ALPHABET.iter().position(|&known| known == digit)?).ok()?; + for byte in bytes.iter_mut().rev() { + let value = u16::from(*byte) * 58 + carry; + *byte = (value & 0xff) as u8; + carry = value >> 8; + } + if carry != 0 { + return None; + } + } + if bytes[0] != 0x41 { + return None; + } + let mut hex = String::with_capacity(40); + for byte in &bytes[1..21] { + hex.push(char::from(HEX[usize::from(byte >> 4)])); + hex.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + Some(hex) +} + +fn decimal_u256_word(decimal: &str) -> Option { + if decimal.is_empty() || !decimal.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let mut bytes = [0u8; 32]; + for digit in decimal.bytes().map(|byte| byte - b'0') { + let mut carry = u16::from(digit); + for byte in bytes.iter_mut().rev() { + let value = u16::from(*byte) * 10 + carry; + *byte = (value & 0xff) as u8; + carry = value >> 8; + } + if carry != 0 { + return None; + } + } + let mut word = String::with_capacity(64); + for byte in bytes { + word.push(char::from(HEX[usize::from(byte >> 4)])); + word.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + Some(word) +} + +fn valid_hex(value: &str) -> bool { + !value.is_empty() + && value.len().is_multiple_of(2) + && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinywallet-bus/src/intent/mod_tests.rs b/crates/tinywallet-bus/src/intent/mod_tests.rs new file mode 100644 index 00000000..a1fb684e --- /dev/null +++ b/crates/tinywallet-bus/src/intent/mod_tests.rs @@ -0,0 +1,655 @@ +//! Behavior tests for pure quote-to-intent comparisons. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::TronTransfer; +use crate::wire::{ + AuthorizeWeb3ExecutionRequest, EvmNetwork, NativeWalletExecutionIntent, PreparedKind, + PreparedStatus, PreparedTransaction, TransactionSpec, WalletChain, Web3ExecutionIntent, + Web3QuoteKind, Web3QuoteResponse, +}; + +fn prepared_quote( + quote_id: &str, + chain: WalletChain, + kind: PreparedKind, + amount_raw: &str, + to_address: &str, +) -> PreparedTransaction { + PreparedTransaction { + quote_id: quote_id.into(), + kind, + chain, + evm_network: Some(EvmNetwork::EthereumMainnet), + from_address: "sender".into(), + to_address: to_address.into(), + asset_symbol: "ETH".into(), + amount_raw: amount_raw.into(), + amount_formatted: amount_raw.into(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: None, + estimated_fee_raw: "1".into(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: 1, + expires_at_ms: u64::MAX, + notes: vec![], + } +} + +fn evm_intent(to: &str, value: &str, data: &str, chain_id: u64) -> NativeWalletExecutionIntent { + NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Evm { + to: to.into(), + value_wei: value.into(), + data_hex: data.into(), + nonce: 0, + gas_limit: 21_000, + gas_price_wei: "1".into(), + chain_id, + }, + } +} + +fn erc20_transfer_data(recipient: &str, amount: u64) -> String { + let address = recipient.strip_prefix("0x").unwrap(); + format!("0xa9059cbb{address:0>64}{amount:064x}") +} + +#[test] +fn native_bitcoin_transfer_requires_the_exact_chain_addresses_and_u64_amount() { + let quote = prepared_quote( + "btc-quote", + WalletChain::Btc, + PreparedKind::NativeTransfer, + "42", + "recipient", + ); + let intent = NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Btc { + from: "sender".into(), + to: "recipient".into(), + amount_sat: 42, + fee_sat: 1, + utxos: vec![], + }, + }; + assert!(super::native_intent_matches_quote("e, &intent)); + + let wrong_sender = NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Btc { + from: "other-sender".into(), + to: "recipient".into(), + amount_sat: 42, + fee_sat: 1, + utxos: vec![], + }, + }; + assert!(!super::native_intent_matches_quote("e, &wrong_sender)); + let wrong_amount_quote = prepared_quote( + "btc-overflow-quote", + WalletChain::Btc, + PreparedKind::NativeTransfer, + "18446744073709551616", + "recipient", + ); + assert!(!super::native_intent_matches_quote( + &wrong_amount_quote, + &intent + )); +} + +#[test] +fn native_evm_transfer_uses_the_quoted_network_and_empty_calldata() { + let quote = prepared_quote( + "evm-native", + WalletChain::Evm, + PreparedKind::NativeTransfer, + "42", + "recipient", + ); + assert!(super::native_intent_matches_quote( + "e, + &evm_intent("recipient", "42", "", 1) + )); + // EIP-55 checksum casing does not change the address. + assert!(super::native_intent_matches_quote( + "e, + &evm_intent("RECIPIENT", "42", "", 1) + )); + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent("recipient2", "42", "", 1) + )); + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent("recipient", "42", "0x", 1) + )); + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent("recipient", "42", "", 137) + )); + + let mut default_network = quote; + default_network.evm_network = None; + assert!(super::native_intent_matches_quote( + &default_network, + &evm_intent( + "recipient", + "42", + "", + EvmNetwork::EthereumMainnet.chain_id() + ) + )); +} + +#[test] +fn native_evm_token_transfer_checks_contract_selector_recipient_and_uint256_amount() { + let recipient = "0x1111111111111111111111111111111111111111"; + let contract = "0x2222222222222222222222222222222222222222"; + let mut quote = prepared_quote( + "evm-token", + WalletChain::Evm, + PreparedKind::TokenTransfer, + "42", + recipient, + ); + quote.token_address = Some(contract.into()); + let valid = evm_intent( + &contract.to_ascii_uppercase().replacen("0X", "0x", 1), + "0", + &erc20_transfer_data(recipient, 42), + 1, + ); + assert!(super::native_intent_matches_quote("e, &valid)); + + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent(contract, "0", "0xdeadbeef", 1) + )); + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent(contract, "1", &erc20_transfer_data(recipient, 42), 1) + )); + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent( + contract, + "0", + &erc20_transfer_data("0x3333333333333333333333333333333333333333", 42), + 1, + ) + )); + + quote.amount_raw = + "115792089237316195423570985008687907853269984665640564039457584007913129639936".into(); + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent(contract, "0", &erc20_transfer_data(recipient, 42), 1) + )); + quote.token_address = None; + assert!(!super::native_intent_matches_quote( + "e, + &evm_intent(contract, "0", &erc20_transfer_data(recipient, 42), 1) + )); +} + +#[test] +fn native_solana_and_tron_transfers_match_chain_specific_amounts() { + let solana_quote = prepared_quote( + "solana-native", + WalletChain::Solana, + PreparedKind::NativeTransfer, + "1000", + "recipient", + ); + let solana_intent = NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Solana { + from: "sender".into(), + to: "recipient".into(), + lamports: 1000, + recent_blockhash: "blockhash".into(), + }, + }; + assert!(super::native_intent_matches_quote( + &solana_quote, + &solana_intent + )); + + let tron_quote = prepared_quote( + "tron-native", + WalletChain::Tron, + PreparedKind::NativeTransfer, + "25", + "recipient", + ); + let tron_intent = NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Tron { + raw_data_hex: "00".into(), + expected_to: "recipient".into(), + expected_txid: "txid".into(), + transfer: TronTransfer::Native { amount_sun: 25 }, + }, + }; + assert!(super::native_intent_matches_quote( + &tron_quote, + &tron_intent + )); + let wrong_amount = NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Tron { + raw_data_hex: "00".into(), + expected_to: "recipient".into(), + expected_txid: "txid".into(), + transfer: TronTransfer::Native { amount_sun: 26 }, + }, + }; + assert!(!super::native_intent_matches_quote( + &tron_quote, + &wrong_amount + )); +} + +const TRON_USDT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const TRON_RECIPIENT: &str = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"; +/// `transfer(TRON_RECIPIENT, 25)` without the selector. +const TRC20_PAY_25: &str = "000000000000000000000000c8599111f29c1e1e061265b4af93ea1f274ad78a\ + 0000000000000000000000000000000000000000000000000000000000000019"; + +fn trc20_intent(expected_to: &str, parameter_hex: &str) -> NativeWalletExecutionIntent { + NativeWalletExecutionIntent::Transaction { + transaction: TransactionSpec::Tron { + raw_data_hex: "00".into(), + expected_to: expected_to.into(), + expected_txid: "txid".into(), + transfer: TronTransfer::Trc20 { + parameter_hex: parameter_hex.into(), + }, + }, + } +} + +#[test] +fn trc20_intents_bind_the_token_recipient_and_amount() { + let mut tron_quote = prepared_quote( + "tron-token", + WalletChain::Tron, + PreparedKind::TokenTransfer, + "25", + TRON_RECIPIENT, + ); + tron_quote.token_address = Some(TRON_USDT.into()); + assert!(super::native_intent_matches_quote( + &tron_quote, + &trc20_intent(TRON_USDT, TRC20_PAY_25) + )); + assert!(super::native_intent_matches_quote( + &tron_quote, + &trc20_intent(TRON_USDT, &TRC20_PAY_25.to_ascii_uppercase()) + )); + + let wrong_amount = TRC20_PAY_25.replace("019", "01a"); + let wrong_recipient = TRC20_PAY_25.replace("c8599111", "c8599112"); + for (expected_to, parameter_hex) in [ + (TRON_USDT, wrong_amount.as_str()), + (TRON_USDT, wrong_recipient.as_str()), + // Another token contract, or the recipient itself as the call target. + ("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", TRC20_PAY_25), + (TRON_USDT, "deadbeef"), + (TRON_USDT, ""), + (TRON_USDT, "0xgg"), + ] { + assert!( + !super::native_intent_matches_quote( + &tron_quote, + &trc20_intent(expected_to, parameter_hex) + ), + "{expected_to} {parameter_hex}" + ); + } + + let mut no_token = tron_quote.clone(); + no_token.token_address = None; + assert!(!super::native_intent_matches_quote( + &no_token, + &trc20_intent(TRON_USDT, TRC20_PAY_25) + )); + let mut bad_recipient = tron_quote; + bad_recipient.to_address = "recipient".into(); + assert!(!super::native_intent_matches_quote( + &bad_recipient, + &trc20_intent(TRON_USDT, TRC20_PAY_25) + )); +} + +#[test] +fn native_token_signing_intents_reject_malformed_hex_and_wrong_quote_kinds() { + let solana_token_quote = prepared_quote( + "solana-token", + WalletChain::Solana, + PreparedKind::TokenTransfer, + "1", + "recipient", + ); + assert!(super::native_intent_matches_quote( + &solana_token_quote, + &NativeWalletExecutionIntent::SolanaMessage { + message_hex: "010203".into(), + } + )); + for message_hex in ["", "abc", "01xz"] { + assert!(!super::native_intent_matches_quote( + &solana_token_quote, + &NativeWalletExecutionIntent::SolanaMessage { + message_hex: message_hex.into(), + } + )); + } + let wrong_kind = prepared_quote( + "solana-native-kind", + WalletChain::Solana, + PreparedKind::NativeTransfer, + "1", + "recipient", + ); + assert!(!super::native_intent_matches_quote( + &wrong_kind, + &NativeWalletExecutionIntent::SolanaMessage { + message_hex: "010203".into(), + } + )); +} + +fn web3_quote( + kind: Web3QuoteKind, + signing_chain: WalletChain, + quote: serde_json::Value, +) -> Web3QuoteResponse { + Web3QuoteResponse { + quote_id: "quote-1".into(), + kind, + expires_at_ms: u64::MAX, + signing_chain, + quote, + } +} + +fn web3_request( + quote_id: &str, + operation: &str, + transaction: Web3ExecutionIntent, +) -> AuthorizeWeb3ExecutionRequest { + AuthorizeWeb3ExecutionRequest { + quote_id: quote_id.into(), + operation: operation.into(), + transaction, + } +} + +fn web3_evm( + network: &str, + to: &str, + data: Option<&str>, + value: &str, + chain_id: u64, +) -> Web3ExecutionIntent { + Web3ExecutionIntent::Evm { + network: network.into(), + to: to.into(), + data: data.map(str::to_owned), + value: value.into(), + nonce: 0, + gas_limit: 21_000, + gas_price_wei: "1".into(), + chain_id, + } +} + +fn dapp_quote(address: &str) -> Web3QuoteResponse { + web3_quote( + Web3QuoteKind::DappCall, + WalletChain::Evm, + serde_json::json!({ + "network": "polygon_mainnet", + "contractAddress": address, + "calldata": "0xabcd", + "valueRaw": "5" + }), + ) +} + +fn dapp_request( + quote_id: &str, + operation: &str, + network: &str, + to: &str, + data: Option<&str>, + value: &str, + chain_id: u64, +) -> AuthorizeWeb3ExecutionRequest { + web3_request( + quote_id, + operation, + web3_evm(network, to, data, value, chain_id), + ) +} + +#[test] +fn web3_dapp_call_matches_case_insensitive_address_and_exact_call_facts() { + let address = "0x1111111111111111111111111111111111111111"; + let quote = dapp_quote(address); + assert!(super::web3_approval_matches_request( + "e, + &dapp_request( + "quote-1", + "dapp_call", + "polygon_mainnet", + &address.to_ascii_uppercase().replacen("0X", "0x", 1), + Some("0xabcd"), + "5", + 137, + ) + )); +} + +#[test] +fn web3_dapp_call_rejects_a_different_quote_operation_or_network() { + let address = "0x1111111111111111111111111111111111111111"; + let quote = dapp_quote(address); + for (quote_id, operation, network, chain_id) in [ + ("other-quote", "dapp_call", "polygon_mainnet", 137), + ("quote-1", "swap", "polygon_mainnet", 137), + ("quote-1", "dapp_call", "ethereum_mainnet", 137), + ("quote-1", "dapp_call", "polygon_mainnet", 1), + ] { + assert!(!super::web3_approval_matches_request( + "e, + &dapp_request( + quote_id, + operation, + network, + address, + Some("0xabcd"), + "5", + chain_id, + ) + )); + } +} + +#[test] +fn web3_dapp_call_rejects_changed_call_facts_and_missing_quote_fields() { + let address = "0x1111111111111111111111111111111111111111"; + let quote = dapp_quote(address); + for (data, value) in [(Some("0xabce"), "5"), (Some("0xabcd"), "6"), (None, "5")] { + assert!(!super::web3_approval_matches_request( + "e, + &dapp_request( + "quote-1", + "dapp_call", + "polygon_mainnet", + address, + data, + value, + 137, + ) + )); + } + + let missing_calldata = web3_quote( + Web3QuoteKind::DappCall, + WalletChain::Evm, + serde_json::json!({ + "network": "polygon_mainnet", + "contractAddress": address, + "calldata": null, + "valueRaw": "5" + }), + ); + assert!(!super::web3_approval_matches_request( + &missing_calldata, + &dapp_request( + "quote-1", + "dapp_call", + "polygon_mainnet", + address, + None, + "5", + 137, + ) + )); +} + +#[test] +fn web3_swap_and_bridge_compare_transaction_data_and_string_or_number_value() { + for kind in [Web3QuoteKind::Swap, Web3QuoteKind::Bridge] { + let quote = web3_quote( + kind, + WalletChain::Evm, + serde_json::json!({ + "network": "polygon_mainnet", + "tx": { + "to": "0x1111111111111111111111111111111111111111", + "data": "0x0102", + "value": 5 + } + }), + ); + let operation = if kind == Web3QuoteKind::Swap { + "swap" + } else { + "bridge" + }; + assert!(super::web3_approval_matches_request( + "e, + &web3_request( + "quote-1", + operation, + web3_evm( + "polygon_mainnet", + "0x1111111111111111111111111111111111111111", + Some("0x0102"), + "5", + 137, + ), + ) + )); + assert!(!super::web3_approval_matches_request( + "e, + &web3_request( + "quote-1", + operation, + web3_evm( + "polygon_mainnet", + "0x1111111111111111111111111111111111111111", + Some("0x0103"), + "5", + 137 + ), + ) + )); + } + + let wrong_value_type = web3_quote( + Web3QuoteKind::Swap, + WalletChain::Evm, + serde_json::json!({ + "network": "polygon_mainnet", + "tx": { + "to": "recipient", + "data": "0x01", + "value": true + } + }), + ); + assert!(!super::web3_approval_matches_request( + &wrong_value_type, + &web3_request( + "quote-1", + "swap", + web3_evm("polygon_mainnet", "recipient", Some("0x01"), "1", 137), + ) + )); +} + +#[test] +fn web3_solana_matches_exact_swap_or_bridge_transaction_bytes_only() { + let quote = web3_quote( + Web3QuoteKind::Bridge, + WalletChain::Solana, + serde_json::json!({ "tx": { "data": "010203" } }), + ); + let request = web3_request( + "quote-1", + "bridge", + Web3ExecutionIntent::Solana { + tx_blob_hex: "010203".into(), + }, + ); + assert!(super::web3_approval_matches_request("e, &request)); + let changed = web3_request( + "quote-1", + "bridge", + Web3ExecutionIntent::Solana { + tx_blob_hex: "010204".into(), + }, + ); + assert!(!super::web3_approval_matches_request("e, &changed)); + + let dapp_call = web3_quote( + Web3QuoteKind::DappCall, + WalletChain::Solana, + serde_json::json!({ "tx": { "data": "010203" } }), + ); + assert!(!super::web3_approval_matches_request(&dapp_call, &request)); + let wrong_signing_chain = web3_quote( + Web3QuoteKind::Bridge, + WalletChain::Evm, + serde_json::json!({ "tx": { "data": "010203" } }), + ); + assert!(!super::web3_approval_matches_request( + &wrong_signing_chain, + &request + )); +} + +#[test] +fn optional_quote_network_is_checked_only_when_it_is_a_string() { + let contract = "0x1111111111111111111111111111111111111111"; + let quote = web3_quote( + Web3QuoteKind::DappCall, + WalletChain::Evm, + serde_json::json!({ + "network": null, + "contractAddress": contract, + "calldata": "0x01", + "valueRaw": "0" + }), + ); + let request = web3_request( + "quote-1", + "dapp_call", + web3_evm("ethereum_mainnet", contract, Some("0x01"), "0", 1), + ); + assert!(super::web3_approval_matches_request("e, &request)); +} diff --git a/crates/tinywallet-bus/src/lib.rs b/crates/tinywallet-bus/src/lib.rs index 281fcafb..81269b48 100644 --- a/crates/tinywallet-bus/src/lib.rs +++ b/crates/tinywallet-bus/src/lib.rs @@ -21,9 +21,12 @@ //! The `serde` feature derives wire serialization; `wire` enables call DTOs. //! Legacy feature names remain accepted but never enable implementation code. +#[cfg(feature = "wire")] +pub mod intent; pub mod mnemonic; pub mod names; pub mod storage; +mod storage_names; #[cfg(feature = "wire")] pub mod tools; pub mod version; @@ -46,6 +49,7 @@ pub use names::{ BUS_NAME, CONFIDENTIAL_METHODS, HOST_INTERFACE, HOST_METHODS, HOST_OBJECT_PATH, METHODS, OBJECT_PATH, }; +pub use storage_names::{BUDGETS_COLLECTION, PAYMENTS_STREAM}; pub use version::{CONTRACT_VERSION, is_compatible}; #[cfg(test)] diff --git a/crates/tinywallet-bus/src/lib_tests.rs b/crates/tinywallet-bus/src/lib_tests.rs index bd939552..ca4fb307 100644 --- a/crates/tinywallet-bus/src/lib_tests.rs +++ b/crates/tinywallet-bus/src/lib_tests.rs @@ -118,6 +118,16 @@ fn the_contract_accepts_its_own_version_and_newer_minors() { assert!(!is_compatible((1, 5))); } +#[test] +fn storage_declarations_are_available_from_the_bus_contract() { + let names = super::storage_collections() + .into_iter() + .map(|collection| collection.name) + .collect::>(); + + assert_eq!(names, ["wallet.budgets", "wallet.migrations"]); +} + #[test] fn the_identity_matches_the_path_the_module_serves() { // Both spellings appear in the module's `serve_at` / `request_name` pair, @@ -185,16 +195,6 @@ fn the_method_list_is_sorted_complete_and_free_of_duplicates() { } } -#[test] -fn storage_declarations_are_available_from_the_bus_contract() { - let names = super::storage_collections() - .into_iter() - .map(|collection| collection.name) - .collect::>(); - - assert_eq!(names, ["wallet.budgets", "wallet.migrations"]); -} - #[cfg(feature = "wire")] #[test] fn x402_history_and_budget_wire_vocabulary_preserves_rpc_record_shape() { diff --git a/crates/tinywallet-bus/src/storage.rs b/crates/tinywallet-bus/src/storage.rs index 511f8709..2b1bba14 100644 --- a/crates/tinywallet-bus/src/storage.rs +++ b/crates/tinywallet-bus/src/storage.rs @@ -1,10 +1,26 @@ //! Pure storage declarations the host can ensure before starting the module. -use tinystoragedrivers_types::CollectionSpec; - #[cfg(feature = "wire")] use serde::{Deserialize, Serialize}; +/// A host-facing declaration for a collection the wallet module uses. +/// +/// The host translates this portable name into its own storage-driver type. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "wire", derive(Serialize, Deserialize))] +pub struct CollectionDeclaration { + /// Fully qualified collection name. + pub name: String, +} + +impl CollectionDeclaration { + /// Declare a collection by its fully qualified name. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + #[cfg(feature = "wire")] /// Payment lifecycle state in the canonical `wallet.payments` record. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -73,9 +89,9 @@ pub struct StoredPaymentRecord { /// Migration behavior remains in the x402 implementation. The host only needs /// these declarations to ensure the collections in its already-bound scope. #[must_use] -pub fn collections() -> Vec { +pub fn collections() -> Vec { vec![ - CollectionSpec::new("wallet.budgets"), - CollectionSpec::new("wallet.migrations"), + CollectionDeclaration::new("wallet.budgets"), + CollectionDeclaration::new("wallet.migrations"), ] } diff --git a/crates/tinywallet-bus/src/storage_names.rs b/crates/tinywallet-bus/src/storage_names.rs new file mode 100644 index 00000000..5e07e6ac --- /dev/null +++ b/crates/tinywallet-bus/src/storage_names.rs @@ -0,0 +1,6 @@ +//! Canonical current and legacy names for wallet-owned storage resources. + +/// Collection holding the single budget-hold document. +pub const BUDGETS_COLLECTION: &str = "wallet.budgets"; +/// Append-only stream of payment records. +pub const PAYMENTS_STREAM: &str = "wallet.payments"; diff --git a/crates/tinywallet-client/Cargo.toml b/crates/tinywallet-client/Cargo.toml new file mode 100644 index 00000000..1226fdc2 --- /dev/null +++ b/crates/tinywallet-client/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "tinywallet-client" +version = "0.27.0" +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +description = "Typed TinyBus host client for the TinyWallet module." +repository = "https://github.com/tinyhumansai/tinywallet" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +tinywallet-bus = { path = "../tinywallet-bus", default-features = false, features = ["wire"] } +tinybus.workspace = true +zeroize = "1" + +[dev-dependencies] +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt", "sync"] } +tinybus = { workspace = true, features = ["macros", "modules"] } + +[features] +default = [] +# Exercises the locally built cdylib through its hash-verified TinyBus cache. +# The feature and runner stage the required archive and distinct library pin. +native-e2e = [] +[[test]] +name = "native_e2e" +path = "tests/native_e2e.rs" +required-features = ["native-e2e"] + +[[test]] +name = "native_unattested" +path = "tests/native_unattested.rs" +required-features = ["native-e2e"] + +[[test]] +name = "attested_errors" +path = "tests/attested_errors.rs" +required-features = ["native-e2e"] + +[lints] +workspace = true diff --git a/crates/tinywallet-client/src/lib.rs b/crates/tinywallet-client/src/lib.rs new file mode 100644 index 00000000..de4ff45d --- /dev/null +++ b/crates/tinywallet-client/src/lib.rs @@ -0,0 +1,340 @@ +//! Typed host calls to the `TinyWallet` `TinyBus` module. +//! +//! `WalletClient` is a transport facade over a host configured +//! `tinybus::module::ModuleClient`. It owns no loader, endpoint policy, approval +//! flow, provider configuration, or wallet implementation. Confidential calls +//! use `call_attested`, which checks the host registry's archive and extracted +//! library pins against the same unique peer before serializing a request. + +use std::fmt; + +use serde::{Serialize, de::DeserializeOwned}; +use tinybus::module::{ModuleCallFailure, ModuleClient}; +pub use tinywallet_bus::Chain; +use tinywallet_bus::names::CONFIDENTIAL_METHODS; +pub use tinywallet_bus::names::methods; +pub use tinywallet_bus::wire::{ + DeriveMnemonicSeedRequest, DeriveMnemonicSeedResponse, DerivedAccount, + EncodeErc20TransferRequest, EncodeErc20TransferResponse, ExportRequest, ExportedKey, + GenerateMnemonicRequest, GenerateMnemonicResponse, Scheme, SecretMaterial, SignMessageRequest, + SignRequest, Signature, SignedTransaction, TransactionSpec, +}; +use zeroize::Zeroize; + +const MODULE_ID: &str = "tinywallet"; + +/// Failure from a typed `TinyWallet` module call. +/// +/// Detailed remote failure data is available through `Remote`. Its `Debug` and +/// `Display` renderings redact untrusted wire names and prose through `TinyBus`; +/// callers that need those raw fields must apply their own redaction policy. +#[derive(Debug)] +pub enum ClientError { + /// An ordinary call was attempted for a member declared confidential. + ConfidentialRequired, + /// An attested call was attempted for a public member. + NotConfidential, + /// An ordinary module call failed. + Remote(ModuleCallFailure), +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ConfidentialRequired => f.write_str("wallet member requires attested delivery"), + Self::NotConfidential => f.write_str("wallet member is not confidential"), + Self::Remote(error) => fmt::Display::fmt(error, f), + } + } +} + +impl std::error::Error for ClientError {} + +/// Typed call facade over the host's configured `TinyBus` module client. +#[derive(Clone)] +pub struct WalletClient { + client: ModuleClient, +} + +impl fmt::Debug for WalletClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WalletClient").finish_non_exhaustive() + } +} + +impl WalletClient { + /// Create a wallet facade from host-owned module access and release pins. + #[must_use] + pub fn new(client: ModuleClient) -> Self { + Self { client } + } + + /// Make a typed ordinary call with exactly one request argument. + /// + /// Members declared confidential by tinywallet-bus are refused before the + /// request is serialized. The request is borrowed, so the caller retains + /// ownership and is responsible for wiping any secret fields after refusal + /// or completion. Confidential typed wrappers provide an RAII mnemonic + /// guard. + /// + /// Secret-bearing requests cannot be consumed by this ordinary-call API: + /// + /// ```compile_fail + /// use tinywallet_client::{WalletClient, methods, SecretMaterial}; + /// + /// fn pass_owned_secret(wallet: &WalletClient, secret: SecretMaterial) { + /// let _future = wallet.call::<_, serde_json::Value>( + /// methods::DERIVE_ACCOUNT, + /// secret, + /// ); + /// } + /// ``` + /// + /// # Errors + /// + /// Returns `ConfidentialRequired` for a confidential member, or `Remote` for + /// a failed module call. + pub async fn call( + &self, + member: &str, + request: &Request, + ) -> Result + where + Request: Serialize + Sync + ?Sized, + Reply: DeserializeOwned, + { + if is_confidential(member) { + return Err(ClientError::ConfidentialRequired); + } + self.client + .call_detailed(MODULE_ID, member, (request,)) + .await + .map_err(ClientError::Remote) + } + + /// Make a typed call using strict peer and release-library attestation. + /// + /// The request is borrowed so the caller retains ownership and remains + /// responsible for wiping any secret fields after the call completes or is + /// cancelled. Typed wallet wrappers provide an RAII mnemonic guard. + /// + /// Secret-bearing DTOs cannot be passed by value: + /// + /// ```compile_fail + /// use tinywallet_client::{WalletClient, methods, SecretMaterial}; + /// + /// fn pass_owned_secret(wallet: &WalletClient, secret: SecretMaterial) { + /// let _future = wallet.call_attested::<_, serde_json::Value>( + /// methods::SIGN_TRANSACTION, + /// secret, + /// ); + /// } + /// ``` + /// + /// `TinyBus` validates archive and extracted-library pins in the host's + /// `ReleaseRecord`, then proves both against the same unique peer before it + /// serializes the request. There is no ordinary-delivery fallback. + /// + /// # Errors + /// + /// Returns `NotConfidential` for a public member, or `Remote` with the + /// detailed failure if the strict call fails. + pub async fn call_attested( + &self, + member: &str, + request: &Request, + ) -> Result + where + Request: Serialize + Sync + ?Sized, + Reply: DeserializeOwned, + { + if !is_confidential(member) { + return Err(ClientError::NotConfidential); + } + self.client + .call_attested_detailed(MODULE_ID, member, (request,)) + .await + .map_err(ClientError::Remote) + } + + /// Derive an account through an attested wallet module. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable, unattested, or rejects + /// the request. + pub async fn derive_account( + &self, + secret: SecretMaterial, + ) -> Result { + let request = MnemonicRequestGuard(secret); + self.call_attested(methods::DERIVE_ACCOUNT, &request.0) + .await + } + + /// Sign a transaction inside the attested wallet module. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable, unattested, or rejects + /// the transaction. + pub async fn sign_transaction( + &self, + secret: SecretMaterial, + transaction: TransactionSpec, + ) -> Result { + let request = MnemonicRequestGuard(SignRequest { + secret, + transaction, + }); + self.call_attested(methods::SIGN_TRANSACTION, &request.0) + .await + } + + /// Sign opaque bytes inside the attested wallet module. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable, unattested, or rejects + /// the request. + pub async fn sign_message( + &self, + secret: SecretMaterial, + message: &[u8], + scheme: Scheme, + ) -> Result { + let request = MnemonicRequestGuard(SignMessageRequest { + secret, + message_hex: hex(message), + scheme, + }); + self.call_attested(methods::SIGN_MESSAGE, &request.0).await + } + + /// Export a derived key through the attested wallet module. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable, unattested, or rejects + /// the request. + pub async fn export_key(&self, secret: SecretMaterial) -> Result { + let request = MnemonicRequestGuard(ExportRequest { secret }); + self.call_attested(methods::EXPORT_KEY, &request.0).await + } + + /// Generate a recovery phrase in the attested wallet module. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable, unattested, or rejects + /// the requested word count. + pub async fn generate_mnemonic( + &self, + word_count: u8, + ) -> Result { + let request = GenerateMnemonicRequest { word_count }; + self.call_attested(methods::GENERATE_MNEMONIC, &request) + .await + } + + /// Derive a BIP-39 seed through the attested wallet module. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable, unattested, or rejects + /// the phrase. + pub async fn derive_mnemonic_seed( + &self, + mnemonic: String, + ) -> Result { + let request = MnemonicRequestGuard(DeriveMnemonicSeedRequest { mnemonic }); + self.call_attested(methods::DERIVE_MNEMONIC_SEED, &request.0) + .await + } + + /// Encode ERC-20 transfer calldata with the module-owned ABI implementation. + /// + /// # Errors + /// + /// Returns an error if the module is unavailable or rejects the recipient + /// or amount. + pub async fn encode_erc20_transfer( + &self, + to_address: String, + amount_raw: String, + ) -> Result { + let request = EncodeErc20TransferRequest { + to_address, + amount_raw, + }; + let response: EncodeErc20TransferResponse = + self.call(methods::ENCODE_ERC20_TRANSFER, &request).await?; + Ok(response.calldata) + } +} + +fn is_confidential(member: &str) -> bool { + CONFIDENTIAL_METHODS.contains(&member) +} + +struct MnemonicRequestGuard(T); + +impl Drop for MnemonicRequestGuard { + fn drop(&mut self) { + self.0.zeroize_mnemonic(); + } +} + +trait ZeroizeMnemonic { + fn zeroize_mnemonic(&mut self); +} + +impl ZeroizeMnemonic for SecretMaterial { + fn zeroize_mnemonic(&mut self) { + self.mnemonic.zeroize(); + } +} + +impl ZeroizeMnemonic for DeriveMnemonicSeedRequest { + fn zeroize_mnemonic(&mut self) { + self.mnemonic.zeroize(); + } +} + +impl ZeroizeMnemonic for SignRequest { + fn zeroize_mnemonic(&mut self) { + self.secret.mnemonic.zeroize(); + #[cfg(test)] + crate::tests::observe_mnemonic_wipe("sign_transaction", self.secret.mnemonic.is_empty()); + } +} + +impl ZeroizeMnemonic for SignMessageRequest { + fn zeroize_mnemonic(&mut self) { + self.secret.mnemonic.zeroize(); + #[cfg(test)] + crate::tests::observe_mnemonic_wipe("sign_message", self.secret.mnemonic.is_empty()); + } +} + +impl ZeroizeMnemonic for ExportRequest { + fn zeroize_mnemonic(&mut self) { + self.secret.mnemonic.zeroize(); + #[cfg(test)] + crate::tests::observe_mnemonic_wipe("export_key", self.secret.mnemonic.is_empty()); + } +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/tinywallet-client/src/lib_tests.rs b/crates/tinywallet-client/src/lib_tests.rs new file mode 100644 index 00000000..ea3fbe55 --- /dev/null +++ b/crates/tinywallet-client/src/lib_tests.rs @@ -0,0 +1,571 @@ +//! Tests for confidential routing decisions in the typed wallet client. + +use super::*; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use tinybus::module::{ + AccessFuture, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, ProxyFailure, + ReleaseAsset, ReleaseRecord, +}; + +struct Quiet; + +impl FailureSink for Quiet { + fn capture(&self, _: &tinybus::module::FailureReport<'_>) {} +} + +struct NoModules { + reporter: FailureReporter, +} + +static RECORD: ReleaseRecord = ReleaseRecord { + id: "tinywallet", + bus_name: tinywallet_bus::BUS_NAME, + object_path: tinywallet_bus::OBJECT_PATH, + version: "0.25.1", + release_url: "https://example.invalid/tinywallet", + assets: &[], +}; + +impl ModuleAccess for NoModules { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == RECORD.id).then_some(&RECORD) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Err("not loaded".to_string()) }) + } + + fn proxy<'a>( + &'a self, + _: &'a str, + _: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async { Err(ProxyFailure::Transport) }) + } +} + +fn client() -> WalletClient { + WalletClient::new(ModuleClient::new(NoModules { + reporter: FailureReporter::new(Arc::new(Quiet)), + })) +} + +struct MustNotSerialize(Arc); + +impl serde::Serialize for MustNotSerialize { + fn serialize(&self, _: S) -> Result + where + S: serde::Serializer, + { + self.0.store(true, Ordering::SeqCst); + Err(::custom( + "request serialization was not expected", + )) + } +} + +struct CallerOwnedSecretRequest { + secret: String, + serialized: Arc, + dropped: Arc, +} + +impl serde::Serialize for CallerOwnedSecretRequest { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.serialized.store(true, Ordering::SeqCst); + serializer.serialize_str(&self.secret) + } +} + +impl Drop for CallerOwnedSecretRequest { + fn drop(&mut self) { + self.secret.zeroize(); + self.dropped.store(true, Ordering::SeqCst); + } +} + +#[test] +fn every_bus_confidential_member_is_classified_as_confidential() { + for member in CONFIDENTIAL_METHODS { + assert!(is_confidential(member), "{member}"); + } +} + +#[test] +fn wallet_signing_members_are_declared_confidential() { + for member in [ + methods::DERIVE_ACCOUNT, + methods::DERIVE_MNEMONIC_SEED, + methods::EXPORT_KEY, + methods::GENERATE_MNEMONIC, + methods::SIGN_MESSAGE, + methods::SIGN_TRANSACTION, + ] { + assert!(is_confidential(member), "{member}"); + } +} + +#[test] +fn calls_use_the_tinywallet_contract_identity_and_declared_members() { + assert_eq!(tinywallet_bus::BUS_NAME, "ai.tinyhumans.tinywallet.Wallet"); + assert_eq!( + tinywallet_bus::OBJECT_PATH, + "/ai/tinyhumans/tinywallet/Wallet" + ); + for member in [ + methods::DERIVE_ACCOUNT, + methods::DERIVE_MNEMONIC_SEED, + methods::ENCODE_ERC20_TRANSFER, + methods::EXPORT_KEY, + methods::GENERATE_MNEMONIC, + methods::SIGN_MESSAGE, + methods::SIGN_TRANSACTION, + ] { + assert!( + tinywallet_bus::METHODS.contains(&member), + "{member} is not in the contract" + ); + } +} + +#[test] +fn public_wallet_members_remain_ordinary_calls() { + for member in [ + methods::ENCODE_ERC20_TRANSFER, + methods::VALIDATE_ADDRESS, + methods::WEB3_ROUTES, + methods::X402_CLOSE, + ] { + assert!(!is_confidential(member), "{member}"); + } +} + +#[test] +fn confidential_wire_requests_keep_their_distinct_wrapper_shapes() { + let secret = SecretMaterial { + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: tinywallet_bus::Chain::Evm, + }; + let bare = serde_json::to_value(&secret); + assert!(bare.is_ok(), "the secret wire type must serialize"); + let bare = bare.unwrap_or(serde_json::Value::Null); + assert!(serde_json::from_value::(bare).is_err()); + + let signing = serde_json::to_value(SignRequest { + secret: secret.clone(), + transaction: TransactionSpec::Evm { + to: format!("0x{}", "11".repeat(20)), + value_wei: "1".to_string(), + data_hex: "0x".to_string(), + nonce: 0, + gas_limit: 21_000, + gas_price_wei: "1".to_string(), + chain_id: 1, + }, + }); + assert!(signing.is_ok(), "the sign request wire type must serialize"); + let signing = signing.unwrap_or(serde_json::Value::Null); + assert!(serde_json::from_value::(signing).is_err()); +} + +#[test] +fn remote_peer_details_are_absent_from_error_renderings() { + let error = ClientError::Remote(ModuleCallFailure { + kind: ModuleCallError::ModuleFault, + wire_name: "private-peer-detail".to_string(), + message: "sensitive request content".to_string(), + }); + assert!(!format!("{error:?}").contains("private-peer-detail")); + assert!(!format!("{error:?}").contains("sensitive request content")); + assert!(!error.to_string().contains("private-peer-detail")); + assert!(!error.to_string().contains("sensitive request content")); +} + +#[test] +fn mnemonic_request_types_wipe_their_phrase_fields() { + let mut secret = SecretMaterial { + mnemonic: "sensitive phrase".to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: tinywallet_bus::Chain::Evm, + }; + secret.zeroize_mnemonic(); + let secret_mnemonic_is_empty = secret.mnemonic.is_empty(); + assert!(secret_mnemonic_is_empty); + + let mut request = DeriveMnemonicSeedRequest { + mnemonic: "sensitive phrase".to_string(), + }; + request.zeroize_mnemonic(); + let request_mnemonic_is_empty = request.mnemonic.is_empty(); + assert!(request_mnemonic_is_empty); +} + +static OBSERVER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +static OBSERVED_MNEMONIC_WIPES: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +pub(super) fn observe_mnemonic_wipe(wrapper: &'static str, mnemonic_is_empty: bool) { + OBSERVED_MNEMONIC_WIPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((wrapper, mnemonic_is_empty)); +} + +fn take_observed_mnemonic_wipes() -> Vec<(&'static str, bool)> { + std::mem::take( + &mut *OBSERVED_MNEMONIC_WIPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) +} + +struct WipeObserved(Arc); + +impl ZeroizeMnemonic for WipeObserved { + fn zeroize_mnemonic(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +fn complete_with_guard(result: Result<(), ()>, wiped: Arc) -> Result<(), ()> { + let _request = MnemonicRequestGuard(WipeObserved(wiped)); + result +} + +#[test] +fn mnemonic_request_guard_wipes_after_success_and_error() { + for result in [Ok(()), Err(())] { + let wiped = Arc::new(AtomicBool::new(false)); + let outcome = complete_with_guard(result, wiped.clone()); + assert_eq!(outcome, result); + assert!(wiped.load(Ordering::SeqCst)); + } +} + +#[tokio::test] +async fn mnemonic_request_guard_wipes_when_the_waiting_call_is_cancelled() { + let wiped = Arc::new(AtomicBool::new(false)); + let task_wiped = wiped.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _request = MnemonicRequestGuard(WipeObserved(task_wiped)); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }); + assert!(started_rx.await.is_ok()); + task.abort(); + let _ = task.await; + assert!(wiped.load(Ordering::SeqCst)); +} + +fn secret_material() -> SecretMaterial { + SecretMaterial { + mnemonic: "sensitive phrase".to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: Chain::Evm, + } +} + +fn transaction() -> TransactionSpec { + TransactionSpec::Evm { + to: format!("0x{}", "11".repeat(20)), + value_wei: "1".to_string(), + data_hex: "0x".to_string(), + nonce: 0, + gas_limit: 21_000, + gas_price_wei: "1".to_string(), + chain_id: 1, + } +} + +fn assert_remote_unavailable(result: Result) { + assert!(matches!( + result, + Err(ClientError::Remote(failure)) if failure.kind == ModuleCallError::Unavailable + )); +} + +#[tokio::test] +async fn signing_and_export_wrappers_wipe_mnemonics_after_errors() { + let _observer_lock = OBSERVER_TEST_LOCK.lock().await; + let _ = take_observed_mnemonic_wipes(); + + let result = client() + .sign_transaction(secret_material(), transaction()) + .await; + assert_remote_unavailable(result); + let result = client() + .sign_message(secret_material(), &[0x42; 32], Scheme::Secp256k1Prehash) + .await; + assert_remote_unavailable(result); + let result = client().export_key(secret_material()).await; + assert_remote_unavailable(result); + + assert_eq!( + take_observed_mnemonic_wipes(), + vec![ + ("sign_transaction", true), + ("sign_message", true), + ("export_key", true), + ] + ); +} + +static STRICT_RECORD: std::sync::OnceLock> = std::sync::OnceLock::new(); + +// The dummy pin lets the strict call reach preparation. The fake access never +// reports readiness or supplies a proxy, so no attestation succeeds and no +// request can be serialized or delivered. +fn strict_record() -> Option<&'static ReleaseRecord> { + STRICT_RECORD + .get_or_init(|| { + let host_key = tinybus::module::platform::host_candidates() + .into_iter() + .next()? + .into_boxed_str(); + let host_key = Box::leak(host_key); + let archive_sha256 = Box::leak("a".repeat(64).into_boxed_str()); + let library_sha256 = Box::leak("b".repeat(64).into_boxed_str()); + let assets = Box::leak( + vec![ReleaseAsset { + host_key, + archive: "fixture.tar.gz", + sha256: archive_sha256, + library_sha256: Some(library_sha256), + }] + .into_boxed_slice(), + ); + Some(ReleaseRecord { + id: "tinywallet", + bus_name: tinywallet_bus::BUS_NAME, + object_path: tinywallet_bus::OBJECT_PATH, + version: "0.25.1", + release_url: "https://example.invalid/tinywallet", + assets, + }) + }) + .as_ref() +} + +struct PendingModules { + reporter: FailureReporter, + started: Arc, +} + +impl ModuleAccess for PendingModules { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + if id == "tinywallet" { + strict_record() + } else { + None + } + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Err("unexpected ordinary load".to_string()) }) + } + + fn prepare_attested<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + let started = Arc::clone(&self.started); + Box::pin(async move { + started.notify_one(); + std::future::pending().await + }) + } + + fn proxy<'a>( + &'a self, + _: &'a str, + _: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async { Err(ProxyFailure::Transport) }) + } +} + +fn pending_client(started: Arc) -> WalletClient { + WalletClient::new(ModuleClient::new(PendingModules { + reporter: FailureReporter::new(Arc::new(Quiet)), + started, + })) +} + +#[tokio::test] +async fn signing_and_export_wrappers_wipe_mnemonics_when_cancelled() { + // Unsupported hosts have no published module platform candidate to pin. + if strict_record().is_none() { + return; + } + let _observer_lock = OBSERVER_TEST_LOCK.lock().await; + let _ = take_observed_mnemonic_wipes(); + + let started = Arc::new(tokio::sync::Notify::new()); + let wallet = pending_client(Arc::clone(&started)); + let transaction_task = tokio::spawn(async move { + let _ = wallet + .sign_transaction(secret_material(), transaction()) + .await; + }); + started.notified().await; + transaction_task.abort(); + let _ = transaction_task.await; + + let started = Arc::new(tokio::sync::Notify::new()); + let wallet = pending_client(Arc::clone(&started)); + let message_task = tokio::spawn(async move { + let _ = wallet + .sign_message(secret_material(), &[0x42; 32], Scheme::Secp256k1Prehash) + .await; + }); + started.notified().await; + message_task.abort(); + let _ = message_task.await; + + let started = Arc::new(tokio::sync::Notify::new()); + let wallet = pending_client(Arc::clone(&started)); + let export_task = tokio::spawn(async move { + let _ = wallet.export_key(secret_material()).await; + }); + started.notified().await; + export_task.abort(); + let _ = export_task.await; + + assert_eq!( + take_observed_mnemonic_wipes(), + vec![ + ("sign_transaction", true), + ("sign_message", true), + ("export_key", true), + ] + ); +} + +#[tokio::test] +async fn ordinary_call_refuses_confidential_member_before_serializing_request() { + for member in CONFIDENTIAL_METHODS { + let serialized = Arc::new(AtomicBool::new(false)); + let request = MustNotSerialize(serialized.clone()); + let result = client() + .call::<_, serde_json::Value>(member, &request) + .await; + assert!( + matches!(result, Err(ClientError::ConfidentialRequired)), + "{member}" + ); + assert!(!serialized.load(Ordering::SeqCst), "{member}"); + } +} + +#[tokio::test] +async fn ordinary_refusal_leaves_secret_request_with_caller_to_wipe() { + let serialized = Arc::new(AtomicBool::new(false)); + let dropped = Arc::new(AtomicBool::new(false)); + let request = CallerOwnedSecretRequest { + secret: "content-free-test-secret".to_owned(), + serialized: Arc::clone(&serialized), + dropped: Arc::clone(&dropped), + }; + let result = client() + .call::<_, serde_json::Value>(methods::DERIVE_ACCOUNT, &request) + .await; + + assert!(matches!(result, Err(ClientError::ConfidentialRequired))); + assert!(!serialized.load(Ordering::SeqCst)); + assert!(!dropped.load(Ordering::SeqCst)); + drop(request); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn attested_call_refuses_public_member_before_module_access() { + let serialized = Arc::new(AtomicBool::new(false)); + let request = MustNotSerialize(serialized.clone()); + let result = client() + .call_attested::<_, serde_json::Value>(methods::ENCODE_ERC20_TRANSFER, &request) + .await; + assert!(matches!(result, Err(ClientError::NotConfidential))); + assert!(!serialized.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn attested_call_with_no_release_library_pins_never_serializes_request() { + let serialized = Arc::new(AtomicBool::new(false)); + let request = MustNotSerialize(serialized.clone()); + let result = client() + .call_attested::<_, serde_json::Value>(methods::SIGN_MESSAGE, &request) + .await; + assert!(matches!( + result, + Err(ClientError::Remote(failure)) if failure.kind == ModuleCallError::Unavailable + )); + assert!(!serialized.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn attested_failure_leaves_secret_request_with_caller_to_wipe() { + let serialized = Arc::new(AtomicBool::new(false)); + let dropped = Arc::new(AtomicBool::new(false)); + let request = CallerOwnedSecretRequest { + secret: "content-free-test-secret".to_owned(), + serialized: Arc::clone(&serialized), + dropped: Arc::clone(&dropped), + }; + let result = client() + .call_attested::<_, serde_json::Value>(methods::SIGN_MESSAGE, &request) + .await; + + assert!( + matches!(result, Err(ClientError::Remote(failure)) if failure.kind == ModuleCallError::Unavailable) + ); + assert!(!serialized.load(Ordering::SeqCst)); + assert!(!dropped.load(Ordering::SeqCst)); + drop(request); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn cancelling_attested_call_leaves_secret_request_with_caller_task_to_wipe() { + let serialized = Arc::new(AtomicBool::new(false)); + let dropped = Arc::new(AtomicBool::new(false)); + let started = Arc::new(tokio::sync::Notify::new()); + let wallet = pending_client(Arc::clone(&started)); + let task_serialized = Arc::clone(&serialized); + let task_dropped = Arc::clone(&dropped); + let task = tokio::spawn(async move { + let request = CallerOwnedSecretRequest { + secret: "content-free-test-secret".to_owned(), + serialized: task_serialized, + dropped: task_dropped, + }; + let _ = wallet + .call_attested::<_, serde_json::Value>(methods::SIGN_MESSAGE, &request) + .await; + }); + started.notified().await; + assert!(!dropped.load(Ordering::SeqCst)); + assert!(!serialized.load(Ordering::SeqCst)); + task.abort(); + let _ = task.await; + assert!(dropped.load(Ordering::SeqCst)); + assert!(!serialized.load(Ordering::SeqCst)); +} diff --git a/crates/tinywallet-client/tests/attested_errors.rs b/crates/tinywallet-client/tests/attested_errors.rs new file mode 100644 index 00000000..928f3318 --- /dev/null +++ b/crates/tinywallet-client/tests/attested_errors.rs @@ -0,0 +1,30 @@ +//! Verifies detailed remote errors survive strict confidential delivery. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +#[expect( + dead_code, + reason = "this fixture module is shared by independent Cargo integration-test crates" +)] +mod support; + +use tinywallet_client::ClientError; + +#[tokio::test] +async fn invalid_seed_preserves_remote_error_classification_on_strict_delivery() { + let fixture = support::fixture().await; + let result = fixture + .wallet + .derive_mnemonic_seed("not a valid mnemonic".to_string()) + .await; + let Err(ClientError::Remote(failure)) = result else { + panic!("strict remote InvalidInput must retain its detailed failure type"); + }; + + assert!( + failure.wire_name == "ai.tinyhumans.tinywallet.Error.InvalidInput", + "strict remote failure must preserve the wire classification" + ); + assert!(!format!("{failure:?}").contains("not a valid mnemonic")); + assert!(!failure.to_string().contains("not a valid mnemonic")); +} diff --git a/crates/tinywallet-client/tests/native_e2e.rs b/crates/tinywallet-client/tests/native_e2e.rs new file mode 100644 index 00000000..fb6192c8 --- /dev/null +++ b/crates/tinywallet-client/tests/native_e2e.rs @@ -0,0 +1,239 @@ +//! Real cached-module E2E tests for `TinyWalletClient` confidential wrappers. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +#[expect( + dead_code, + reason = "this fixture module is shared by independent Cargo integration-test crates" +)] +mod support; + +use tinywallet_client::{ + Chain, ClientError, Scheme, SecretMaterial, Signature, TransactionSpec, methods, +}; +use zeroize::Zeroize; + +fn secret() -> SecretMaterial { + SecretMaterial { + mnemonic: support::VECTOR.to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: Chain::Evm, + } +} + +fn transaction() -> TransactionSpec { + TransactionSpec::Evm { + to: "0x3535353535353535353535353535353535353535".to_string(), + value_wei: "1000000000000000000".to_string(), + data_hex: "0x".to_string(), + nonce: 9, + gas_limit: 21_000, + gas_price_wei: "20000000000".to_string(), + chain_id: 1, + } +} + +fn hex(bytes: &[u8]) -> String { + const TABLE: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(TABLE[usize::from(byte >> 4)])); + output.push(char::from(TABLE[usize::from(byte & 0x0f)])); + } + output +} + +async fn assert_same_peer(fixture: &support::Fixture, expected: &tinybus::BusName) { + assert_eq!(&fixture.access.unique_peer().await, expected); +} + +#[tokio::test] +async fn all_six_confidential_wrappers_use_the_verified_unique_peer() { + let fixture = support::fixture().await; + let record = support::record(false); + let unique_peer = fixture.access.unique_peer().await; + let attestation = fixture.access.attestation().await; + assert_eq!(attestation.name.as_str(), record.bus_name); + assert_eq!(attestation.sha256, record.assets[0].sha256); + assert_eq!( + attestation.library_sha256.as_deref(), + record.assets[0].library_sha256 + ); + + let account = fixture + .wallet + .derive_account(secret()) + .await + .expect("DeriveAccount returns its typed public account response"); + assert!(account.address.starts_with("0x")); + assert_eq!(account.public_key.key_hex.len(), 66); + assert_same_peer(&fixture, &unique_peer).await; + + let signed = fixture + .wallet + .sign_transaction(secret(), transaction()) + .await + .expect("SignTransaction returns a module-signed transaction"); + assert!(signed.raw.starts_with("0x")); + assert!(signed.raw.len() > 100); + assert_same_peer(&fixture, &unique_peer).await; + + let signature = fixture + .wallet + .sign_message(secret(), &[0x42; 32], Scheme::Secp256k1Prehash) + .await + .expect("SignMessage returns its typed signature"); + assert!(matches!( + signature, + Signature::Secp256k1 { ref rs_hex, recovery_id } + if rs_hex.len() == 128 && recovery_id <= 3 + )); + assert_same_peer(&fixture, &unique_peer).await; + + let mut exported = fixture + .wallet + .export_key(secret()) + .await + .expect("ExportKey returns its typed response only over attested delivery"); + assert_eq!(exported.secret_key_hex.len(), 64); + assert_eq!(exported.address, account.address); + exported.secret_key_hex.zeroize(); + assert_same_peer(&fixture, &unique_peer).await; + + let mut generated = fixture + .wallet + .generate_mnemonic(12) + .await + .expect("GenerateMnemonic returns its typed response"); + assert_eq!(generated.word_count, 12); + assert_eq!(generated.phrase.split_whitespace().count(), 12); + generated.phrase.zeroize(); + assert_same_peer(&fixture, &unique_peer).await; + + let mut seed = fixture + .wallet + .derive_mnemonic_seed(support::VECTOR.to_string()) + .await + .expect("DeriveMnemonicSeed returns its typed response"); + assert!( + hex(&seed.seed) + == "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "seed response must match the standard BIP-39 vector" + ); + seed.seed.zeroize(); + assert_same_peer(&fixture, &unique_peer).await; +} + +#[tokio::test] +async fn wrong_library_pin_prevents_business_delivery_for_all_confidential_methods() { + let fixture = support::fixture().await; + let unique_peer = fixture.access.unique_peer().await; + + assert!(support::expected_module_unavailable( + fixture + .wrong_pin_wallet + .derive_account(secret()) + .await + .unwrap_err() + )); + assert_same_peer(&fixture, &unique_peer).await; + assert!(support::expected_module_unavailable( + fixture + .wrong_pin_wallet + .sign_transaction(secret(), transaction()) + .await + .unwrap_err() + )); + assert_same_peer(&fixture, &unique_peer).await; + assert!(support::expected_module_unavailable( + fixture + .wrong_pin_wallet + .sign_message(secret(), &[0x42; 32], Scheme::Secp256k1Prehash) + .await + .unwrap_err() + )); + assert_same_peer(&fixture, &unique_peer).await; + assert!(support::expected_module_unavailable( + fixture + .wrong_pin_wallet + .export_key(secret()) + .await + .unwrap_err() + )); + assert_same_peer(&fixture, &unique_peer).await; + assert!(support::expected_module_unavailable( + fixture + .wrong_pin_wallet + .generate_mnemonic(12) + .await + .unwrap_err() + )); + assert_same_peer(&fixture, &unique_peer).await; + assert!(support::expected_module_unavailable( + fixture + .wrong_pin_wallet + .derive_mnemonic_seed(support::VECTOR.to_string()) + .await + .unwrap_err() + )); + assert_same_peer(&fixture, &unique_peer).await; +} + +#[tokio::test] +async fn ordinary_public_call_encodes_transfers_and_keeps_preflight_errors_safe() { + let fixture = support::fixture().await; + assert_eq!(format!("{:?}", fixture.wallet), "WalletClient { .. }"); + + let calldata = fixture + .wallet + .encode_erc20_transfer( + "0x1111111111111111111111111111111111111111".to_string(), + "1".to_string(), + ) + .await + .expect("public ERC-20 encoding uses the actual module client"); + assert_eq!( + calldata, + "0xa9059cbb\ + 0000000000000000000000001111111111111111111111111111111111111111\ + 0000000000000000000000000000000000000000000000000000000000000001" + ); + + let confidential_request = serde_json::json!({}); + let confidential_error = fixture + .wallet + .call::<_, serde_json::Value>(methods::SIGN_MESSAGE, &confidential_request) + .await + .unwrap_err(); + assert!(matches!( + &confidential_error, + ClientError::ConfidentialRequired + )); + assert_eq!( + confidential_error.to_string(), + "wallet member requires attested delivery" + ); + + let public_request = serde_json::json!({}); + let public_error = fixture + .wallet + .call_attested::<_, serde_json::Value>(methods::ENCODE_ERC20_TRANSFER, &public_request) + .await + .unwrap_err(); + assert!(matches!(&public_error, ClientError::NotConfidential)); + assert_eq!( + public_error.to_string(), + "wallet member is not confidential" + ); + + let remote_error = fixture + .wallet + .encode_erc20_transfer("not-an-address".to_string(), "1".to_string()) + .await + .unwrap_err(); + assert!(matches!( + remote_error, + ClientError::Remote(failure) + if failure.wire_name == "ai.tinyhumans.tinywallet.Error.InvalidInput" + )); +} diff --git a/crates/tinywallet-client/tests/native_unattested.rs b/crates/tinywallet-client/tests/native_unattested.rs new file mode 100644 index 00000000..8480f663 --- /dev/null +++ b/crates/tinywallet-client/tests/native_unattested.rs @@ -0,0 +1,68 @@ +//! A separate-process test for refusing secrets to an unattested module peer. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +#[expect( + dead_code, + reason = "this fixture module is shared by independent Cargo integration-test crates" +)] +mod support; + +use tinywallet_client::{Chain, Scheme, SecretMaterial, TransactionSpec}; + +fn secret() -> SecretMaterial { + SecretMaterial { + mnemonic: support::VECTOR.to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: Chain::Evm, + } +} + +fn transaction() -> TransactionSpec { + TransactionSpec::Evm { + to: "0x3535353535353535353535353535353535353535".to_string(), + value_wei: "1000000000000000000".to_string(), + data_hex: "0x".to_string(), + nonce: 9, + gas_limit: 21_000, + gas_price_wei: "20000000000".to_string(), + chain_id: 1, + } +} + +#[tokio::test] +async fn unattested_module_never_receives_any_confidential_wrapper_request() { + let fixture = support::unattested_fixture().await; + assert!(fixture.access.observed_attestation().await.is_none()); + + assert!(support::expected_module_unavailable( + fixture.wallet.derive_account(secret()).await.unwrap_err() + )); + assert!(support::expected_module_unavailable( + fixture + .wallet + .sign_transaction(secret(), transaction()) + .await + .unwrap_err() + )); + assert!(support::expected_module_unavailable( + fixture + .wallet + .sign_message(secret(), &[0x42; 32], Scheme::Secp256k1Prehash) + .await + .unwrap_err() + )); + assert!(support::expected_module_unavailable( + fixture.wallet.export_key(secret()).await.unwrap_err() + )); + assert!(support::expected_module_unavailable( + fixture.wallet.generate_mnemonic(12).await.unwrap_err() + )); + assert!(support::expected_module_unavailable( + fixture + .wallet + .derive_mnemonic_seed(support::VECTOR.to_string()) + .await + .unwrap_err() + )); +} diff --git a/crates/tinywallet-client/tests/support/mod.rs b/crates/tinywallet-client/tests/support/mod.rs new file mode 100644 index 00000000..c8742967 --- /dev/null +++ b/crates/tinywallet-client/tests/support/mod.rs @@ -0,0 +1,358 @@ +//! Local native-module fixtures shared by the explicit client E2E targets. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::OnceLock; + +use tinybus::broker::Broker; +use tinybus::module::{ + AccessFuture, CachedRelease, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, + ModuleHost, ProxyFailure, ReleaseAsset, ReleaseRecord, +}; +use tinybus::transport::memory::{MemoryBus, MemoryTransport}; +use tinybus::{Connection, Proxy}; +use tinywallet_client::WalletClient; +use tokio::sync::OnceCell; + +pub(crate) const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; +const RELEASE_URL: &str = "https://github.com/tinyhumansai/tinywallet/releases/tag/v0.25.1"; + +static RECORD: OnceLock = OnceLock::new(); +static WRONG_LIBRARY_RECORD: OnceLock = OnceLock::new(); + +struct QuietSink; + +impl FailureSink for QuietSink { + fn capture(&self, _: &tinybus::module::FailureReport<'_>) {} +} + +pub(crate) struct Fixture { + pub(crate) access: Arc, + pub(crate) wallet: WalletClient, + pub(crate) wrong_pin_wallet: WalletClient, + _controller: Connection, + broker_task: tokio::task::JoinHandle>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.broker_task.abort(); + } +} + +pub(crate) struct Access { + host: Arc, + connection: Connection, + reporter: FailureReporter, + loaded: Arc>, + record: &'static ReleaseRecord, + release: CachedRelease<'static>, + unattested_module: Option, +} + +pub(crate) struct SharedAccess(Arc); + +impl Access { + fn new( + host: Arc, + connection: Connection, + loaded: Arc>, + record: &'static ReleaseRecord, + release: CachedRelease<'static>, + unattested_module: Option, + ) -> Self { + Self { + host, + connection, + reporter: FailureReporter::new(Arc::new(QuietSink)), + loaded, + record, + release, + unattested_module, + } + } + + pub(crate) async fn unique_peer(&self) -> tinybus::BusName { + self.connection + .name_owner(self.record.bus_name) + .await + .expect("module owner lookup") + .expect("ready module has a unique owner") + } + + pub(crate) async fn attestation(&self) -> tinybus::Attestation { + self.observed_attestation() + .await + .expect("cached release was attested") + } + + pub(crate) async fn observed_attestation(&self) -> Option { + self.connection + .attestation( + tinybus::BusName::new(self.record.bus_name).expect("valid wallet bus name"), + ) + .await + .expect("module attestation lookup") + } +} + +impl ModuleAccess for Access { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + self.loaded + .get_or_try_init(|| async { + let loaded = if let Some(path) = &self.unattested_module { + self.host.load_file(path) + } else { + self.host + .load_github_release_cached(&self.release, serde_json::json!({})) + }; + loaded.map(drop).map_err(|error| { + format!("the local test module could not be admitted: {error}") + }) + }) + .await + .map(drop) + }) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + self.ensure_loaded(id).await?; + self.host + .ensure_ready(self.record.bus_name) + .await + .map_err(|_| "the test module did not reach ready".to_string()) + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +impl ModuleAccess for SharedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.0.find(id) + } + + fn enabled(&self) -> bool { + self.0.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.0.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.ensure_loaded(id) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.prepare_attested(id) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.0.proxy(bus_name, object_path) + } +} + +pub(crate) async fn fixture() -> Fixture { + let admitted_record = record(false); + let release = cached_release(); + let bus = MemoryBus::new(); + let broker = Broker::new_restricted_module_control(); + let broker_task = broker.clone().spawn(bus.clone()); + let controller = attach_module_controller(&broker).await; + let host = Arc::new(ModuleHost::new(broker)); + let connection = Connection::connect(bus.connect().await.expect("memory bus")) + .await + .expect("client connection"); + let loaded = Arc::new(OnceCell::new()); + let access = Arc::new(Access::new( + Arc::clone(&host), + connection.clone(), + Arc::clone(&loaded), + admitted_record, + release.clone(), + None, + )); + access + .ensure_loaded(admitted_record.id) + .await + .expect("the pinned cached test artifact should load without network access"); + host.ensure_ready(admitted_record.bus_name) + .await + .expect("the pinned module should become ready"); + let wrong_access = Access::new(host, connection, loaded, record(true), release, None); + Fixture { + wallet: WalletClient::new(tinybus::module::ModuleClient::new(SharedAccess( + Arc::clone(&access), + ))), + wrong_pin_wallet: WalletClient::new(tinybus::module::ModuleClient::new(wrong_access)), + access, + _controller: controller, + broker_task, + } +} + +pub(crate) async fn unattested_fixture() -> Fixture { + let admitted_record = record(false); + let release = cached_release(); + let bus = MemoryBus::new(); + let broker = Broker::new_restricted_module_control(); + let broker_task = broker.clone().spawn(bus.clone()); + let controller = attach_module_controller(&broker).await; + let host = Arc::new(ModuleHost::new(broker)); + let connection = Connection::connect(bus.connect().await.expect("memory bus")) + .await + .expect("client connection"); + let loaded = Arc::new(OnceCell::new()); + let module_path = unattested_module_path(); + let access = Arc::new(Access::new( + Arc::clone(&host), + connection.clone(), + Arc::clone(&loaded), + admitted_record, + release.clone(), + Some(module_path), + )); + access + .ensure_loaded(admitted_record.id) + .await + .expect("the local unattested module should load without network access"); + host.ensure_ready(admitted_record.bus_name) + .await + .expect("the local module should become ready"); + let wrong_access = Access::new(host, connection, loaded, record(true), release, None); + Fixture { + wallet: WalletClient::new(tinybus::module::ModuleClient::new(SharedAccess( + Arc::clone(&access), + ))), + wrong_pin_wallet: WalletClient::new(tinybus::module::ModuleClient::new(wrong_access)), + access, + _controller: controller, + broker_task, + } +} + +async fn attach_module_controller(broker: &Broker) -> Connection { + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(Arc::new(broker_transport)) + .expect("the fixture controller should be selected once"); + Connection::connect(Box::new(controller_transport)) + .await + .expect("fixture controller should connect") +} + +pub(crate) fn record(wrong_library_pin: bool) -> &'static ReleaseRecord { + let slot = if wrong_library_pin { + &WRONG_LIBRARY_RECORD + } else { + &RECORD + }; + slot.get_or_init(|| { + let host_key = Box::leak( + tinybus::module::platform::host_candidates() + .into_iter() + .next() + .expect("the native test host has a module platform key") + .into_boxed_str(), + ); + let archive = leak_env("TINYWALLET_CLIENT_TEST_ARCHIVE_NAME"); + let archive_sha256 = leak_env("TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256"); + let library_sha256 = if wrong_library_pin { + Box::leak("f".repeat(64).into_boxed_str()) + } else { + leak_env("TINYWALLET_CLIENT_TEST_LIBRARY_SHA256") + }; + let assets = Box::leak( + vec![ReleaseAsset { + host_key, + archive, + sha256: archive_sha256, + library_sha256: Some(library_sha256), + }] + .into_boxed_slice(), + ); + ReleaseRecord { + id: "tinywallet", + bus_name: tinywallet_bus::BUS_NAME, + object_path: tinywallet_bus::OBJECT_PATH, + version: "0.25.1", + release_url: RELEASE_URL, + assets, + } + }) +} + +fn cached_release() -> CachedRelease<'static> { + CachedRelease { + release_url: RELEASE_URL, + asset_name: leak_env("TINYWALLET_CLIENT_TEST_ARCHIVE_NAME"), + expected_sha256: Some(leak_env("TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256")), + expected_library_sha256: Some(leak_env("TINYWALLET_CLIENT_TEST_LIBRARY_SHA256")), + cache_dir: leak_path("TINYWALLET_CLIENT_TEST_CACHE"), + allow_download: false, + } +} + +fn leak_env(name: &str) -> &'static str { + Box::leak( + std::env::var(name) + .unwrap_or_else(|_| { + panic!("{name} must point at the staged local wallet test artifact") + }) + .into_boxed_str(), + ) +} + +fn leak_path(name: &str) -> &'static Path { + Box::leak( + PathBuf::from( + std::env::var(name) + .unwrap_or_else(|_| panic!("{name} must point at the staged local wallet cache")), + ) + .into_boxed_path(), + ) +} + +pub(crate) fn unattested_module_path() -> PathBuf { + PathBuf::from(leak_env("TINYWALLET_CLIENT_UNATTESTED_MODULE")) +} + +pub(crate) fn expected_module_unavailable(error: tinywallet_client::ClientError) -> bool { + matches!( + error, + tinywallet_client::ClientError::Remote(failure) + if failure.kind == ModuleCallError::Unavailable + ) +} diff --git a/crates/tinywallet-module/Cargo.toml b/crates/tinywallet-module/Cargo.toml index 363e7de9..8e01108a 100644 --- a/crates/tinywallet-module/Cargo.toml +++ b/crates/tinywallet-module/Cargo.toml @@ -46,12 +46,12 @@ tinywallet = { path = "../..", default-features = false, features = [ "abi", ] } # TinyBus provides the typed service interface and the dynamic module host ABI. -tinybus = { version = ">=0.3.6, <0.4", git = "https://github.com/tinyhumansai/tinybus", rev = "6ce06bc674e4f4d718aa51e643ef62c1f372e270", default-features = false, features = [ +tinybus = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", rev = "85345114f3066e94d5d70161ea8dc43252b83ae6", default-features = false, features = [ "macros", "modules", ] } # The module-side SDK owns its runtime and exports the stable C entrypoints. -tinybus-module = { version = ">=0.3.6, <0.4", git = "https://github.com/tinyhumansai/tinybus", rev = "6ce06bc674e4f4d718aa51e643ef62c1f372e270" } +tinybus-module = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", rev = "85345114f3066e94d5d70161ea8dc43252b83ae6" } # The module runtime drives asynchronous host callbacks and serializes service # lifecycle changes against in-flight operations. tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } @@ -101,7 +101,7 @@ workspace = true [dev-dependencies] # Testkit-backed module storage fixtures exercise the host's v2 storage ports. -tinystoragedrivers-testkit = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "c447da3" } # Encodes and decodes the Solana transaction fixtures in native tests. base64 = "0.23" # The native lifecycle fixture stages and hashes the actual cdylib to attest it. @@ -110,7 +110,7 @@ sha2 = "0.11" tempfile = "3" # The loader E2E admits the built module from its adjacent digest manifest and # uses TinyBus's shared lifecycle helpers. -tinybus = { version = ">=0.3.6, <0.4", git = "https://github.com/tinyhumansai/tinybus", rev = "6ce06bc674e4f4d718aa51e643ef62c1f372e270", default-features = false, features = [ +tinybus = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", rev = "85345114f3066e94d5d70161ea8dc43252b83ae6", default-features = false, features = [ "macros", "modules", "test-support", diff --git a/crates/tinywallet-module/src/lib_tests.rs b/crates/tinywallet-module/src/lib_tests.rs index 70b95832..a5926790 100644 --- a/crates/tinywallet-module/src/lib_tests.rs +++ b/crates/tinywallet-module/src/lib_tests.rs @@ -4,21 +4,20 @@ use super::*; -use tinystoragedrivers_types::CollectionSpec; - #[test] fn every_stored_item_is_wallet_owned_and_module_scoped() { - let collections = storage_collections(); + let collections = storage_collections() + .into_iter() + .map(|collection| collection.name) + .collect::>(); for collection in &collections { - assert!( - collection.name.starts_with("wallet."), - "{}", - collection.name - ); + assert!(collection.starts_with("wallet."), "{}", collection); } - assert!( - collections - .iter() - .any(|item| *item == CollectionSpec::new("wallet.budgets")) + assert_eq!( + collections, + vec![ + "wallet.budgets".to_string(), + "wallet.migrations".to_string() + ] ); } diff --git a/crates/tinywallet-module/src/service/host_tests.rs b/crates/tinywallet-module/src/service/host_tests.rs index 49fae9e6..08234407 100644 --- a/crates/tinywallet-module/src/service/host_tests.rs +++ b/crates/tinywallet-module/src/service/host_tests.rs @@ -83,3 +83,19 @@ async fn missing_host_is_reported_without_fallback() { assert_ne!(error, ""); broker_task.abort(); } + +#[tokio::test] +async fn missing_storage_host_fails_closed_for_the_payment_ledger() { + let bus = tinybus::transport::memory::MemoryBus::new(); + let broker = tinybus::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let callbacks = HostCallbacks::new(&connection).unwrap(); + + let error = callbacks.ledger_storage().await.unwrap_err(); + + assert!(error.contains("x402 ledger storage is unavailable")); + broker_task.abort(); +} diff --git a/crates/tinywallet-module/src/service/x402.rs b/crates/tinywallet-module/src/service/x402.rs index ea95f47b..233cdaf8 100644 --- a/crates/tinywallet-module/src/service/x402.rs +++ b/crates/tinywallet-module/src/service/x402.rs @@ -119,8 +119,10 @@ impl X402Runtime { /// /// # Errors /// - /// The storage cannot be opened or read. The previous process-wide ledger, - /// if any, is left in place. + /// The storage cannot be opened or read, or the ledger's own migration + /// cannot complete. The previous process-wide ledger, if any, is left in + /// place. The host must ensure the wallet collections in this module's + /// scope before calling this operation. pub(crate) fn new( callbacks: HostCallbacks, config: X402ConfigResponse, diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index 8a88e568..944b3300 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -25,8 +25,6 @@ use std::{ path::{Path, PathBuf}, }; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; use sha2::{Digest, Sha256}; use tinybus::Connection; @@ -45,10 +43,7 @@ use tinywallet_bus::wire::{ NativeWalletPrepareTransferRequest, NativeWalletServiceHandle, NetworkRequest, PrepareTransferParams, RpcEndpointKey, SecretMaterial, SolanaCluster, WalletAccountsResponse, WalletBackendRequest, WalletBackendResponse, WalletChain, Web3CloseRequest, Web3ServiceHandle, - X402CloseRequest, X402ConfigResponse, X402ConfigureRequest, X402DetailedSpendingSummary, - X402ListPaymentsRequest, X402ListPaymentsResponse, X402PaymentStatus, - X402PreparePaymentRequest, X402RecordPaymentRequest, X402ServiceHandle, X402SummaryRequest, - X402UpdateBudgetRequest, + X402ConfigResponse, X402ConfigureRequest, X402ServiceHandle, }; use tinywallet_module::{BUS_NAME, HOST_INTERFACE, HOST_OBJECT_PATH, OBJECT_PATH}; @@ -124,9 +119,9 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { approved_native, native_transport_networks, submitted_tx, - approved_x402, - x402_approved, - x402_gate, + _, + _, + _, modules, broker_task, staged, @@ -135,7 +130,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); let x402_configured = proxy - .call( + .call::( "X402Configure", (X402ConfigureRequest { config: X402ConfigResponse { @@ -147,20 +142,14 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { }, },), ) - .await - .map(|handle: X402ServiceHandle| handle); + .await; // This fixture has no storage host, so x402 must fail closed instead of // silently switching to process-local state. - let x402_service = match x402_configured { - Ok(handle) => Some(handle), - Err(error) => { - assert!( - error.to_string().contains("X402OperationFailed"), - "an unprovisioned artifact must fail x402 closed: {error}" - ); - None - } - }; + let error = x402_configured.expect_err("unprovisioned storage must fail x402 closed"); + assert!( + error.to_string().contains("X402OperationFailed"), + "an unprovisioned artifact must fail x402 closed: {error}" + ); let mut web3_service: Web3ServiceHandle = proxy.call("Web3Configure", ((),)).await.unwrap(); // The new native engine crosses the compiled module ABI, not a copied host @@ -552,405 +541,6 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { .unwrap_err(); assert!(stale_quote.to_string().contains("Web3OperationFailed")); - if let Some(mut x402_service) = x402_service { - let challenge = BASE64.encode( - serde_json::to_vec(&serde_json::json!({ - "x402Version": 2, - "resource": {"url": "https://fixture.test/resource"}, - "accepts": [{ - "scheme": "exact", - "network": "eip155:1", - "amount": "100", - "asset": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "payTo": "0x0000000000000000000000000000000000000002", - "maxTimeoutSeconds": 60, - "extra": {"name": "USD Coin", "version": "2"} - }], - "extensions": {} - })) - .unwrap(), - ); - x402_approved.store(false, Ordering::SeqCst); - let denied = proxy - .call_confidential::( - "X402PreparePayment", - (X402PreparePaymentRequest { - service: x402_service.clone(), - url: "https://fixture.test/resource".to_string(), - payment_required: challenge.clone(), - owner: None, - additional_secrets: Vec::new(), - secret: SecretMaterial { - mnemonic: VECTOR.to_string(), - derivation_path: "m/44'/60'/0'/0/0".to_string(), - chain: Chain::Evm, - }, - },), - ) - .await - .unwrap_err(); - assert!(denied.to_string().contains("X402OperationFailed")); - let denied_summary: serde_json::Value = proxy - .call( - "X402SpendingSummary", - (X402SummaryRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - assert_eq!(denied_summary["reserved_atomic"], 0); - x402_approved.store(true, Ordering::SeqCst); - let mut expensive_challenge: serde_json::Value = - serde_json::from_slice(&BASE64.decode(&challenge).unwrap()).unwrap(); - expensive_challenge["accepts"][0]["amount"] = serde_json::json!("1000001"); - let expensive_challenge = BASE64.encode(serde_json::to_vec(&expensive_challenge).unwrap()); - let too_expensive = proxy - .call_confidential::( - "X402PreparePayment", - (X402PreparePaymentRequest { - service: x402_service.clone(), - url: "https://fixture.test/resource".to_string(), - payment_required: expensive_challenge, - owner: None, - additional_secrets: Vec::new(), - secret: SecretMaterial { - mnemonic: VECTOR.to_string(), - derivation_path: "m/44'/60'/0'/0/0".to_string(), - chain: Chain::Evm, - }, - },), - ) - .await - .unwrap_err(); - assert!(too_expensive.to_string().contains("X402OperationFailed")); - let budget_summary: serde_json::Value = proxy - .call( - "X402SpendingSummary", - (X402SummaryRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - assert_eq!(budget_summary["reserved_atomic"], 0); - assert_eq!( - approved_x402 - .lock() - .unwrap() - .as_ref() - .unwrap() - .amount_atomic, - 100, - "an over-budget payment must be rejected before host authorization" - ); - let payment: serde_json::Value = proxy - .call_confidential( - "X402PreparePayment", - (X402PreparePaymentRequest { - service: x402_service.clone(), - url: "https://fixture.test/resource".to_string(), - payment_required: challenge.clone(), - owner: None, - additional_secrets: Vec::new(), - secret: SecretMaterial { - mnemonic: VECTOR.to_string(), - derivation_path: "m/44'/60'/0'/0/0".to_string(), - chain: Chain::Evm, - }, - },), - ) - .await - .unwrap(); - assert_ne!(payment["payment_header"].as_str().unwrap(), ""); - let approved_payment = approved_x402.lock().unwrap().clone().unwrap(); - assert_eq!(approved_payment.url, "https://fixture.test/resource"); - assert_eq!(approved_payment.amount_atomic, 100); - assert_eq!(approved_payment.network, "eip155:1"); - assert_eq!( - approved_payment.asset, - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - ); - assert_eq!( - approved_payment.recipient, - "0x0000000000000000000000000000000000000002" - ); - - proxy - .call::<()>( - "X402RecordPayment", - (X402RecordPaymentRequest { - service: x402_service.clone(), - payment_id: payment["payment_id"].as_str().unwrap().to_string(), - response_status: 200, - settlement_response: None, - },), - ) - .await - .unwrap(); - let spending: serde_json::Value = proxy - .call( - "X402SpendingSummary", - (X402SummaryRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - assert_eq!(spending["session_total_atomic"], 0); - assert_eq!(spending["reserved_atomic"], 100); - - let history: X402ListPaymentsResponse = proxy - .call( - "X402ListPayments", - (X402ListPaymentsRequest { - service: x402_service.clone(), - limit: 10, - },), - ) - .await - .unwrap(); - assert_eq!(history.payments.len(), 1); - assert_eq!(history.payments[0].id, payment["payment_id"]); - assert_eq!(history.payments[0].status, X402PaymentStatus::Pending); - assert_eq!(history.payments[0].session_id, "module-e2e"); - assert_eq!(history.payments[0].amount_display, "0.000100 USDC"); - - let updated_budget: tinywallet_bus::wire::X402SpendingSummary = proxy - .call( - "X402UpdateBudget", - (X402UpdateBudgetRequest { - service: x402_service.clone(), - per_request_max_atomic: Some(500), - daily_max_atomic: None, - monthly_max_atomic: None, - },), - ) - .await - .unwrap(); - assert_eq!(updated_budget.per_request_max_atomic, 500); - assert_eq!(updated_budget.daily_max_atomic, 10_000_000); - assert_eq!(updated_budget.reserved_atomic, 100); - - let detailed: X402DetailedSpendingSummary = proxy - .call( - "X402SpendingSummaryDetailed", - (X402SummaryRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - assert_eq!(detailed.session_count, 0); - assert_eq!(detailed.reserved_atomic, 100); - - let settlement = BASE64.encode( - serde_json::to_vec(&serde_json::json!({ - "success": true, - "transaction": "0x1111111111111111111111111111111111111111111111111111111111111111", - "network": "eip155:1" - })) - .unwrap(), - ); - proxy - .call::<()>( - "X402RecordPayment", - (X402RecordPaymentRequest { - service: x402_service.clone(), - payment_id: payment["payment_id"].as_str().unwrap().to_string(), - response_status: 200, - settlement_response: Some(settlement), - },), - ) - .await - .unwrap(); - let settled: serde_json::Value = proxy - .call( - "X402SpendingSummary", - (X402SummaryRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - assert_eq!(settled["session_total_atomic"], 100); - assert_eq!(settled["reserved_atomic"], 0); - - // Hold an old prepare after it has reserved its amount and entered the - // host approval callback. Reconfiguration must wait for that call to - // finish. Its exported authorization remains durably budgeted when the - // same workspace is reopened under a new session, while the old service - // handle becomes unavailable. - let old_service = x402_service.clone(); - let pending_service = old_service.clone(); - x402_gate.blocked.store(true, Ordering::SeqCst); - let prepare_proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); - let prepare = tokio::spawn(async move { - prepare_proxy - .call_confidential::( - "X402PreparePayment", - (X402PreparePaymentRequest { - service: pending_service, - url: "https://fixture.test/resource".to_string(), - payment_required: challenge, - owner: None, - additional_secrets: Vec::new(), - secret: SecretMaterial { - mnemonic: VECTOR.to_string(), - derivation_path: "m/44'/60'/0'/0/0".to_string(), - chain: Chain::Evm, - }, - },), - ) - .await - }); - x402_gate.started.notified().await; - let configure_proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); - let staged_workspace = staged.path().join("workspace").display().to_string(); - let configure = tokio::spawn(async move { - configure_proxy - .call::( - "X402Configure", - (X402ConfigureRequest { - config: X402ConfigResponse { - workspace_dir: staged_workspace, - session_id: "module-e2e-switched-user".to_string(), - per_request_max_atomic: 1_000_000, - daily_max_atomic: 10_000_000, - monthly_max_atomic: 100_000_000, - }, - },), - ) - .await - }); - tokio::task::yield_now().await; - x402_gate.resume.notify_one(); - let pending = prepare.await.unwrap().unwrap(); - x402_service = configure.await.unwrap().unwrap(); - x402_gate.blocked.store(false, Ordering::SeqCst); - assert_ne!(x402_service, old_service); - let old_summary = proxy - .call::( - "X402SpendingSummary", - (X402SummaryRequest { - service: old_service.clone(), - },), - ) - .await - .unwrap_err(); - assert!(old_summary.to_string().contains("X402OperationFailed")); - let new_summary: serde_json::Value = proxy - .call( - "X402SpendingSummary", - (X402SummaryRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - assert_eq!(new_summary["reserved_atomic"], 100); - assert_eq!(new_summary["session_total_atomic"], 0); - let stale_record = proxy - .call::<()>( - "X402RecordPayment", - (X402RecordPaymentRequest { - service: old_service, - payment_id: pending["payment_id"].as_str().unwrap().to_string(), - response_status: 200, - settlement_response: None, - },), - ) - .await - .unwrap_err(); - assert!(stale_record.to_string().contains("X402OperationFailed")); - let legacy_address: serde_json::Value = proxy - .call( - "ValidateAddress", - (tinywallet_bus::wire::ValidateAddressRequest { - chain: Chain::Evm, - address: "0x0000000000000000000000000000000000000001".to_string(), - sender: false, - },), - ) - .await - .unwrap(); - assert_eq!( - legacy_address["result"]["Ok"], - "0x0000000000000000000000000000000000000001" - ); - - let solana_challenge = BASE64.encode( - serde_json::to_vec(&serde_json::json!({ - "x402Version": 2, - "resource": {"url": "https://fixture.test/solana-resource"}, - "accepts": [{ - "scheme": "exact", - "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "amount": "100", - "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "payTo": "11111111111111111111111111111111", - "maxTimeoutSeconds": 60, - "extra": { - "feePayer": "EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd", - "memo": "module-chain-secret-fixture" - } - }], - "extensions": {} - })) - .unwrap(), - ); - let selected_payment: serde_json::Value = proxy - .call_confidential( - "X402PreparePayment", - (X402PreparePaymentRequest { - service: x402_service.clone(), - url: "https://fixture.test/solana-resource".to_string(), - payment_required: solana_challenge, - owner: None, - secret: SecretMaterial { - mnemonic: VECTOR.to_string(), - derivation_path: "m/44'/60'/0'/0/0".to_string(), - chain: Chain::Evm, - }, - additional_secrets: vec![SecretMaterial { - mnemonic: VECTOR.to_string(), - derivation_path: "m/44'/501'/0'/0'".to_string(), - chain: Chain::Solana, - }], - },), - ) - .await - .expect("module must select the Solana secret after parsing the challenge"); - assert_eq!( - selected_payment["network"], - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" - ); - assert_eq!( - approved_x402.lock().unwrap().as_ref().unwrap().network, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "the actual parsed chain must reach the normal approval callback" - ); - - proxy - .call::<()>( - "X402Close", - (X402CloseRequest { - service: x402_service.clone(), - },), - ) - .await - .unwrap(); - let closed = proxy - .call::( - "X402SpendingSummary", - (X402SummaryRequest { - service: x402_service, - },), - ) - .await - .unwrap_err(); - assert!(closed.to_string().contains("HostUnavailable")); - } host_bad_response.store(true, Ordering::SeqCst); let error = proxy .call::("Web3Routes", ((),)) diff --git a/crates/tinywallet-module/tests/static_link.rs b/crates/tinywallet-module/tests/static_link.rs index d8575b6f..b5911acc 100644 --- a/crates/tinywallet-module/tests/static_link.rs +++ b/crates/tinywallet-module/tests/static_link.rs @@ -6,14 +6,21 @@ use sha2::{Digest, Sha256}; use tinybus::Connection; use tinybus::broker::Broker; use tinybus::module::{ModuleHost, ModuleState}; -use tinybus::transport::memory::MemoryBus; +use tinybus::transport::memory::{MemoryBus, MemoryTransport}; use tinywallet_module::{BUS_NAME, linked_module}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn linked_module_is_admitted_and_attested_as_the_host_executable() { let bus = MemoryBus::new(); - let broker = Broker::new(); + let broker = Broker::new_restricted_module_control(); let broker_task = broker.spawn(bus.clone()); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(std::sync::Arc::new(broker_transport)) + .unwrap(); + let _controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); let host = ModuleHost::new(broker); let module = linked_module().expect("linked manifest is valid"); diff --git a/crates/tinywallet-x402/Cargo.toml b/crates/tinywallet-x402/Cargo.toml index 54b42fac..7a082f18 100644 --- a/crates/tinywallet-x402/Cargo.toml +++ b/crates/tinywallet-x402/Cargo.toml @@ -77,6 +77,7 @@ tokio = { version = "1", default-features = false, features = ["rt-multi-thread" [dev-dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" # Drives the async tests; the crate itself pulls in no runtime. tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "net", "io-util", "sync"] } # A loopback HTTP server so the 402 client and the tool are exercised against a @@ -94,7 +95,7 @@ tinywallet = { path = "../..", default-features = false, features = ["key", "sol k256 = { version = "0.14", default-features = false, features = ["ecdsa"] } ed25519-dalek = { version = "2", default-features = false, features = ["std"] } # Native ledger migration tests use an in-memory v2 storage implementation. -tinystoragedrivers-testkit = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "c447da3" } [features] default = ["wire"] diff --git a/crates/tinywallet-x402/README.md b/crates/tinywallet-x402/README.md index b83b3afe..6d5afa72 100644 --- a/crates/tinywallet-x402/README.md +++ b/crates/tinywallet-x402/README.md @@ -41,6 +41,12 @@ configuration of its own. ## Payment safety +The module declares its storage requirements through +`tinywallet_bus::storage::collections()`. The host provisions those collection +specs in the caller's authenticated scope and serves a scoped `Storage` handle +to the module over the TSD v2 bus. The ledger owns its schema and legacy data +migration; it opens only after receiving that host-provisioned storage handle. + - **Budget reservation.** `handle_402_and_pay` checks the daily and monthly caps and holds the amount (`ledger::reserve`) in one critical section *before* signing, so concurrent payments cannot together overspend. The hold is a @@ -84,8 +90,9 @@ configuration of its own. ## Constraints - Not `bitcoin`, `k256` or `coins-*` in the normal dependency graph (CI guard). -- The ledger owns one process-wide handle; hosts choose the budget when they - call `ledger::init_global`. +- The ledger owns one process-wide handle. Both direct callers and the module + initialize it with `ledger::init_global`; the module passes the scoped + storage handle provided by its host. - `tools` needs a compiler new enough for `tinytools` (1.88), and `ledger` for the stable `tinystoragedrivers-api` / `tinystoragedrivers-types` contracts (1.88), matching this crate's declared minimum. The other features build on diff --git a/crates/tinywallet-x402/src/ledger/global.rs b/crates/tinywallet-x402/src/ledger/global.rs index 6fe933c1..0e311528 100644 --- a/crates/tinywallet-x402/src/ledger/global.rs +++ b/crates/tinywallet-x402/src/ledger/global.rs @@ -13,32 +13,6 @@ const NOT_INITIALISED: &str = "x402 payment ledger not initialized"; static GLOBAL_LEDGER: Mutex> = Mutex::new(None); -/// Storage a module-style host provisioned for later [`init_global`] calls that -/// do not carry their own handles (the `TinyBus` module configures the ledger -/// over a wire request that can only name a session and a budget). -static PROVISIONED: Mutex> = Mutex::new(None); - -/// Provision the storage the next module-driven ledger will persist through, -/// replacing any earlier one. -pub fn provision_storage(storage: LedgerStorage) { - *PROVISIONED.lock().unwrap_or_else(PoisonError::into_inner) = Some(storage); - debug!("{LOG_PREFIX} ledger storage provisioned"); -} - -/// The provisioned storage, if the host has provided one. -#[must_use] -pub fn provisioned_storage() -> Option { - PROVISIONED - .lock() - .unwrap_or_else(PoisonError::into_inner) - .clone() -} - -/// Forget the provisioned storage. -pub fn clear_provisioned_storage() { - *PROVISIONED.lock().unwrap_or_else(PoisonError::into_inner) = None; -} - /// Run `f`, which blocks on storage (and on the ledger mutex), without holding /// an async worker hostage. /// diff --git a/crates/tinywallet-x402/src/ledger/global_tests.rs b/crates/tinywallet-x402/src/ledger/global_tests.rs index 1f688dd3..429e406c 100644 --- a/crates/tinywallet-x402/src/ledger/global_tests.rs +++ b/crates/tinywallet-x402/src/ledger/global_tests.rs @@ -39,11 +39,3 @@ async fn the_global_ledger_lifecycle_runs_on_a_multi_thread_runtime() { clear_global(); assert!(with_ledger(|_| ()).is_err()); } - -#[test] -fn provisioned_storage_can_be_set_and_cleared() { - provision_storage(LedgerStorage::memory()); - assert!(provisioned_storage().is_some()); - clear_provisioned_storage(); - assert!(provisioned_storage().is_none()); -} diff --git a/crates/tinywallet-x402/src/ledger/mod.rs b/crates/tinywallet-x402/src/ledger/mod.rs index 94f3d160..1d283734 100644 --- a/crates/tinywallet-x402/src/ledger/mod.rs +++ b/crates/tinywallet-x402/src/ledger/mod.rs @@ -70,10 +70,7 @@ mod types; #[cfg(test)] pub(crate) use global::{TEST_LOCK, reset_global}; -pub use global::{ - clear_global, clear_provisioned_storage, init_global, provision_storage, provisioned_storage, - with_ledger, with_ledger_mut, -}; +pub use global::{clear_global, init_global, with_ledger, with_ledger_mut}; pub use persist::{HOLD_RENEW_EVERY, LedgerStorage}; pub use reservation::{Reservation, reserve}; pub use schema::collections; diff --git a/crates/tinywallet-x402/src/ledger/schema.rs b/crates/tinywallet-x402/src/ledger/schema.rs index be53ddd9..4888220d 100644 --- a/crates/tinywallet-x402/src/ledger/schema.rs +++ b/crates/tinywallet-x402/src/ledger/schema.rs @@ -87,7 +87,8 @@ async fn migrate_budgets(documents: &dyn DocumentStore) -> tinystoragedrivers_ty let Some(legacy) = documents.get(LEGACY_BUDGET_COLLECTION, HOLDS_ID).await? else { return Ok(()); }; - let holds = renamed_holds(&legacy.doc).unwrap_or_default(); + let holds = renamed_holds(&legacy.doc) + .ok_or_else(|| StorageError::invalid_input("legacy budget holds document is malformed"))?; let migrated = json!({ "holds": holds.clone() }); match documents.get(BUDGETS_COLLECTION, HOLDS_ID).await? { Some(existing) if budget_target_matches(&existing, &migrated, legacy.meta.expires_ms) => { diff --git a/crates/tinywallet-x402/src/ledger/schema_tests.rs b/crates/tinywallet-x402/src/ledger/schema_tests.rs index a6de97f0..1b5048f6 100644 --- a/crates/tinywallet-x402/src/ledger/schema_tests.rs +++ b/crates/tinywallet-x402/src/ledger/schema_tests.rs @@ -390,7 +390,15 @@ fn host_declarations_are_pure_wallet_collection_specs() { #[test] fn ledger_and_bus_declare_the_same_collections() { - assert_eq!(collections(), tinywallet_bus::storage::collections()); + let bus_names = tinywallet_bus::storage::collections() + .into_iter() + .map(|collection| collection.name) + .collect::>(); + let ledger_names = collections() + .into_iter() + .map(|collection| collection.name) + .collect::>(); + assert_eq!(ledger_names, bus_names); } #[test] @@ -792,6 +800,48 @@ fn invalid_legacy_payment_rows_are_skipped_without_changing_the_stream_name_cont assert!(super::renamed_holds(&json!({ "holds": [] })).is_none()); } +#[test] +fn malformed_legacy_budget_holds_abort_migration_without_marking_it_complete() { + let storage = test_storage(); + let documents = Arc::clone(storage.documents()); + Blocking::new() + .unwrap() + .run(async move { + documents + .put( + "x402_budget", + HOLDS_ID, + json!({ "holds": { "reserved": { "amount": "12", "expires_at_ms": 1_800_000_000_000_i64 } } }), + Precondition::Absent, + ) + .await + }) + .unwrap() + .unwrap(); + + let documents = Arc::clone(storage.documents()); + let streams = Arc::clone(storage.streams()); + let result = Blocking::new() + .unwrap() + .run(async move { super::migrate(documents.as_ref(), streams.as_ref()).await }) + .unwrap(); + + assert!(result.is_err(), "malformed holds must stop migration"); + let documents = Arc::clone(storage.documents()); + let (target, marker) = Blocking::new() + .unwrap() + .run(async move { + Ok::<_, tinystoragedrivers_types::StorageError>(( + documents.get(BUDGETS_COLLECTION, HOLDS_ID).await?, + documents.get("wallet.migrations", "x402").await?, + )) + }) + .unwrap() + .unwrap(); + assert!(target.is_none(), "invalid reservations must not be dropped"); + assert!(marker.is_none(), "a failed migration must remain retryable"); +} + #[test] fn owner_migration_advances_a_versioned_marker_and_skips_current_versions() { let storage = test_storage(); diff --git a/crates/tinywallet-x402/src/ledger/store.rs b/crates/tinywallet-x402/src/ledger/store.rs index ff5a7e60..f556227c 100644 --- a/crates/tinywallet-x402/src/ledger/store.rs +++ b/crates/tinywallet-x402/src/ledger/store.rs @@ -70,7 +70,14 @@ impl PaymentLedger { session_id: &str, budget: SpendingBudget, ) -> Result { - let persist = Persist::open(storage)?; + Self::from_persist(Persist::open(storage)?, session_id, budget) + } + + fn from_persist( + persist: Persist, + session_id: &str, + budget: SpendingBudget, + ) -> Result { let (records, next_offset) = persist.read_from(0)?; debug!( "{LOG_PREFIX} loaded {} existing payment records", diff --git a/deny.toml b/deny.toml index d7b14c3d..20bc816a 100644 --- a/deny.toml +++ b/deny.toml @@ -56,5 +56,6 @@ allow-registry = ["https://github.com/rust-lang/crates.io-index"] # URL to its own copy. Nothing else may come from git. allow-git = [ "https://github.com/tinyhumansai/tinytools", - "https://github.com/tinyhumansai/tinystoragedrivers", + "https://github.com/senamakel/tinystoragedrivers", + "https://github.com/senamakel/tinybus", ] diff --git a/docs/specs/host-wallet-contract-extraction.md b/docs/specs/host-wallet-contract-extraction.md new file mode 100644 index 00000000..b4c72364 --- /dev/null +++ b/docs/specs/host-wallet-contract-extraction.md @@ -0,0 +1,102 @@ +# Host-owned wallet contract data and intent matching + +## Purpose and boundary + +The `tinywallet-bus` crate owns reusable, pure contract facts shared by TinyWallet +and its hosts. It may expose the wallet's typed storage collection declarations, map a +chain selection to an RPC endpoint key, and compare a prepared quote with the +transaction intent supplied for execution. + +The bus remains data-only: it does not open storage, resolve an endpoint URL, +send an RPC request, execute a transaction, retain an approval permit, or make +an authorization decision. OpenHuman keeps permit ownership, approval policy, +execution and provider callbacks. The wallet implementation keeps ledger +persistence and quote construction. This extraction moves only immutable +declarations and deterministic comparisons; it does not change their +behavior. + +## Storage requirements + +The bus publishes the x402 ledger's host-facing +`storage::collections() -> Vec` using a bus-owned, +serde-compatible name-only type. It names `wallet.budgets` and +`wallet.migrations`; storage indexes, expiry and search policy remain with the +host. The host maps those names to its storage API, ensures the collections in +the caller's authenticated scope, and serves a scoped v2 `Storage` handle to +the module over TinyBus. The x402 implementation owns the payments stream +declaration and performs its schema and legacy-data migration through the v2 +document and stream ports. The bus itself does not create or access storage. + +## Endpoint key mapping + +`chain::endpoint_key` is a pure mapping from the shared `Chain` enum and an +optional EVM chain ID to `RpcEndpointKey`: + +- Bitcoin, Solana, and Tron map to their matching endpoint keys, regardless of + the optional EVM chain ID. +- EVM maps only when the chain ID matches a member of + `EvmNetwork::ALL`; an absent or unknown ID returns `None`. +- Any future or unsupported `Chain` variant returns `None`. + +The function returns a key only. Endpoint URLs, overrides, credentials, +transport and failure handling stay in the host. + +## Native intent matching + +`intent::native_intent_matches_quote` compares a prepared transaction with a +native-wallet execution intent, without consuming permission or invoking the +wallet. It preserves the existing host comparison rules: + +- Bitcoin and Solana transfers require the matching quote chain and + `NativeTransfer` kind, exact sender and recipient strings, and an amount that + parses as `u64` and equals the transaction amount. +- An EVM intent requires the quote's EVM chain ID. A missing quote network uses + Ethereum mainnet, matching existing behavior. Native transfers require the + exact destination and decimal value and empty data. Token transfers require + a present token contract, case-insensitive contract equality, zero native + value, and ERC-20 `transfer(address,uint256)` calldata whose selector, + recipient and 256-bit decimal amount match. Invalid addresses, non-decimal or + overflowing amounts, and any other calldata return `false`. +- A Tron native transfer requires the matching chain, exact recipient and + equal parsed sun amount. A TRC-20 transfer requires the matching chain, + exact recipient and non-empty, even-length hexadecimal parameter bytes. +- A Solana message intent matches only a Solana token-transfer quote and only + when its message is non-empty, even-length hexadecimal data. +- Unsupported transaction kinds and mismatched quote kinds return `false`. + +## Web3 intent matching + +`intent::web3_approval_matches_request` checks the request against a previously +constructed quote. It is a comparison only; OpenHuman continues to consume +the quote-bound permit and decide whether execution is authorized. + +- Quote ID must match exactly, and operation must correspond exactly to quote + kind: `swap`, `bridge`, or `dapp_call`. +- EVM execution requires an EVM signing chain, a known network name and chain + ID pair, and agreement with a quote `network` string when it is a string. + Missing, null, or non-string quote network values are treated as absent. +- Dapp calls compare quote `contractAddress` case-insensitively, `calldata` + byte-for-byte, and `valueRaw` byte-for-byte against the request. +- Swaps and bridges compare `tx.to` case-insensitively, `tx.data` exactly, and + `tx.value` when represented as either a JSON string or number. For `tx.data`, + absent or non-string quote data matches only an absent request data value; + other JSON types do not match a string request value. Other value JSON types + never match. +- Solana execution matches only swap or bridge quotes and requires an exact + match with `tx.data`. +- Missing required quote fields, wrong signing chain, quote kind, operation, + network, recipient, calldata or value return `false`; optional network and + swap/bridge data follow the absence rules above. Nonce and fee fields are + intentionally outside this quote-intent comparison, as in the existing host + implementation. + +## Compatibility and regression coverage + +The extracted functions preserve the host's existing comparison matrix. Tests +cover successful matches and rejected changes to chain, quote kind, operation, +recipient, amount, token contract, calldata selector and payload, missing or +wrongly typed quote fields, malformed hexadecimal, invalid decimal values, +and decimal overflow. Storage declaration tests compare the exported +requirements with the names consumed by the ledger stores. Approval-permit +consumption and authorization tests remain with the host that owns those +behaviors. diff --git a/proof-workspaces/storage-contract/Cargo.lock b/proof-workspaces/storage-contract/Cargo.lock new file mode 100644 index 00000000..ddc442b8 --- /dev/null +++ b/proof-workspaces/storage-contract/Cargo.lock @@ -0,0 +1,135 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinywallet-bus" +version = "0.27.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "tinywallet-storage-contract-consumer" +version = "0.0.0" +dependencies = [ + "tinywallet-bus", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/proof-workspaces/storage-contract/Cargo.toml b/proof-workspaces/storage-contract/Cargo.toml new file mode 100644 index 00000000..1f2a07f1 --- /dev/null +++ b/proof-workspaces/storage-contract/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tinywallet-storage-contract-consumer" +version = "0.0.0" +edition = "2024" +rust-version = "1.88" +publish = false + +[workspace] +resolver = "3" + +[dependencies] +tinywallet-bus = { path = "../../crates/tinywallet-bus" } diff --git a/proof-workspaces/storage-contract/README.md b/proof-workspaces/storage-contract/README.md new file mode 100644 index 00000000..2d648ae7 --- /dev/null +++ b/proof-workspaces/storage-contract/README.md @@ -0,0 +1,7 @@ +# Storage collection consumer proof + +This directory is excluded from the TinyWallet workspace and has its own lock +file. It compiles the public `tinywallet_bus::storage::collections()` API from +a downstream crate using the bus-owned collection declaration, without a +TinyStorageDrivers dependency. The CI job runs this consumer with Rust 1.88 +and `--locked`. diff --git a/proof-workspaces/storage-contract/tests/collection_specs.rs b/proof-workspaces/storage-contract/tests/collection_specs.rs new file mode 100644 index 00000000..f9842054 --- /dev/null +++ b/proof-workspaces/storage-contract/tests/collection_specs.rs @@ -0,0 +1,10 @@ +//! An excluded downstream consumer of the bus-owned collection contract. + +#[test] +fn declarations_name_the_wallet_ledger_collections_without_storage_dependencies() { + let collections = tinywallet_bus::storage::collections(); + + assert_eq!(collections.len(), 2); + assert_eq!(collections[0].name, "wallet.budgets"); + assert_eq!(collections[1].name, "wallet.migrations"); +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index e85071cd..bcb78067 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" report="target/coverage.json" -cargo llvm-cov --workspace --all-features --all-targets --json --output-path "$report" +cargo llvm-cov --workspace --exclude tinywallet-client --all-features --all-targets --json --output-path "$report" below="$(jq -r --arg root "$PWD" ' .data[0].files[] | select(.filename | contains("/src/")) diff --git a/scripts/test-e2e.ps1 b/scripts/test-e2e.ps1 index f94f9dd6..e62fb307 100644 --- a/scripts/test-e2e.ps1 +++ b/scripts/test-e2e.ps1 @@ -1,3 +1,5 @@ +param([switch]$PrepareOnly) + $ErrorActionPreference = 'Stop' $root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $root @@ -6,6 +8,28 @@ New-Item -ItemType Directory -Force $target | Out-Null $target = (Resolve-Path -LiteralPath $target).Path $rootPrefix = $root.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $target.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'CARGO_TARGET_DIR must stay inside the repository' } +$nativeTestLogs = Join-Path $target 'tinywallet-native-e2e-logs' +Remove-Item -LiteralPath $nativeTestLogs -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $nativeTestLogs | Out-Null + +function Invoke-CountedNativeTest { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][int]$ExpectedCount, + [Parameter(Mandatory)][string[]]$Arguments + ) + + $logPath = Join-Path $nativeTestLogs "$Name.log" + & cargo test @Arguments 2>&1 | Tee-Object -FilePath $logPath + if ($LASTEXITCODE -ne 0) { throw "TinyWallet native E2E target failed: $Name" } + $summaries = @(Select-String -Path $logPath -Pattern '^test result: ok\. (\d+) passed;') + if ($summaries.Count -eq 0) { throw "TinyWallet native E2E target reported no test result: $Name" } + $actualCount = [int]$summaries[-1].Matches[0].Groups[1].Value + if ($actualCount -ne $ExpectedCount) { + throw "TinyWallet native E2E target $Name ran $actualCount tests; expected $ExpectedCount" + } +} + $stage = Join-Path $target 'tinywallet-module-e2e' cargo build --locked --release --package tinywallet-module @@ -31,7 +55,64 @@ $hash = (Get-FileHash -LiteralPath $library -Algorithm SHA256).Hash.ToLowerInvar '"tinywallet_module.dll" = "{0}"' -f $hash | Set-Content -Path (Join-Path $stage 'modules.toml') -Encoding utf8NoBOM $env:TINYWALLET_TEST_MODULE = $library -cargo test --locked --release --package tinywallet-module --test module_e2e -- --ignored -if ($LASTEXITCODE -ne 0) { throw 'TinyWallet module E2E failed' } -cargo test --locked --release --package tinywallet-module --test lifecycle_e2e -- --ignored -if ($LASTEXITCODE -ne 0) { throw 'TinyWallet lifecycle E2E failed' } +if (-not $PrepareOnly) { + Invoke-CountedNativeTest -Name 'module_e2e' -ExpectedCount 2 -Arguments @( + '--locked', '--release', '--package', 'tinywallet-module', '--test', 'module_e2e', '--', '--ignored' + ) + Invoke-CountedNativeTest -Name 'lifecycle_e2e' -ExpectedCount 1 -Arguments @( + '--locked', '--release', '--package', 'tinywallet-module', '--test', 'lifecycle_e2e', '--', '--ignored' + ) +} + +# Build a local archive/cache from the same release cdylib. The Rust client +# fixture uses CachedRelease with downloads disabled and verifies both hashes. +$clientCache = Join-Path $target 'tinywallet-client-e2e-cache' +$unattestedDir = Join-Path $target 'tinywallet-client-unattested' +$archiveName = 'tinywallet-module-client-test.tar.gz' +$archivePath = Join-Path $target $archiveName +Remove-Item -LiteralPath $clientCache, $unattestedDir, $archivePath -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $clientCache, $unattestedDir | Out-Null +foreach ($privateDir in @($clientCache, $unattestedDir)) { + icacls $privateDir /reset | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not reset module test directory permissions: $privateDir" } + icacls $privateDir /inheritance:r /grant:r $runnerSid $adminSid $systemSid | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not restrict module test directory permissions: $privateDir" } + icacls $privateDir /setowner $identity.Name | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not set module test directory owner: $privateDir" } +} +$library = Join-Path $clientCache 'tinywallet_module.dll' +Copy-Item -LiteralPath (Join-Path $stage 'tinywallet_module.dll') -Destination $library +Copy-Item -LiteralPath (Join-Path $stage 'modules.toml') -Destination (Join-Path $clientCache 'modules.toml') +Push-Location $target +try { + & tar.exe -czf $archiveName -C 'tinywallet-client-e2e-cache' . + $archiveExitCode = $LASTEXITCODE +} finally { + Pop-Location +} +if ($archiveExitCode -ne 0) { throw 'Could not archive the local TinyWallet test module' } +Copy-Item -LiteralPath $archivePath -Destination (Join-Path $clientCache $archiveName) +$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() +$libraryHash = (Get-FileHash -LiteralPath $library -Algorithm SHA256).Hash.ToLowerInvariant() +$unattestedModule = Join-Path $unattestedDir 'tinywallet_module.dll' +Copy-Item -LiteralPath (Join-Path $stage 'tinywallet_module.dll') -Destination $unattestedModule + +$env:TINYWALLET_CLIENT_TEST_CACHE = $clientCache +$env:TINYWALLET_CLIENT_TEST_ARCHIVE_NAME = $archiveName +$env:TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256 = $archiveHash +$env:TINYWALLET_CLIENT_TEST_LIBRARY_SHA256 = $libraryHash +$env:TINYWALLET_CLIENT_UNATTESTED_MODULE = $unattestedModule +if ($PrepareOnly) { return } + +Invoke-CountedNativeTest -Name 'native_e2e' -ExpectedCount 3 -Arguments @( + '--locked', '--release', '--package', 'tinywallet-client', + '--features', 'native-e2e', '--test', 'native_e2e', '--', '--test-threads=1' +) +Invoke-CountedNativeTest -Name 'attested_errors' -ExpectedCount 1 -Arguments @( + '--locked', '--release', '--package', 'tinywallet-client', + '--features', 'native-e2e', '--test', 'attested_errors', '--', '--test-threads=1' +) +Invoke-CountedNativeTest -Name 'native_unattested' -ExpectedCount 1 -Arguments @( + '--locked', '--release', '--package', 'tinywallet-client', + '--features', 'native-e2e', '--test', 'native_unattested', '--', '--test-threads=1' +) diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index c1cceb7d..07a64a3e 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -35,3 +35,52 @@ TINYWALLET_TEST_MODULE="$stage/$name" cargo test --locked --release \ --package tinywallet-module --test module_e2e -- --ignored TINYWALLET_TEST_MODULE="$stage/$name" cargo test --locked --release \ --package tinywallet-module --test lifecycle_e2e -- --ignored + +# The client integration tests use the same release module through its +# hash-verified local cache. Keep that artifact separate from the loader's +# ordinary module directory, then provide distinct attested and unattested +# library copies for the rejection cases. +client_cache="$target/tinywallet-client-e2e-cache" +unattested_dir="$target/tinywallet-client-unattested" +archive_name="tinywallet-module-client-test.tar.gz" +archive_path="$target/$archive_name" +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} +rm -rf "$client_cache" "$unattested_dir" "$archive_path" +mkdir -m 700 "$client_cache" "$unattested_dir" +install -m 755 "$stage/$name" "$client_cache/$name" +install -m 644 "$stage/modules.toml" "$client_cache/modules.toml" +install -m 755 "$stage/$name" "$unattested_dir/$name" +( + cd "$target" + tar -czf "$archive_name" -C "$(basename "$client_cache")" . +) +archive_hash="$(sha256 "$archive_path")" +library_hash="$(sha256 "$stage/$name")" +install -m 644 "$archive_path" "$client_cache/$archive_name" +TINYWALLET_CLIENT_TEST_CACHE="$client_cache" \ +TINYWALLET_CLIENT_TEST_ARCHIVE_NAME="$archive_name" \ +TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256="$archive_hash" \ +TINYWALLET_CLIENT_TEST_LIBRARY_SHA256="$library_hash" \ +TINYWALLET_CLIENT_UNATTESTED_MODULE="$unattested_dir/$name" \ + cargo test --locked --release --package tinywallet-client \ + --features native-e2e --test native_e2e -- --test-threads=1 +TINYWALLET_CLIENT_TEST_CACHE="$client_cache" \ +TINYWALLET_CLIENT_TEST_ARCHIVE_NAME="$archive_name" \ +TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256="$archive_hash" \ +TINYWALLET_CLIENT_TEST_LIBRARY_SHA256="$library_hash" \ +TINYWALLET_CLIENT_UNATTESTED_MODULE="$unattested_dir/$name" \ + cargo test --locked --release --package tinywallet-client \ + --features native-e2e --test attested_errors -- --test-threads=1 +TINYWALLET_CLIENT_TEST_CACHE="$client_cache" \ +TINYWALLET_CLIENT_TEST_ARCHIVE_NAME="$archive_name" \ +TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256="$archive_hash" \ +TINYWALLET_CLIENT_TEST_LIBRARY_SHA256="$library_hash" \ +TINYWALLET_CLIENT_UNATTESTED_MODULE="$unattested_dir/$name" \ + cargo test --locked --release --package tinywallet-client \ + --features native-e2e --test native_unattested -- --test-threads=1 diff --git a/scripts/test-features.ps1 b/scripts/test-features.ps1 new file mode 100644 index 00000000..9f515c5a --- /dev/null +++ b/scripts/test-features.ps1 @@ -0,0 +1,12 @@ +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $root + +cargo test --locked --workspace --all-features --exclude tinywallet-client +if ($LASTEXITCODE -ne 0) { throw 'TinyWallet all-feature workspace tests failed' } + +cargo test --locked --workspace --exclude tinywallet-client +if ($LASTEXITCODE -ne 0) { throw 'TinyWallet default workspace tests failed' } + +cargo test --locked -p tinywallet-client --lib --all-features +if ($LASTEXITCODE -ne 0) { throw 'TinyWallet all-feature client library tests failed' } diff --git a/scripts/test-features.sh b/scripts/test-features.sh index f1fc7139..e3d018f9 100644 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -2,5 +2,13 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root" -cargo test --locked --workspace -cargo test --locked --workspace --all-features +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + pwsh -NoProfile -File scripts/test-features.ps1 + exit +fi +# The client's native integration tests need the staged module artifact from +# test-e2e.sh. Run its library tests here, and leave the artifact-backed tests +# to that dedicated lane. +cargo test --locked --workspace --all-features --exclude tinywallet-client +cargo test --locked --workspace --exclude tinywallet-client +cargo test --locked -p tinywallet-client --lib --all-features diff --git a/scripts/test_native_e2e_topology.py b/scripts/test_native_e2e_topology.py new file mode 100644 index 00000000..acc7c7f7 --- /dev/null +++ b/scripts/test_native_e2e_topology.py @@ -0,0 +1,184 @@ +"""Guard native fixture staging, test cardinality, and client coverage scope.""" + +from pathlib import Path +import json +import re +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +EXPECTED_COUNTS = { + "module_e2e": 2, + "lifecycle_e2e": 1, +} +MODULE_TESTS = { + "module_e2e.rs": ( + "refuses_a_confidential_call_to_an_unattested_module", + "the_built_module_signs_every_chain_over_a_real_broker", + ), + "lifecycle_e2e.rs": ("the_built_module_signs_every_chain_over_a_real_broker",), +} + + +def _job_block(workflow, job_name): + match = re.search( + rf"(?ms)^ {re.escape(job_name)}:\n(.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", + workflow, + ) + return match.group(1) if match else "" + + +def _msrv_contract_issues(workflow): + issues = [] + matrix_start = workflow.find("msrv-build-targets: |\n") + matrix_end = workflow.find(" module-e2e-script:", matrix_start) + if matrix_start < 0 or matrix_end < 0: + return ["shared MSRV package matrix is missing"] + targets = json.loads( + workflow[matrix_start:].split("|\n", 1)[1].split( + " module-e2e-script:", 1 + )[0] + ) + + bus_manifest = tomllib.loads((ROOT / "crates/tinywallet-bus/Cargo.toml").read_text()) + module_manifest = tomllib.loads((ROOT / "crates/tinywallet-module/Cargo.toml").read_text()) + bus_floor = bus_manifest["package"]["rust-version"] + module_floor = module_manifest["package"]["rust-version"] + if bus_floor != "1.88": + issues.append("tinywallet-bus floor must match tinystoragedrivers-types Rust 1.88") + if module_floor != "1.88": + issues.append("tinywallet-module floor must match its Rust 1.88 runtime dependencies") + + bus_wire = any( + target.get("packages") == ["tinywallet-bus"] + and target.get("features") == ["wire"] + and not target.get("all_features", False) + for target in targets + ) + if not bus_wire: + issues.append("shared MSRV matrix must build the bus wire feature at Rust 1.88") + bus_all_features = any( + target.get("packages") == ["tinywallet-bus"] + and target.get("all_targets", False) + and target.get("all_features", False) + for target in targets + ) + if not bus_all_features: + issues.append("shared MSRV matrix must build bus storage vocabulary all-features at Rust 1.88") + module_matrix = any( + target.get("packages") == ["tinywallet-module"] + and target.get("all_targets", False) + and target.get("all_features", False) + for target in targets + ) + if not module_matrix: + issues.append("shared MSRV matrix must build all module targets/features at its Rust 1.88 floor") + + ledger_job = _job_block(workflow, "wallet-ledger-msrv") + if "toolchain: '1.88.0'" not in ledger_job: + issues.append("x402 ledger feature job must use Rust 1.88.0") + if "cargo test --locked -p tinywallet-x402 --features ledger" not in ledger_job: + issues.append("Rust 1.88 ledger job must test the x402 ledger consumer") + if "tinywallet-bus --all-targets --all-features" in ledger_job: + issues.append("bus all-features must stay in the bus package's Rust 1.88 matrix target") + + proof_job = _job_block(workflow, "wallet-storage-contract") + if "toolchain: '1.88.0'" not in proof_job or ( + "cargo test --locked --manifest-path proof-workspaces/storage-contract/Cargo.toml" + not in proof_job + ): + issues.append("storage collection contract proof must be tested on Rust 1.88") + + bus_docs = (ROOT / "crates/tinywallet-bus/README.md").read_text() + bus_manifest_text = (ROOT / "crates/tinywallet-bus/Cargo.toml").read_text() + x402_manifest_text = (ROOT / "crates/tinywallet-x402/Cargo.toml").read_text() + if "Rust 1.88" not in bus_docs or "tinystoragedrivers-types" in bus_manifest_text: + issues.append("bus docs and manifest must keep portable collection declarations free of storage-driver dependencies at Rust 1.88") + if not all(word in x402_manifest_text for word in ("1.88", "ledger", "pay", "tools")): + issues.append("x402 manifest must document the Rust 1.88 ledger/pay/tools feature floor") + return issues + + +class NativeE2ETopologyTests(unittest.TestCase): + def test_msrv_matrix_tracks_the_tsd_v2_bus_and_module_floors(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + self.assertEqual(_msrv_contract_issues(workflow), []) + + def test_msrv_contract_requires_bus_storage_vocabulary_allfeatures_at_its_1_88_floor(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + bus_all_features = '{"packages":["tinywallet-bus"],"all_targets":true,"all_features":true},' + self.assertIn(bus_all_features, workflow) + mutated = workflow.replace(bus_all_features, "", 1) + self.assertIn( + "shared MSRV matrix must build bus storage vocabulary all-features at Rust 1.88", + _msrv_contract_issues(mutated), + ) + + def test_msrv_contract_rejects_lowering_the_x402_ledger_job_toolchain(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + ledger_job = _job_block(workflow, "wallet-ledger-msrv") + self.assertTrue(ledger_job) + mutated_job = ledger_job.replace("toolchain: '1.88.0'", "toolchain: '1.85.1'", 1) + self.assertNotEqual(ledger_job, mutated_job) + mutated = workflow.replace(ledger_job, mutated_job, 1) + self.assertIn( + "x402 ledger feature job must use Rust 1.88.0", + _msrv_contract_issues(mutated), + ) + + def test_module_e2e_cases_are_present_and_run_against_the_built_artifact(self): + for filename, names in MODULE_TESTS.items(): + source = (ROOT / "crates/tinywallet-module/tests" / filename).read_text() + for name in names: + self.assertRegex(source, rf"(?m)^async fn {re.escape(name)}\(") + + shell = (ROOT / "scripts/test-e2e.sh").read_text() + self.assertEqual(shell.count("--test module_e2e"), 1) + self.assertEqual(shell.count("--test lifecycle_e2e"), 1) + self.assertIn("TINYWALLET_TEST_MODULE=", shell) + self.assertIn("--package tinywallet-client", shell) + for target in ("native_e2e", "attested_errors", "native_unattested"): + self.assertIn(f"--test {target}", shell) + self.assertIn("TINYWALLET_CLIENT_TEST_ARCHIVE_SHA256=", shell) + + ci = (ROOT / ".github/workflows/ci.yml").read_text() + self.assertIn("module-e2e-script: scripts/test-e2e.sh", ci) + + def test_feature_and_coverage_lanes_use_current_workspace_targets(self): + feature_shell = (ROOT / "scripts/test-features.sh").read_text() + self.assertIn("cargo test --locked --workspace", feature_shell) + self.assertNotIn("--prepare-only", feature_shell) + self.assertIn("--exclude tinywallet-client", feature_shell) + self.assertIn("-p tinywallet-client --lib --all-features", feature_shell) + self.assertIn("cargo test --locked --workspace --all-features", feature_shell) + + feature_windows = (ROOT / "scripts/test-features.ps1").read_text() + self.assertNotIn("PrepareOnly", feature_windows) + self.assertIn("cargo test --locked --workspace --all-features --exclude tinywallet-client", feature_windows) + self.assertIn("cargo test --locked -p tinywallet-client --lib --all-features", feature_windows) + + coverage = (ROOT / "scripts/check-coverage.sh").read_text() + self.assertNotIn("--prepare-only", coverage) + self.assertIn("--exclude tinywallet-client", coverage) + self.assertIn("cargo llvm-cov --workspace --exclude tinywallet-client --all-features --all-targets", coverage) + + def test_windows_archive_operand_is_local_not_a_drive_qualified_remote(self): + powershell = (ROOT / "scripts/test-e2e.ps1").read_text() + self.assertIn("Push-Location $target", powershell) + self.assertIn("& cargo test @Arguments", powershell) + self.assertIn("[string[]]$Arguments", powershell) + self.assertIn("tar.exe -czf $archiveName -C 'tinywallet-client-e2e-cache' .", powershell) + self.assertNotIn("tar.exe -czf $archivePath", powershell) + + def test_contributor_checklists_use_the_feature_runner(self): + for filename in ("AGENTS.md", "CONTRIBUTING.md", "README.md", ".github/PULL_REQUEST_TEMPLATE.md"): + source = (ROOT / filename).read_text() + self.assertIn("scripts/test-features.sh", source) + self.assertNotRegex(source, r"(?m)^cargo test --all-features\s*$") + self.assertIn("scripts/test-features.ps1", (ROOT / "CONTRIBUTING.md").read_text()) + self.assertIn("scripts/test-features.ps1", (ROOT / ".github/PULL_REQUEST_TEMPLATE.md").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_reviewed_release_workflow.py b/scripts/test_reviewed_release_workflow.py new file mode 100644 index 00000000..ab8d35d0 --- /dev/null +++ b/scripts/test_reviewed_release_workflow.py @@ -0,0 +1,283 @@ +"""Guard the reviewed-epic release caller and its explicit admission inputs.""" + +from pathlib import Path +import os +import subprocess +import tempfile +import tomllib +import unittest +from pathlib import PureWindowsPath + + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".github/workflows/release.yml" +REVIEWED_WORKFLOW = "tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@6ed83b04aab2d8977ec62e966cb914531e534fa4" +LEGACY_WORKFLOW = "tinyhumansai/.github/.github/workflows/module-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7" +TINYBUS_REV = "85345114f3066e94d5d70161ea8dc43252b83ae6" +TINYBUS_VERSION = "=0.3.12" +TINYBUS_CRATES = ("tinybus", "tinybus-module", "tinybus-macros") + + +def _is_expected_tinybus_gitlink(entry): + lines = entry.splitlines() + if len(lines) != 1: + return False + metadata, separator, path = lines[0].partition("\t") + fields = metadata.split() + return ( + separator == "\t" + and len(fields) == 3 + and fields[0] == "160000" + and fields[1] == "commit" + and fields[2] == TINYBUS_REV + and path == "vendor/tinybus" + ) + + +def _manifest_path_label(manifest_path, repository_root=ROOT): + return manifest_path.relative_to(repository_root).as_posix() + + +class ReviewedReleaseWorkflowTests(unittest.TestCase): + def setUp(self): + self.workflow = WORKFLOW.read_text() + + def test_reviewed_mode_is_opt_in_and_legacy_defaults_to_zero(self): + self.assertIn( + "mode:\n description: Ordinary tag release or reviewed OpenHuman epic release\n" + " type: choice", + self.workflow, + ) + self.assertIn("default: standard", self.workflow) + self.assertIn("reviewed_pr:\n description: Positive canonical PR number", self.workflow) + self.assertIn("default: '0'", self.workflow) + self.assertIn("options: [standard, reviewed-epic]", self.workflow) + + def test_ordinary_tag_release_keeps_the_existing_shared_workflow_pin(self): + self.assertIn(LEGACY_WORKFLOW, self.workflow) + self.assertIn("module-crate: tinywallet-module", self.workflow) + self.assertIn("github.event_name == 'push' || inputs.mode == 'standard'", self.workflow) + self.assertIn("^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)", self.workflow) + + def test_reviewed_mode_calls_the_fixed_reusable_workflow_not_its_internal_action(self): + self.assertIn(REVIEWED_WORKFLOW, self.workflow) + self.assertNotIn("module-reviewed-epic-preflight@", self.workflow) + self.assertNotIn("f4602aaa4a6f163973ec38e29f8b5bbc6ff4db51", self.workflow) + self.assertIn( + "reviewed-pr: ${{ fromJSON(needs.validate-reviewed-dispatch.outputs.reviewed_pr || '0') }}", + self.workflow, + ) + self.assertIn("tag: ${{ needs.resolve.outputs.tag }}", self.workflow) + self.assertIn("version: ${{ needs.resolve.outputs.version }}", self.workflow) + self.assertNotIn("module-package:", self.workflow) + self.assertNotIn("asset-name:", self.workflow) + self.assertNotIn("extra-packaged-files:", self.workflow) + + def test_permissions_keep_preflight_read_only_and_publish_in_the_shared_workflow(self): + self.assertIn("permissions: {}", self.workflow) + self.assertIn( + " validate-reviewed-dispatch:\n" + " if: github.event_name == 'workflow_dispatch' && inputs.mode == 'reviewed-epic'\n" + " needs: [resolve]\n" + " permissions: {}", + self.workflow, + ) + self.assertIn( + " module-reviewed-release:\n" + " if: github.event_name == 'workflow_dispatch' && inputs.mode == 'reviewed-epic'\n" + " needs: [resolve, validate-reviewed-dispatch]\n" + " permissions:\n" + " contents: write\n" + " pull-requests: read\n" + " checks: read\n" + " actions: read", + self.workflow, + ) + + def test_ci_runs_the_behavioral_dispatch_guard(self): + ci = (ROOT / ".github/workflows/ci.yml").read_text() + self.assertIn("python3 scripts/test_reviewed_release_workflow.py", ci) + + def test_native_e2e_runs_the_detailed_attested_error_target(self): + for path in (ROOT / "scripts/test-e2e.sh", ROOT / "scripts/test-e2e.ps1"): + with self.subTest(path=path.name): + native_target = "--test attested_errors" if path.suffix == ".sh" else "'--test', 'attested_errors'" + self.assertIn(native_target, path.read_text()) + + def test_tinybus_patch_table_keeps_all_three_standalone_crates(self): + root_manifest = tomllib.loads((ROOT / "Cargo.toml").read_text()) + patch_table = root_manifest["patch"]["https://github.com/tinyhumansai/tinybus"] + self.assertEqual(patch_table["tinybus"]["path"], "vendor/tinybus/crates/tinybus") + self.assertEqual(patch_table["tinybus-module"]["path"], "vendor/tinybus/crates/tinybus-module") + self.assertEqual(patch_table["tinybus-macros"]["path"], "vendor/tinybus/crates/tinybus-macros") + entry = subprocess.check_output( + ["git", "-C", str(ROOT), "ls-tree", "HEAD", "--", "vendor/tinybus"], text=True + ).strip() + self.assertTrue(_is_expected_tinybus_gitlink(entry), entry) + + def test_tinybus_gitlink_guard_reads_exact_parent_tree_entry(self): + entry = f"160000 commit {TINYBUS_REV}\tvendor/tinybus\n" + self.assertTrue(_is_expected_tinybus_gitlink(entry)) + + def test_tinybus_gitlink_guard_rejects_stale_missing_wrong_mode_or_path(self): + self.assertFalse(_is_expected_tinybus_gitlink("")) + self.assertFalse( + _is_expected_tinybus_gitlink( + "160000 commit 1111111111111111111111111111111111111111\tvendor/tinybus\n" + ) + ) + self.assertFalse( + _is_expected_tinybus_gitlink( + f"100644 blob {TINYBUS_REV}\tvendor/tinybus\n" + ) + ) + self.assertFalse( + _is_expected_tinybus_gitlink( + f"160000 commit {TINYBUS_REV}\tvendor/other\n" + ) + ) + + def test_manifest_path_labels_use_forward_slashes_on_windows(self): + repository = PureWindowsPath(r"C:\repo") + manifest = PureWindowsPath(r"C:\repo\crates\tinywallet-module\Cargo.toml") + self.assertEqual( + _manifest_path_label(manifest, repository), + "crates/tinywallet-module/Cargo.toml", + ) + + def test_all_wallet_tinybus_sdk_edges_allow_a_single_compatible_host_version(self): + root_manifest = tomllib.loads((ROOT / "Cargo.toml").read_text()) + manifests = [ROOT / "Cargo.toml", *sorted((ROOT / "crates").glob("*/Cargo.toml"))] + sdk_edges = [] + for manifest_path in manifests: + manifest = tomllib.loads(manifest_path.read_text()) + for section in ("workspace.dependencies", "dependencies", "dev-dependencies"): + current = manifest + for key in section.split("."): + current = current.get(key, {}) + for crate in TINYBUS_CRATES: + if crate in current: + edge = current[crate] + if isinstance(edge, dict) and edge.get("workspace"): + continue + sdk_edges.append((_manifest_path_label(manifest_path), section, crate, edge)) + + self.assertEqual( + {(path, section, crate) for path, section, crate, _ in sdk_edges}, + { + ("Cargo.toml", "workspace.dependencies", "tinybus"), + ("crates/tinywallet-module/Cargo.toml", "dependencies", "tinybus"), + ("crates/tinywallet-module/Cargo.toml", "dependencies", "tinybus-module"), + ("crates/tinywallet-module/Cargo.toml", "dev-dependencies", "tinybus"), + }, + ) + for path, section, crate, edge in sdk_edges: + with self.subTest(path=path, section=section, crate=crate): + self.assertEqual(edge["version"], TINYBUS_VERSION) + self.assertEqual(edge["git"], "https://github.com/senamakel/tinybus") + self.assertEqual(edge["rev"], TINYBUS_REV) + + client_manifest = tomllib.loads( + (ROOT / "crates/tinywallet-client/Cargo.toml").read_text() + ) + client_dependency = client_manifest["dependencies"]["tinybus"] + self.assertTrue(client_dependency["workspace"]) + self.assertNotIn("features", client_dependency) + + module_manifest = tomllib.loads( + (ROOT / "crates/tinywallet-module/Cargo.toml").read_text() + ) + module_dependencies = module_manifest["dependencies"] + module_dev_dependencies = module_manifest["dev-dependencies"] + self.assertEqual( + module_dependencies["tinybus"]["features"], ["macros", "modules"] + ) + self.assertEqual( + module_dev_dependencies["tinybus"]["features"], + ["macros", "modules", "test-support"], + ) + self.assertTrue(module_dependencies["tinybus-module"]) + + def test_every_workspace_package_is_at_the_feature_release_version(self): + manifests = [ROOT / "Cargo.toml", *sorted((ROOT / "crates").glob("*/Cargo.toml"))] + workspace_version = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]["version"] + versions = set() + for manifest in manifests: + package_version = tomllib.loads(manifest.read_text())["package"]["version"] + if isinstance(package_version, dict) and package_version.get("workspace") is True: + package_version = workspace_version + versions.add(package_version) + self.assertEqual(versions, {workspace_version}) + self.assertNotIn("0.24.1", versions, "stale pre-epic package versions must not ship") + + def validation_run_block(self): + lines = self.workflow.splitlines() + start = lines.index(" validate-reviewed-dispatch:") + 1 + end = next( + index + for index in range(start, len(lines)) + if lines[index].startswith(" ") + and not lines[index].startswith(" ") + and lines[index].endswith(":") + ) + for index in range(start, end): + line = lines[index] + if not line.startswith(" run:"): + continue + command = line[len(" run:"):].strip() + if command != "|": + return command + body = [] + for body_line in lines[index + 1:end]: + if body_line.strip() and not body_line.startswith(" "): + break + body.append(body_line[10:] if body_line.startswith(" ") else "") + return "\n".join(body) + raise AssertionError("reviewed-dispatch job has no run block") + + def run_validation_block(self, repository, ref, reviewed_pr): + with tempfile.TemporaryDirectory(prefix="reviewed-release-test-") as directory: + empty_cwd = Path(directory) / "empty-cwd" + empty_cwd.mkdir() + output = Path(directory) / "output" + output.write_text("") + environment = dict( + os.environ, + GITHUB_REPOSITORY=repository, + GITHUB_REF=ref, + REVIEWED_PR=reviewed_pr, + GITHUB_OUTPUT=str(output), + ) + result = subprocess.run( + ["bash", "-c", self.validation_run_block()], + cwd=empty_cwd, + capture_output=True, + text=True, + env=environment, + ) + return result, output.read_text() + + def test_dispatch_gate_accepts_only_positive_integer_on_canonical_epic_branch(self): + result, output = self.run_validation_block( + "tinyhumansai/tinywallet", "refs/heads/epic/8056-modules-wiring", "123" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, "reviewed_pr=123\n") + + for repository, ref, reviewed_pr in ( + ("senamakel/tinywallet", "refs/heads/epic/8056-modules-wiring", "123"), + ("tinyhumansai/tinywallet", "refs/heads/main", "123"), + ("tinyhumansai/tinywallet", "refs/heads/epic/8056-modules-wiring", "0"), + ("tinyhumansai/tinywallet", "refs/heads/epic/8056-modules-wiring", "-1"), + ("tinyhumansai/tinywallet", "refs/heads/epic/8056-modules-wiring", "1.5"), + ("tinyhumansai/tinywallet", "refs/heads/epic/8056-modules-wiring", "01"), + ("tinyhumansai/tinywallet", "refs/heads/epic/8056-modules-wiring", "123;echo bad"), + ): + with self.subTest(repository=repository, ref=ref, reviewed_pr=reviewed_pr): + result, output = self.run_validation_block(repository, ref, reviewed_pr) + self.assertEqual(result.returncode, 2) + self.assertEqual(output, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/tinybus b/vendor/tinybus index 6ce06bc6..85345114 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 6ce06bc674e4f4d718aa51e643ef62c1f372e270 +Subproject commit 85345114f3066e94d5d70161ea8dc43252b83ae6