mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-05-26 01:49:29 +00:00
Merge branch 'main' into schouhy/private-pdas-as-external-input
This commit is contained in:
commit
6517dab391
44
.github/workflows/bench-regression.yml
vendored
Normal file
44
.github/workflows/bench-regression.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "tools/crypto_primitives_bench/**"
|
||||
- "key_protocol/**"
|
||||
- "nssa/core/**"
|
||||
- ".github/workflows/bench-regression.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
name: bench-regression
|
||||
|
||||
jobs:
|
||||
crypto-primitives:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.head_ref }}
|
||||
# criterion-compare-action checks out the base branch in a second
|
||||
# working tree, so we need the full history.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/install-system-deps
|
||||
|
||||
- uses: ./.github/actions/install-risc0
|
||||
|
||||
- uses: ./.github/actions/install-logos-blockchain-circuits
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install active toolchain
|
||||
run: rustup install
|
||||
|
||||
- name: Run criterion-compare against base branch
|
||||
uses: boa-dev/criterion-compare-action@v3
|
||||
with:
|
||||
branchName: ${{ github.base_ref }}
|
||||
cwd: tools/crypto_primitives_bench
|
||||
benchName: primitives
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -6,7 +6,7 @@ This document describes the guidelines for contributing to the project. We will
|
||||
|
||||
If you have any questions, come say hi to our [Discord](https://discord.gg/tGJwgGrSPN)!
|
||||
|
||||
## Commit and PR title format
|
||||
## Commit title format
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
@ -33,11 +33,22 @@ Examples:
|
||||
|
||||
Breaking changes:
|
||||
- Mark with `!` in the title.
|
||||
- Optionally add a `BREAKING CHANGE:` footer in the PR body with migration notes.
|
||||
|
||||
`CHANGELOG.md` is generated from these markers on every `v*` tag via `git-cliff`, and GitHub Releases are created from the same content.
|
||||
|
||||
Before merging PR consider squashing non-meaningful commits. E.g.:
|
||||
## Pull requests
|
||||
|
||||
PR titles should follow the same Conventional Commits format:
|
||||
- `type(scope): description`
|
||||
- `type(scope)!: description` for breaking changes
|
||||
|
||||
Before marking a PR as ready for review:
|
||||
- Fill out the PR template.
|
||||
|
||||
Breaking changes in PRs:
|
||||
- Optionally add a `BREAKING CHANGE:` footer in the PR body with migration notes.
|
||||
|
||||
Before merging a PR, consider squashing non-meaningful commits. E.g.:
|
||||
|
||||
```
|
||||
- refactor(wallet): move user keys to a separate module
|
||||
|
||||
312
Cargo.lock
generated
312
Cargo.lock
generated
@ -91,6 +91,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@ -126,6 +135,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
@ -1405,6 +1420,12 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.29.2"
|
||||
@ -1489,6 +1510,33 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@ -1860,6 +1908,41 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"itertools 0.13.0",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
@ -1942,12 +2025,10 @@ dependencies = [
|
||||
name = "crypto_primitives_bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"criterion",
|
||||
"key_protocol",
|
||||
"nssa_core",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1994,9 +2075,11 @@ dependencies = [
|
||||
"amm_core",
|
||||
"anyhow",
|
||||
"ata_core",
|
||||
"authenticated_transfer_core",
|
||||
"borsh",
|
||||
"clap",
|
||||
"clock_core",
|
||||
"criterion",
|
||||
"nssa",
|
||||
"nssa_core",
|
||||
"risc0-zkvm",
|
||||
@ -2098,7 +2181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2582,7 +2665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3145,6 +3228,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.2.1"
|
||||
@ -3561,7 +3655,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@ -3924,6 +4018,15 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@ -4392,6 +4495,17 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keycard_wallet"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"nssa",
|
||||
"pyo3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-regex"
|
||||
version = "3.6.0"
|
||||
@ -5963,6 +6077,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mempool"
|
||||
version = "0.1.0"
|
||||
@ -6583,6 +6706,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@ -6737,6 +6866,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@ -6893,6 +7032,34 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@ -7199,6 +7366,69 @@ dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"indoc",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
"unindent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"pyo3-build-config",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-protobuf"
|
||||
version = "0.8.1"
|
||||
@ -7235,7 +7465,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@ -7272,9 +7502,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -8013,6 +8243,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds"
|
||||
version = "1.2.0"
|
||||
@ -8105,6 +8346,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruint"
|
||||
version = "1.17.2"
|
||||
@ -8167,7 +8418,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -8225,7 +8476,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs 0.26.11",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -9128,6 +9379,12 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.26.0"
|
||||
@ -9138,7 +9395,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -9373,6 +9630,16 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.10.0"
|
||||
@ -9997,6 +10264,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
version = "0.5.2"
|
||||
@ -10178,11 +10451,14 @@ dependencies = [
|
||||
"indicatif",
|
||||
"itertools 0.14.0",
|
||||
"key_protocol",
|
||||
"keycard_wallet",
|
||||
"log",
|
||||
"nssa",
|
||||
"nssa_core",
|
||||
"optfield",
|
||||
"pyo3",
|
||||
"rand 0.8.5",
|
||||
"rpassword",
|
||||
"sequencer_service_rpc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -10193,6 +10469,7 @@ dependencies = [
|
||||
"token_core",
|
||||
"tokio",
|
||||
"url",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -10469,7 +10746,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -10608,6 +10885,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
||||
@ -41,6 +41,7 @@ members = [
|
||||
"examples/program_deployment/methods/guest",
|
||||
"testnet_initial_state",
|
||||
"indexer/ffi",
|
||||
"keycard_wallet",
|
||||
"test_fixtures",
|
||||
"tools/cycle_bench",
|
||||
"tools/crypto_primitives_bench",
|
||||
@ -77,6 +78,7 @@ faucet_core = { path = "programs/faucet/core" }
|
||||
vault_core = { path = "programs/vault/core" }
|
||||
test_program_methods = { path = "test_program_methods" }
|
||||
testnet_initial_state = { path = "testnet_initial_state" }
|
||||
keycard_wallet = { path = "keycard_wallet" }
|
||||
test_fixtures = { path = "test_fixtures" }
|
||||
|
||||
tokio = { version = "1.50", features = [
|
||||
@ -131,6 +133,7 @@ url = { version = "2.5.4", features = ["serde"] }
|
||||
tokio-retry = "0.3.0"
|
||||
schemars = "1.2"
|
||||
async-stream = "0.3.6"
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
|
||||
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
|
||||
@ -157,6 +160,7 @@ actix-web = { version = "4.13.0", default-features = false, features = [
|
||||
] }
|
||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||
pyo3 = { version = "0.24", features = ["auto-initialize"] }
|
||||
|
||||
# Profile for leptos WASM release builds
|
||||
[profile.wasm-release]
|
||||
|
||||
6
Justfile
6
Justfile
@ -23,6 +23,12 @@ test:
|
||||
@echo "🧪 Running tests"
|
||||
RISC0_DEV_MODE=1 cargo nextest run --no-fail-fast
|
||||
|
||||
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
|
||||
bench:
|
||||
@echo "📊 Running criterion benches"
|
||||
cargo bench -p crypto_primitives_bench --bench primitives
|
||||
cargo bench -p cycle_bench --features ppe --bench verify
|
||||
|
||||
# Run Bedrock node in docker
|
||||
[working-directory: 'bedrock']
|
||||
run-bedrock:
|
||||
|
||||
BIN
artifacts/program_methods/bridge.bin
Normal file
BIN
artifacts/program_methods/bridge.bin
Normal file
Binary file not shown.
BIN
artifacts/test_program_methods/malicious_injector.bin
Normal file
BIN
artifacts/test_program_methods/malicious_injector.bin
Normal file
Binary file not shown.
BIN
artifacts/test_program_methods/malicious_launderer.bin
Normal file
BIN
artifacts/test_program_methods/malicious_launderer.bin
Normal file
Binary file not shown.
237
docs/LEZ testnet v0.1 tutorials/keycard.md
Normal file
237
docs/LEZ testnet v0.1 tutorials/keycard.md
Normal file
@ -0,0 +1,237 @@
|
||||
This tutorial walks you through using Keycard with Wallet CLI. Keycard is optional hardware that can offer enhance security to a LEZ wallet. A LEZ wallet that utilizes Keycard does not store any secret keys for public accounts (eventually, this will extend to private accounts). Instead, Wallet CLI retrieves the appropriate public keys and signatures from Keycard.
|
||||
|
||||
|
||||
## Keycard Setup
|
||||
|
||||
### Required hardware
|
||||
- Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE.
|
||||
- Smartcard reader
|
||||
- Applets (`math.cap` and `LEE_keycard.cap`). Eventually, both of these applets will be available in separate repos.
|
||||
- `math.cap` is an applet to speed up computations on Keycard; developed by Bitgamma (Keycard-tech team).
|
||||
- `LEE_keycard.cap` is an applet that contains LEE keycard protocol; developed by Bitgamma (Keycard-tech team)
|
||||
|
||||
### Firmware installation
|
||||
Installation:
|
||||
|
||||
1. Install math applet on your keycard; this process only needs to be done once. In the root of repo:
|
||||
```
|
||||
sudo apt-get install -y default-jdk
|
||||
wget https://github.com/martinpaljak/GlobalPlatformPro/releases/download/v25.10.20/gp.jar -P keycard_wallet/keycard_applets
|
||||
cd keycard_wallet/keycard_applets
|
||||
java -jar gp.jar --key c212e073ff8b4bbfaff4de8ab655221f --load math.cap
|
||||
```
|
||||
2. Install `keycard-desktop` from [github](https://github.com/choppu/keycard-desktop)
|
||||
- Keycard Desktop is used to install the LEE key protocol to a blank keycard.
|
||||
- Select (Re)Install Applet and upload the key binary (`keycard_wallet/keycard_applets/LEE_keycard.cap`).
|
||||

|
||||
- **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa.
|
||||
|
||||
## Wallet with Keycard
|
||||
Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `keycard_wallet/wallet_with_keycard.sh`.
|
||||
|
||||
```bash
|
||||
# Install appropriate version of `keycard-py`.
|
||||
git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git keycard_wallet/python/keycard-py
|
||||
|
||||
# Set up virtual environment.
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install pyscard mnemonic ecdsa pyaes
|
||||
pip install -e keycard_wallet/python/keycard-py
|
||||
```
|
||||
|
||||
**Important**: Keycard wallet commands only work within the virtual environment.
|
||||
```bash
|
||||
# In the root of LEE repo:
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
## PIN entry
|
||||
|
||||
Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable:
|
||||
|
||||
```bash
|
||||
export KEYCARD_PIN=123456
|
||||
```
|
||||
|
||||
Unset it when done:
|
||||
|
||||
```bash
|
||||
unset KEYCARD_PIN
|
||||
```
|
||||
|
||||
## Keycard Commands
|
||||
|
||||
### Keycard
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------------|------------------------------------------------------------|
|
||||
| `wallet keycard available` | Checks whether a Keycard reader and card are accessible |
|
||||
| `wallet keycard init` | Initializes a blank Keycard with a PIN and a generated PUK |
|
||||
| `wallet keycard connect` | Establishes and saves a pairing with the Keycard |
|
||||
| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing |
|
||||
| `wallet keycard load` | Loads a mnemonic phrase onto the Keycard |
|
||||
|
||||
1. Check keycard availability
|
||||
```bash
|
||||
wallet keycard available
|
||||
|
||||
# Output:
|
||||
✅ Keycard is available.
|
||||
```
|
||||
|
||||
2. Initialize a blank Keycard
|
||||
```bash
|
||||
wallet keycard init
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
Keycard PUK: 847302916485
|
||||
Record this PUK and store it somewhere safe. It cannot be recovered.
|
||||
✅ Keycard initialized successfully.
|
||||
```
|
||||
|
||||
3. Connect (pair and save pairing for subsequent commands)
|
||||
```bash
|
||||
wallet keycard connect
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
✅ Keycard paired and ready.
|
||||
```
|
||||
|
||||
4. Load a mnemonic phrase
|
||||
```bash
|
||||
# Supply mnemonic via environment variable to avoid interactive prompt
|
||||
export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then"
|
||||
wallet keycard load
|
||||
unset KEYCARD_MNEMONIC
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
✅ Keycard is now connected to wallet.
|
||||
✅ Mnemonic phrase loaded successfully.
|
||||
```
|
||||
|
||||
5. Disconnect (unpair and clear saved pairing)
|
||||
```bash
|
||||
wallet keycard disconnect
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
✅ Keycard unpaired and pairing cleared.
|
||||
```
|
||||
|
||||
### Pinata (testnet)
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------|--------------------------------------------------------------------------|
|
||||
| `wallet pinata claim` | Claims a testnet pinata reward to a public or private recipient account |
|
||||
|
||||
Note: The recipient account must be initialized with `wallet auth-transfer init` before claiming.
|
||||
|
||||
`--to` accepts any of:
|
||||
- A BIP32 key path — uses Keycard (e.g. `m/44'/60'/0'/0/0`)
|
||||
- An account ID with privacy prefix (e.g. `Public/9bKm...`)
|
||||
- An account label (e.g. `my-account`)
|
||||
|
||||
1. Claim to a Keycard public account
|
||||
```bash
|
||||
wallet pinata claim --to "m/44'/60'/0'/0/0"
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
Computing solution for pinata...
|
||||
Found solution 989106 in 33.739525ms
|
||||
Transaction hash is fd320c01f5469e62d2486afa1d9d5be39afcca0cd01d1575905b7acd95cf6397
|
||||
```
|
||||
|
||||
2. Claim to a local wallet account by label
|
||||
```bash
|
||||
wallet pinata claim --to my-account
|
||||
|
||||
# Output:
|
||||
Transaction hash is 2c8a4f1e903d5b76e80214c5b82e1d46a105e28930ad71bcce48f2d07b49a16f
|
||||
```
|
||||
|
||||
### Authenticated-transfer program
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------------|-------------------------------------------------------------------------------|
|
||||
| `wallet auth-transfer init` | Registers an account with the auth-transfer program |
|
||||
| `wallet auth-transfer send` | Sends native tokens between accounts |
|
||||
|
||||
`--account-id` (for `init`) and `--from`/`--to` (for `send`) each accept any of:
|
||||
- A BIP32 key path — uses Keycard (e.g. `m/44'/60'/0'/0/0`)
|
||||
- An account ID with privacy prefix (e.g. `Public/9bKm...`)
|
||||
- An account label (e.g. `my-account`)
|
||||
|
||||
For `send`, foreign recipient accounts (not in the local wallet and not a Keycard path) do not need to sign — pass their account ID directly via `--to`. Shielded sends to foreign private accounts use `--to-npk`/`--to-vpk`.
|
||||
|
||||
1. Initialize a Keycard public account
|
||||
```bash
|
||||
wallet auth-transfer init --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
Transaction hash is 49c16940493e1618c393645c1211b5c793d405838221c29ac6562a8a4b11c5a7
|
||||
```
|
||||
|
||||
2. Send native tokens between two Keycard accounts
|
||||
```bash
|
||||
wallet auth-transfer send \
|
||||
--from "m/44'/60'/0'/0/0" \
|
||||
--to "m/44'/60'/0'/0/1" \
|
||||
--amount 40
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
Transaction hash is 1a9764ab20763dcc1ffb51c6e9badd5a6316a773759032ca48e0eee59caaf488
|
||||
```
|
||||
|
||||
3. Send native tokens from a Keycard account to a foreign account
|
||||
```bash
|
||||
wallet auth-transfer send \
|
||||
--from "m/44'/60'/0'/0/0" \
|
||||
--to "Public/9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \
|
||||
--amount 20
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
Transaction hash is 3e7b2a91cf804d56fe19084b3c8b25d07e8f243829bc50addf6e2c78b4b09d34
|
||||
```
|
||||
|
||||
4. Send native tokens from a Keycard account to a local wallet account by label
|
||||
```bash
|
||||
wallet auth-transfer send \
|
||||
--from "m/44'/60'/0'/0/0" \
|
||||
--to my-account \
|
||||
--amount 20
|
||||
|
||||
# Output:
|
||||
Keycard PIN:
|
||||
Transaction hash is 7d4c1b8e2f903a56fd19084b3c8b25d07e8f243829bc50addf6e2c78b4b09e45
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Tests for Keycard commands are in `keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected:
|
||||
|
||||
```bash
|
||||
bash keycard_wallet/tests/keycard_tests.sh
|
||||
```
|
||||
|
||||
## SigningGroups
|
||||
|
||||
`SigningGroups` (`wallet/src/signing.rs`) partitions a transaction's signers into two buckets — local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved.
|
||||
|
||||
Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing.
|
||||
|
||||
Foreign recipient accounts — those with no local key and no Keycard path — are silently skipped and require neither a signature nor a nonce.
|
||||
|
||||
```
|
||||
SigningGroups {
|
||||
local: [(AccountId, PrivateKey)], // signed in pure Rust
|
||||
keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session
|
||||
}
|
||||
```
|
||||
@ -14,15 +14,17 @@ Cryptographic primitives used by client/wallet code. Measures the per-call cost
|
||||
|
||||
## Results
|
||||
|
||||
100 timed iterations per operation, 2 warmup discarded.
|
||||
Criterion sample_size = 50, warm_up_time = 2 s, measurement_time = 10 s. Slope-regression point estimate in the middle column; 95% confidence interval bounds in the outer columns.
|
||||
|
||||
| Operation | best (µs) | mean (µs) | stdev (µs) |
|
||||
|---|---:|---:|---:|
|
||||
| KeyChain::new_os_random | 2,979.62 (2.98 ms) | 3,138.18 (3.14 ms) | 258.59 (0.26 ms) |
|
||||
| KeyChain::new_mnemonic | 2,979.12 (2.98 ms) | 3,012.76 (3.01 ms) | 46.09 (0.05 ms) |
|
||||
| SharedSecretKey::new (sender DH) | 74.17 (0.07 ms) | 74.48 (0.07 ms) | 0.22 (<0.01 ms) |
|
||||
| EncryptionScheme::encrypt | 0.88 (<0.01 ms) | 0.92 (<0.01 ms) | 0.03 (<0.01 ms) |
|
||||
| EncryptionScheme::decrypt | 0.75 (<0.01 ms) | 0.78 (<0.01 ms) | 0.04 (<0.01 ms) |
|
||||
| Operation | low | point | high | outliers (mild + severe) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| keychain/new_os_random | 3.11 ms | 3.21 ms | 3.34 ms | 3 + 5 |
|
||||
| keychain/new_mnemonic | 3.05 ms | 3.11 ms | 3.23 ms | 0 + 2 |
|
||||
| shared_secret_key/sender_dh | 76.7 µs | 78.4 µs | 80.6 µs | 3 + 4 |
|
||||
| encryption/encrypt | 1.11 µs | 1.17 µs | 1.25 µs | 1 + 5 |
|
||||
| encryption/decrypt | 907 ns | 928 ns | 954 ns | 0 + 3 |
|
||||
|
||||
Numbers from a single M2 Pro dev box. For full estimates (slope, mean, median, MAD, std-dev) and the noise model, see `target/criterion/<group>/<bench>/estimates.json` after running locally.
|
||||
|
||||
## Findings
|
||||
|
||||
@ -33,10 +35,21 @@ Cryptographic primitives used by client/wallet code. Measures the per-call cost
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
cargo run --release -p crypto_primitives_bench
|
||||
cargo bench -p crypto_primitives_bench --bench primitives
|
||||
```
|
||||
|
||||
JSON output: `target/crypto_primitives_bench.json`.
|
||||
JSON estimates: `target/criterion/<group>/<bench>/estimates.json`. HTML report: `target/criterion/report/index.html`.
|
||||
|
||||
## Baseline comparison
|
||||
|
||||
```sh
|
||||
# On main:
|
||||
cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main
|
||||
# On your branch:
|
||||
cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main
|
||||
```
|
||||
|
||||
Criterion reports per-bench change as a percentage with a 95% confidence interval; deltas within the CI are reported as "no significant change" rather than red.
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@ -63,23 +63,24 @@ Same `auth_transfer Transfer` instruction, standalone vs wrapped in the privacy
|
||||
|
||||
Linear fit depth=1..9: ≈ 53 s per additional chained call, intercept ≈ 73 s. Composition tax (single program PPE − standalone): ≈ 48 s. `proof_bytes` is constant: the outer succinct proof has fixed size; the journal carried alongside it scales with public state and is reported separately by `--verify`.
|
||||
|
||||
## Verifier (`--verify`)
|
||||
## Verifier (criterion bench)
|
||||
|
||||
One PPE receipt generated once (auth_transfer Transfer in PPE), then `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` measured over 1000 iterations.
|
||||
One PPE receipt generated once (auth_transfer Transfer in PPE), then `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` measured under criterion's statistical sampler. Bench file: `tools/cycle_bench/benches/verify.rs`. Setup (one full PPE prove) is outside the timed `iter` loop.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| case | auth_transfer Transfer in PPE |
|
||||
| proof_bytes (S_agg) | 223,551 |
|
||||
| journal_bytes | 412 |
|
||||
| verify_ms (best / mean ± stdev, n=1000) | 11.71 / 12.06 ± 1.99 |
|
||||
Numbers from the most recent local run on the machine listed above. Criterion sample_size = 100, measurement_time = 15 s, warm_up_time = 2 s. Slope-regression point estimate in the middle column; 95% CI bounds on either side. Run `cargo bench -p cycle_bench --features ppe --bench verify` to refresh.
|
||||
|
||||
| Bench | low | point | high | outliers (mild + severe) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| ppe/verify_auth_transfer | 12.016 ms | 12.215 ms | 12.469 ms | 1 + 10 |
|
||||
|
||||
The corresponding `proof_bytes` (S_agg) for the bench receipt is captured by `--ppe` above; the verify bench itself only times the verify call.
|
||||
|
||||
## Findings
|
||||
|
||||
- Proving cost scales with po2-bucketed `total_cycles`, not raw `user_cycles`. Trimming user_cycles only helps if it crosses a 2^N boundary.
|
||||
- Single-program PPE composition tax on M2 Pro CPU: ≈ 48 s (61.5 − 13.7).
|
||||
- Chained-call cost is linear at ≈ 53 s per call. A max-depth chain (10) would take ≈ 600 s standalone on this CPU.
|
||||
- `G_verify` is ≈ 12 ms and roughly constant per outer receipt (1000-iter stdev ≈ 2 ms). The succinct outer proof is fixed at 223,551 bytes (S_agg); verify is not on the latency critical path.
|
||||
- `G_verify` is ≈ 12 ms (criterion CI: 12.0–12.5 ms over 100 samples) and roughly constant per outer receipt. The succinct outer proof is fixed at 223,551 bytes (S_agg); verify is not on the latency critical path.
|
||||
|
||||
## Reproduce
|
||||
|
||||
@ -87,10 +88,12 @@ One PPE receipt generated once (auth_transfer Transfer in PPE), then `Receipt::v
|
||||
cargo run --release -p cycle_bench
|
||||
cargo run --release -p cycle_bench --features prove -- --prove
|
||||
cargo run --release -p cycle_bench --features ppe -- --prove --ppe
|
||||
cargo run --release -p cycle_bench --features ppe -- --verify --verify-iters 1000
|
||||
|
||||
# Verifier microbench via criterion:
|
||||
cargo bench -p cycle_bench --features ppe --bench verify
|
||||
```
|
||||
|
||||
JSON output: `target/cycle_bench.json`.
|
||||
JSON output: `target/cycle_bench.json` (bin), `target/criterion/ppe/verify_auth_transfer/` (verify bench).
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use bytesize::ByteSize;
|
||||
use common::transaction::NSSATransaction;
|
||||
use integration_tests::{TestContext, config::SequencerPartialConfig};
|
||||
@ -66,7 +66,64 @@ impl TpsTestManager {
|
||||
Duration::from_secs_f64(number_transactions as f64 / self.target_tps as f64)
|
||||
}
|
||||
|
||||
/// Claim funds from each account's vault PDA into the account itself.
|
||||
///
|
||||
/// `GenesisAction::SupplyAccount` funds vault PDAs (not accounts directly), so this step is
|
||||
/// required before sending `authenticated_transfer` transactions from these accounts.
|
||||
/// All claim transactions are submitted at once and then confirmed sequentially.
|
||||
/// After this call every account has nonce 1, so `build_public_txs` must be called after it.
|
||||
pub async fn claim_vault_funds(
|
||||
&self,
|
||||
sequencer_client: &sequencer_service_rpc::SequencerClient,
|
||||
) -> Result<()> {
|
||||
let vault_program_id = Program::vault().id();
|
||||
|
||||
let mut tx_hashes = Vec::with_capacity(self.public_keypairs.len());
|
||||
for (private_key, account_id) in &self.public_keypairs {
|
||||
let owner_vault_id =
|
||||
vault_core::compute_vault_account_id(vault_program_id, *account_id);
|
||||
let message = putx::Message::try_new(
|
||||
vault_program_id,
|
||||
vec![*account_id, owner_vault_id],
|
||||
vec![Nonce(0_u128)],
|
||||
vault_core::Instruction::Claim { amount: 10 },
|
||||
)
|
||||
.context("Failed to build vault claim message")?;
|
||||
let witness_set =
|
||||
nssa::public_transaction::WitnessSet::for_message(&message, &[private_key]);
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
let hash = sequencer_client
|
||||
.send_transaction(NSSATransaction::Public(tx))
|
||||
.await
|
||||
.context("Failed to submit vault claim")?;
|
||||
tx_hashes.push(hash);
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
for (i, tx_hash) in tx_hashes.iter().enumerate() {
|
||||
loop {
|
||||
anyhow::ensure!(
|
||||
Instant::now() < deadline,
|
||||
"Vault claims timed out after 5 minutes ({i}/{} confirmed)",
|
||||
tx_hashes.len()
|
||||
);
|
||||
let found = sequencer_client
|
||||
.get_transaction(*tx_hash)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a batch of public transactions to submit to the node.
|
||||
///
|
||||
/// Must be called after `claim_vault_funds`, which sets each account's nonce to 1.
|
||||
pub fn build_public_txs(&self) -> Vec<PublicTransaction> {
|
||||
// Create valid public transactions
|
||||
let program = Program::authenticated_transfer_program();
|
||||
@ -78,7 +135,7 @@ impl TpsTestManager {
|
||||
let message = putx::Message::try_new(
|
||||
program.id(),
|
||||
[pair[0].1, pair[1].1].to_vec(),
|
||||
[Nonce(0_u128)].to_vec(),
|
||||
[Nonce(1_u128)].to_vec(),
|
||||
authenticated_transfer_core::Instruction::Transfer { amount },
|
||||
)
|
||||
.unwrap();
|
||||
@ -127,6 +184,12 @@ pub async fn tps_test() -> Result<()> {
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
// Genesis funds vault PDAs, not accounts directly. Claim into accounts before measuring.
|
||||
tps_test
|
||||
.claim_vault_funds(ctx.sequencer_client())
|
||||
.await
|
||||
.context("Failed to claim vault funds for TPS accounts")?;
|
||||
|
||||
let target_time = tps_test.target_time();
|
||||
info!(
|
||||
"TPS test begin. Target time is {target_time:?} for {num_transactions} transactions ({target_tps} TPS)"
|
||||
|
||||
15
keycard_wallet/Cargo.toml
Normal file
15
keycard_wallet/Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "keycard_wallet"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa.workspace = true
|
||||
pyo3.workspace = true
|
||||
log.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
BIN
keycard_wallet/keycard_applets/LEE_keycard.cap
Normal file
BIN
keycard_wallet/keycard_applets/LEE_keycard.cap
Normal file
Binary file not shown.
BIN
keycard_wallet/keycard_applets/math.cap
Normal file
BIN
keycard_wallet/keycard_applets/math.cap
Normal file
Binary file not shown.
164
keycard_wallet/python/keycard_wallet.py
Normal file
164
keycard_wallet/python/keycard_wallet.py
Normal file
@ -0,0 +1,164 @@
|
||||
from smartcard.System import readers
|
||||
from keycard.exceptions import APDUError, TransportError
|
||||
from ecdsa import VerifyingKey, SECP256k1
|
||||
|
||||
from keycard.keycard import KeyCard
|
||||
|
||||
from mnemonic import Mnemonic
|
||||
from keycard import constants
|
||||
|
||||
import keycard
|
||||
import secrets
|
||||
|
||||
DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing"
|
||||
|
||||
class KeycardWallet:
|
||||
def __init__(self):
|
||||
self.card = KeyCard()
|
||||
|
||||
def _is_smart_card_reader_detected(self) -> bool:
|
||||
try:
|
||||
return len(readers()) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _is_keycard_detected(self) -> bool:
|
||||
try:
|
||||
KeyCard().select()
|
||||
return True
|
||||
except (TransportError, APDUError, Exception):
|
||||
# No readers, no card, or card doesn't respond.
|
||||
return False
|
||||
|
||||
def is_unpaired_keycard_available(self) -> bool:
|
||||
if not self._is_smart_card_reader_detected():
|
||||
return False
|
||||
elif not self._is_keycard_detected():
|
||||
return False
|
||||
return True
|
||||
|
||||
def initialize(self, pin: str) -> bool:
|
||||
try:
|
||||
self.card.select()
|
||||
|
||||
if self.card.is_initialized:
|
||||
raise RuntimeError("Card is already initialized")
|
||||
|
||||
puk = ''.join(secrets.choice('0123456789') for _ in range(12))
|
||||
self.card.init(pin, puk, DEFAULT_PAIRING_PASSWORD)
|
||||
print(f"Keycard PUK: {puk}")
|
||||
print("Record this PUK and store it somewhere safe. It cannot be recovered.")
|
||||
return True
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error initializing keycard: {e}") from e
|
||||
|
||||
def setup_communication(self, pin: str, password = DEFAULT_PAIRING_PASSWORD) -> bool:
|
||||
self.card.select()
|
||||
|
||||
if not self.card.is_initialized:
|
||||
raise RuntimeError("Card is not initialized — run 'wallet keycard init' first")
|
||||
|
||||
pairing_index, pairing_key = self.card.pair(password)
|
||||
self.pairing_index = pairing_index
|
||||
self.pairing_key = pairing_key
|
||||
|
||||
try:
|
||||
self.card.open_secure_channel(pairing_index, pairing_key)
|
||||
self.card.verify_pin(pin)
|
||||
except Exception as e:
|
||||
try:
|
||||
self.card.unpair(pairing_index)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Error setting up communication: {e}") from e
|
||||
|
||||
return True
|
||||
|
||||
def get_pairing_data(self) -> tuple[int, bytes]:
|
||||
return (self.pairing_index, self.pairing_key)
|
||||
|
||||
def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool:
|
||||
self.card.select()
|
||||
|
||||
if not self.card.is_initialized:
|
||||
raise RuntimeError("Card is not initialized — run 'wallet keycard init' first")
|
||||
|
||||
self.pairing_index = pairing_index
|
||||
self.pairing_key = pairing_key
|
||||
|
||||
try:
|
||||
self.card.open_secure_channel(pairing_index, pairing_key)
|
||||
self.card.verify_pin(pin)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error setting up communication with stored pairing: {e}") from e
|
||||
|
||||
return True
|
||||
|
||||
def close_session(self) -> bool:
|
||||
return True
|
||||
|
||||
def load_mnemonic(self, mnemonic: str) -> bool:
|
||||
try:
|
||||
# Convert mnemonic to seed
|
||||
mnemo = Mnemonic("english")
|
||||
if not mnemo.check(mnemonic):
|
||||
raise RuntimeError("Invalid mnemonic phrase — check spelling and word count")
|
||||
seed = mnemo.to_seed(mnemonic)
|
||||
|
||||
# Load the LEE seed onto the card
|
||||
result = self.card.load_key(
|
||||
key_type = constants.LoadKeyType.LEE_SEED,
|
||||
lee_seed = seed
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error loading mnemonic: {e}") from e
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
try:
|
||||
if not self.card.is_secure_channel_open:
|
||||
return False
|
||||
|
||||
self.card.unpair(self.pairing_index)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error during disconnect: {e}") from e
|
||||
|
||||
def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None:
|
||||
try:
|
||||
if not self.card.is_secure_channel_open or not self.card.is_pin_verified:
|
||||
return None
|
||||
|
||||
public_key = self.card.export_key(
|
||||
derivation_option = constants.DerivationOption.DERIVE,
|
||||
public_only = True,
|
||||
keypath = path
|
||||
)
|
||||
|
||||
public_key = public_key.public_key
|
||||
public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1)
|
||||
public_key = public_key.to_string("compressed")[1:]
|
||||
|
||||
return public_key
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error getting public key: {e}") from e
|
||||
|
||||
|
||||
def sign_message_for_path(self, message: bytes, path: str = "m/44'/60'/0'/0/0") -> bytes | None:
|
||||
try:
|
||||
if not self.card.is_secure_channel_open or not self.card.is_pin_verified:
|
||||
return None
|
||||
|
||||
signature = self.card.sign_with_path(
|
||||
digest = message,
|
||||
path = path,
|
||||
algorithm = constants.SigningAlgorithm.SCHNORR_BIP340,
|
||||
make_current = False
|
||||
)
|
||||
|
||||
return signature.signature
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error signing message: {e}") from e
|
||||
230
keycard_wallet/src/lib.rs
Normal file
230
keycard_wallet/src/lib.rs
Normal file
@ -0,0 +1,230 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nssa::{AccountId, PublicKey, Signature};
|
||||
use pyo3::{prelude::*, types::PyAny};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod python_path;
|
||||
|
||||
// TODO: encrypt at rest alongside broader wallet storage encryption work.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct KeycardPairingData {
|
||||
pub index: u8,
|
||||
pub key: Vec<u8>,
|
||||
}
|
||||
|
||||
impl KeycardPairingData {
|
||||
const fn is_valid(&self) -> bool {
|
||||
self.key.len() == 32 && self.index <= 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust wrapper around the Python `KeycardWallet` class.
|
||||
pub struct KeycardWallet {
|
||||
instance: Py<PyAny>,
|
||||
}
|
||||
|
||||
impl KeycardWallet {
|
||||
/// Create a new Python `KeycardWallet` instance.
|
||||
pub fn new(py: Python) -> PyResult<Self> {
|
||||
let module = py.import("keycard_wallet")?;
|
||||
let class = module.getattr("KeycardWallet")?;
|
||||
|
||||
let instance = class.call0()?;
|
||||
|
||||
Ok(Self {
|
||||
instance: instance.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult<bool> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method0("is_unpaired_keycard_available")?
|
||||
.extract()
|
||||
}
|
||||
|
||||
pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult<bool> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method1("initialize", (pin,))?
|
||||
.extract()
|
||||
}
|
||||
|
||||
pub fn get_pairing_data(&self, py: Python<'_>) -> PyResult<(u8, Vec<u8>)> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method0("get_pairing_data")?
|
||||
.extract()
|
||||
}
|
||||
|
||||
pub fn setup_communication_with_pairing(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
pin: &str,
|
||||
index: u8,
|
||||
key: &[u8],
|
||||
) -> PyResult<bool> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method1(
|
||||
"setup_communication_with_pairing",
|
||||
(pin, index, key.to_vec()),
|
||||
)?
|
||||
.extract()
|
||||
}
|
||||
|
||||
pub fn close_session(&self, py: Python<'_>) -> PyResult<bool> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method0("close_session")?
|
||||
.extract()
|
||||
}
|
||||
|
||||
/// Connect using a stored pairing if available, falling back to a fresh pair.
|
||||
/// Saves any newly established pairing to disk.
|
||||
pub fn connect(&self, py: Python<'_>, pin: &str) -> PyResult<()> {
|
||||
if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid)
|
||||
&& self
|
||||
.setup_communication_with_pairing(py, pin, pairing.index, &pairing.key)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.setup_communication(py, pin)?;
|
||||
if let Ok((index, key)) = self.get_pairing_data(py) {
|
||||
save_pairing(&KeycardPairingData { index, key });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn setup_communication(&self, py: Python<'_>, pin: &str) -> PyResult<bool> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method1("setup_communication", (pin,))?
|
||||
.extract()
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, py: Python) -> PyResult<bool> {
|
||||
self.instance.bind(py).call_method0("disconnect")?.extract()
|
||||
}
|
||||
|
||||
pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult<PublicKey> {
|
||||
let public_key: Vec<u8> = self
|
||||
.instance
|
||||
.bind(py)
|
||||
.call_method1("get_public_key_for_path", (path,))?
|
||||
.extract()?;
|
||||
|
||||
let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec<u8>| {
|
||||
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
|
||||
"expected 32-byte public key from keycard, got {} bytes",
|
||||
vec.len()
|
||||
))
|
||||
})?;
|
||||
|
||||
PublicKey::try_new(public_key)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult<PublicKey> {
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py)?;
|
||||
let wallet = Self::new(py)?;
|
||||
wallet.connect(py, pin)?;
|
||||
let pub_key = wallet.get_public_key_for_path(py, path);
|
||||
drop(wallet.close_session(py));
|
||||
pub_key
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sign_message_for_path(
|
||||
&self,
|
||||
py: Python,
|
||||
path: &str,
|
||||
message: &[u8; 32],
|
||||
) -> PyResult<(Signature, PublicKey)> {
|
||||
let py_signature: Vec<u8> = self
|
||||
.instance
|
||||
.bind(py)
|
||||
.call_method1("sign_message_for_path", (message, path))?
|
||||
.extract()?;
|
||||
|
||||
let signature: [u8; 64] = py_signature.try_into().map_err(|vec: Vec<u8>| {
|
||||
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
|
||||
"Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})",
|
||||
vec.len(),
|
||||
vec
|
||||
))
|
||||
})?;
|
||||
|
||||
let sig = Signature { value: signature };
|
||||
let pub_key = self.get_public_key_for_path(py, path)?;
|
||||
if !sig.is_valid_for(message, &pub_key) {
|
||||
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
|
||||
"keycard returned a signature that does not verify against its own public key",
|
||||
));
|
||||
}
|
||||
Ok((sig, pub_key))
|
||||
}
|
||||
|
||||
pub fn sign_message_for_path_with_connect(
|
||||
pin: &str,
|
||||
path: &str,
|
||||
message: &[u8; 32],
|
||||
) -> PyResult<(Signature, PublicKey)> {
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py)?;
|
||||
let wallet = Self::new(py)?;
|
||||
wallet.connect(py, pin)?;
|
||||
let result = wallet.sign_message_for_path(py, path, message);
|
||||
drop(wallet.close_session(py));
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> {
|
||||
self.instance
|
||||
.bind(py)
|
||||
.call_method1("load_mnemonic", (mnemonic,))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_account_id_for_path_with_connect(pin: &str, key_path: &str) -> PyResult<String> {
|
||||
let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?;
|
||||
|
||||
Ok(format!("Public/{}", AccountId::from(&public_key)))
|
||||
}
|
||||
}
|
||||
|
||||
fn pairing_file_path() -> Option<PathBuf> {
|
||||
let home = std::env::var("NSSA_WALLET_HOME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|_| {
|
||||
std::env::home_dir()
|
||||
.map(|h| h.join(".nssa").join("wallet"))
|
||||
.ok_or(())
|
||||
})
|
||||
.ok()?;
|
||||
Some(home.join("keycard_pairing.json"))
|
||||
}
|
||||
|
||||
fn load_pairing() -> Option<KeycardPairingData> {
|
||||
let path = pairing_file_path()?;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
serde_json::from_reader(file).ok()
|
||||
}
|
||||
|
||||
fn save_pairing(data: &KeycardPairingData) {
|
||||
if let Some(path) = pairing_file_path()
|
||||
&& let Ok(json) = serde_json::to_vec_pretty(data)
|
||||
{
|
||||
drop(std::fs::write(path, json));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_pairing() {
|
||||
if let Some(path) = pairing_file_path() {
|
||||
drop(std::fs::remove_file(path));
|
||||
}
|
||||
}
|
||||
63
keycard_wallet/src/python_path.rs
Normal file
63
keycard_wallet/src/python_path.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use pyo3::{prelude::*, types::PyList};
|
||||
|
||||
/// Adds the project's `python/` directory and venv site-packages to Python's sys.path.
|
||||
pub fn add_python_path(py: Python<'_>) -> PyResult<()> {
|
||||
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
||||
|
||||
let python_base = env::var("VIRTUAL_ENV")
|
||||
.ok()
|
||||
.and_then(|v| PathBuf::from(v).parent().map(PathBuf::from))
|
||||
.unwrap_or_else(|| current_dir.clone());
|
||||
|
||||
let mut paths_to_add: Vec<PathBuf> = vec![
|
||||
python_base.join("keycard_wallet").join("python"),
|
||||
python_base
|
||||
.join("keycard_wallet")
|
||||
.join("python")
|
||||
.join("keycard-py"),
|
||||
];
|
||||
|
||||
// If a virtualenv is active, add its site-packages so that dependencies
|
||||
// installed in the venv (e.g. smartcard, ecdsa) are importable by the
|
||||
// pyo3 embedded interpreter, which does not inherit sys.path from the
|
||||
// shell's `python3` executable.
|
||||
if let Ok(venv) = env::var("VIRTUAL_ENV") {
|
||||
let lib = PathBuf::from(&venv).join("lib");
|
||||
if let Ok(entries) = std::fs::read_dir(&lib) {
|
||||
for entry in entries.flatten() {
|
||||
let site_packages = entry.path().join("site-packages");
|
||||
if site_packages.exists() {
|
||||
paths_to_add.push(site_packages);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check — warns early if a path doesn't exist
|
||||
for path in &paths_to_add {
|
||||
if !path.exists() {
|
||||
log::info!("Warning: Python path does not exist: {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
let sys = PyModule::import(py, "sys")?;
|
||||
let binding = sys.getattr("path")?;
|
||||
let sys_path = binding.downcast::<PyList>()?;
|
||||
|
||||
for path in &paths_to_add {
|
||||
let path_str = path.to_str().expect("Invalid path");
|
||||
|
||||
// Avoid duplicating the path
|
||||
let already_present = sys_path
|
||||
.iter()
|
||||
.any(|p| p.extract::<&str>().map(|s| s == path_str).unwrap_or(false));
|
||||
|
||||
if !already_present {
|
||||
sys_path.insert(0, path_str)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
keycard_wallet/tests/keycard_tests.sh
Executable file
81
keycard_wallet/tests/keycard_tests.sh
Executable file
@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Run wallet_with_keycard.sh first
|
||||
|
||||
source venv/bin/activate # Load the appropriate virtual environment
|
||||
|
||||
export KEYCARD_PIN=111111
|
||||
|
||||
# Tests wallet keycard available
|
||||
# - Checks whether smart reader and keycard are both available.
|
||||
echo "Test: wallet keycard available"
|
||||
wallet keycard available
|
||||
|
||||
# Install a new mnemonic phrase to keycard
|
||||
echo "Test: wallet keycard load"
|
||||
export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then"
|
||||
wallet keycard load
|
||||
unset KEYCARD_MNEMONIC
|
||||
|
||||
echo "Test: wallet auth-transfer init --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet auth-transfer init --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet pinata claim --to \"m/44'/60'/0'/0/0\""
|
||||
wallet pinata claim --to "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet auth-transfer init and send between two keycard accounts"
|
||||
wallet auth-transfer init --account-id "m/44'/60'/0'/0/1"
|
||||
wallet auth-transfer send --amount 40 --from "m/44'/60'/0'/0/0" --to "m/44'/60'/0'/0/1"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/1"
|
||||
|
||||
# Send from keycard account to a local wallet account
|
||||
echo "Test: create local wallet account"
|
||||
LOCAL_ACCOUNT_ID=$(wallet account new public 2>&1 | grep -oP '(?<=Public/)\S+')
|
||||
echo "Created local account: Public/${LOCAL_ACCOUNT_ID}"
|
||||
|
||||
echo "Test: wallet auth-transfer init local account"
|
||||
wallet auth-transfer init --account-id "Public/${LOCAL_ACCOUNT_ID}"
|
||||
|
||||
|
||||
echo "Test: wallet auth-transfer send from keycard to local account"
|
||||
wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/${LOCAL_ACCOUNT_ID}"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\""
|
||||
wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}"
|
||||
|
||||
# Create a local wallet account, fund it, and send to keycard account (co-signed: local key + keycard)
|
||||
|
||||
echo "Test: wallet auth-transfer send from local account to keycard account"
|
||||
wallet auth-transfer send --amount 10 --from "Public/${LOCAL_ACCOUNT_ID}" --to "m/44'/60'/0'/0/1"
|
||||
|
||||
echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\""
|
||||
wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/1"
|
||||
|
||||
# Send from keycard account to a local wallet account (foreign recipient — no signature needed)
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
|
||||
|
||||
echo "Test: wallet auth-transfer send from keycard to local account"
|
||||
wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
|
||||
12
keycard_wallet/wallet_with_keycard.sh
Executable file
12
keycard_wallet/wallet_with_keycard.sh
Executable file
@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
cargo install --path wallet --force
|
||||
|
||||
# Install appropriate version of `keycard-py`.
|
||||
git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git keycard_wallet/python/keycard-py
|
||||
|
||||
# Set up virtual environment.
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install pyscard mnemonic ecdsa pyaes
|
||||
pip install -e keycard_wallet/python/keycard-py
|
||||
@ -14,7 +14,7 @@ faucet_core.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
risc0-zkvm = { workspace = true, features = ["prove"] }
|
||||
risc0-zkvm = { workspace = true, features = ["client"] }
|
||||
serde.workspace = true
|
||||
serde_with.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
@ -469,6 +469,24 @@ mod tests {
|
||||
use test_program_methods::PINATA_COOLDOWN_ELF;
|
||||
Self::new(PINATA_COOLDOWN_ELF.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn malicious_injector() -> Self {
|
||||
use test_program_methods::{MALICIOUS_INJECTOR_ELF, MALICIOUS_INJECTOR_ID};
|
||||
Self {
|
||||
id: MALICIOUS_INJECTOR_ID,
|
||||
elf: MALICIOUS_INJECTOR_ELF.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn malicious_launderer() -> Self {
|
||||
use test_program_methods::{MALICIOUS_LAUNDERER_ELF, MALICIOUS_LAUNDERER_ID};
|
||||
Self {
|
||||
id: MALICIOUS_LAUNDERER_ID,
|
||||
elf: MALICIOUS_LAUNDERER_ELF.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -265,7 +265,11 @@ impl ValidatedStateDiff {
|
||||
state_diff.insert(pre.account_id, post.account().clone());
|
||||
}
|
||||
|
||||
let authorized_accounts: HashSet<_> = chained_call
|
||||
// Source from `program_output.pre_states`, not `chained_call.pre_states`:
|
||||
// the loop above already gates program_output's `is_authorized` via the
|
||||
// `!pre.is_authorized || is_indeed_authorized` check, while `chained_call.
|
||||
// pre_states` is caller-controlled and can be forged (audit-issue 91).
|
||||
let authorized_accounts: HashSet<_> = program_output
|
||||
.pre_states
|
||||
.iter()
|
||||
.filter(|pre| pre.is_authorized)
|
||||
@ -488,3 +492,427 @@ fn n_unique<T: Eq + Hash>(data: &[T]) -> usize {
|
||||
let set: HashSet<&T> = data.iter().collect();
|
||||
set.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nssa_core::account::{AccountId, Nonce};
|
||||
|
||||
use crate::{
|
||||
PrivateKey, PublicKey, V03State,
|
||||
error::{InvalidProgramBehaviorError, NssaError},
|
||||
program::Program,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
validated_state_diff::ValidatedStateDiff,
|
||||
};
|
||||
|
||||
/// Privacy-path version of the authorization-injection attack. The test passes when the
|
||||
/// attack is rejected and the victim's balance is left untouched.
|
||||
///
|
||||
/// `execute_and_prove` succeeds because each inner receipt is individually valid and the
|
||||
/// outer circuit faithfully commits whatever the attacker's program output says, including
|
||||
/// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know
|
||||
/// the victim never signed.
|
||||
///
|
||||
/// The host-side validator is what catches the attack: it independently reconstructs
|
||||
/// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`,
|
||||
/// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed
|
||||
/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction`
|
||||
/// returns an error before any state is applied.
|
||||
#[test]
|
||||
fn privacy_malicious_programs_cannot_drain_public_victim() {
|
||||
use nssa_core::{
|
||||
Commitment, InputAccountIdentity, SharedSecretKey,
|
||||
account::{Account, AccountWithMetadata},
|
||||
encryption::EphemeralPublicKey,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
PrivacyPreservingTransaction,
|
||||
privacy_preserving_transaction::{
|
||||
circuit::{ProgramWithDependencies, execute_and_prove},
|
||||
message::Message,
|
||||
witness_set::WitnessSet,
|
||||
},
|
||||
state::{CommitmentSet, tests::test_private_account_keys_1},
|
||||
};
|
||||
|
||||
type InjectorInstruction = (
|
||||
nssa_core::program::ProgramId, // p2_id
|
||||
nssa_core::program::ProgramId, // auth_transfer_id
|
||||
[u8; 32], // victim_id_raw
|
||||
u128, // victim_balance
|
||||
u128, // victim_nonce
|
||||
nssa_core::program::ProgramId, // victim_program_owner
|
||||
[u8; 32], // recipient_id_raw
|
||||
u128, // amount
|
||||
);
|
||||
|
||||
// Attacker controls a private account.
|
||||
let attacker_keys = test_private_account_keys_1();
|
||||
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
|
||||
let attacker_esk = [12_u8; 32];
|
||||
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
|
||||
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
|
||||
|
||||
let victim_id = AccountId::new([20_u8; 32]);
|
||||
let recipient_id = AccountId::new([42_u8; 32]);
|
||||
let victim_balance = 5_000_u128;
|
||||
|
||||
// genesis sets program_owner = authenticated_transfer_program.id() on all accounts.
|
||||
let mut state = V03State::new_with_genesis_accounts(
|
||||
&[(victim_id, victim_balance), (recipient_id, 0)],
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
state.insert_program(Program::malicious_injector());
|
||||
state.insert_program(Program::malicious_launderer());
|
||||
|
||||
// Build attacker's private account and its local commitment tree.
|
||||
let attacker_account = Account {
|
||||
program_owner: Program::authenticated_transfer_program().id(),
|
||||
balance: 100,
|
||||
..Account::default()
|
||||
};
|
||||
let attacker_commitment = Commitment::new(&attacker_id, &attacker_account);
|
||||
let mut commitment_set = CommitmentSet::with_capacity(1);
|
||||
commitment_set.extend(std::slice::from_ref(&attacker_commitment));
|
||||
let membership_proof = commitment_set
|
||||
.get_proof_for(&attacker_commitment)
|
||||
.expect("attacker commitment must be in the set");
|
||||
|
||||
let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id);
|
||||
|
||||
let victim_account = state.get_account_by_id(victim_id);
|
||||
let instruction: InjectorInstruction = (
|
||||
Program::malicious_launderer().id(),
|
||||
Program::authenticated_transfer_program().id(),
|
||||
*victim_id.value(),
|
||||
victim_account.balance,
|
||||
victim_account.nonce.0,
|
||||
victim_account.program_owner,
|
||||
*recipient_id.value(),
|
||||
victim_balance,
|
||||
);
|
||||
let instruction_data = Program::serialize_instruction(instruction).unwrap();
|
||||
|
||||
let p2 = Program::malicious_launderer();
|
||||
let at = Program::authenticated_transfer_program();
|
||||
let program_with_deps = ProgramWithDependencies::new(
|
||||
Program::malicious_injector(),
|
||||
[(p2.id(), p2), (at.id(), at)].into(),
|
||||
);
|
||||
|
||||
// account_identities order must match self.pre_states as built by the circuit:
|
||||
// [0] attacker — first seen in P1's program_output.pre_states
|
||||
// [1] victim — first seen in authenticated_transfer's program_output.pre_states
|
||||
// [2] recipient — first seen in authenticated_transfer's program_output.pre_states
|
||||
let account_identities = vec![
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
ssk: attacker_ssk,
|
||||
nsk: attacker_keys.nsk,
|
||||
membership_proof,
|
||||
identifier: 0,
|
||||
},
|
||||
InputAccountIdentity::Public, // victim
|
||||
InputAccountIdentity::Public, // recipient
|
||||
];
|
||||
|
||||
// execute_and_prove succeeds: all inner receipts are valid.
|
||||
// The outer circuit commits victim(is_authorized=true) to its journal.
|
||||
let (circuit_output, proof) = execute_and_prove(
|
||||
vec![attacker_pre],
|
||||
instruction_data,
|
||||
account_identities,
|
||||
&program_with_deps,
|
||||
)
|
||||
.expect("execute_and_prove should succeed \u{2014} the programs execute correctly");
|
||||
|
||||
// public_account_ids lists the Public entries from account_identities, in order.
|
||||
// The single ciphertext belongs to attacker's private account update.
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![victim_id, recipient_id],
|
||||
vec![], // no public signers, no nonces
|
||||
vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)],
|
||||
circuit_output,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
|
||||
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(NssaError::InvalidPrivacyPreservingProof)),
|
||||
"attack privacy transaction should be rejected with InvalidPrivacyPreservingProof"
|
||||
);
|
||||
assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance);
|
||||
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
|
||||
}
|
||||
|
||||
/// Private-victim variant of the authorization-injection attack. The test passes when the
|
||||
/// attack is rejected and the recipient's balance remains zero.
|
||||
///
|
||||
/// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)`
|
||||
/// verbatim, the attacker must choose how to declare the victim in `account_identities`.
|
||||
/// There are two routes, both closed:
|
||||
///
|
||||
/// - **mask=1 (`PrivateAuthorizedUpdate`)**: the circuit derives `account_id =
|
||||
/// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches
|
||||
/// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker
|
||||
/// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced.
|
||||
///
|
||||
/// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and
|
||||
/// `execute_and_prove` succeeds. The host-side validator then reconstructs
|
||||
/// `public_pre_states` from chain state; `state.get_account_by_id(victim_id)` returns the
|
||||
/// default account (balance=0) because the victim has no public state entry. The committed
|
||||
/// journal and the reconstructed expected output diverge, `receipt.verify` fails, and
|
||||
/// `from_privacy_preserving_transaction` returns an error before any state is applied. This
|
||||
/// test exercises this route.
|
||||
#[test]
|
||||
fn privacy_malicious_programs_cannot_drain_private_victim() {
|
||||
use nssa_core::{
|
||||
Commitment, InputAccountIdentity, SharedSecretKey,
|
||||
account::{Account, AccountWithMetadata},
|
||||
encryption::EphemeralPublicKey,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
PrivacyPreservingTransaction,
|
||||
privacy_preserving_transaction::{
|
||||
circuit::{ProgramWithDependencies, execute_and_prove},
|
||||
message::Message,
|
||||
witness_set::WitnessSet,
|
||||
},
|
||||
state::{
|
||||
CommitmentSet,
|
||||
tests::{test_private_account_keys_1, test_private_account_keys_2},
|
||||
},
|
||||
};
|
||||
|
||||
type InjectorInstruction = (
|
||||
nssa_core::program::ProgramId, // p2_id
|
||||
nssa_core::program::ProgramId, // auth_transfer_id
|
||||
[u8; 32], // victim_id_raw
|
||||
u128, // victim_balance
|
||||
u128, // victim_nonce
|
||||
nssa_core::program::ProgramId, // victim_program_owner
|
||||
[u8; 32], // recipient_id_raw
|
||||
u128, // amount
|
||||
);
|
||||
|
||||
// Attacker controls a private account.
|
||||
let attacker_keys = test_private_account_keys_1();
|
||||
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
|
||||
let attacker_esk = [12_u8; 32];
|
||||
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
|
||||
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
|
||||
|
||||
// Victim is a private account — not registered in public chain state.
|
||||
let victim_keys = test_private_account_keys_2();
|
||||
let victim_id = AccountId::for_regular_private_account(&victim_keys.npk(), 0);
|
||||
let victim_balance = 5_000_u128;
|
||||
|
||||
let recipient_id = AccountId::new([42_u8; 32]);
|
||||
|
||||
// Victim has no public state entry; only recipient is registered at genesis.
|
||||
let mut state = V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], 0);
|
||||
state.insert_program(Program::malicious_injector());
|
||||
state.insert_program(Program::malicious_launderer());
|
||||
|
||||
// Build attacker's private account and its local commitment tree.
|
||||
let attacker_account = Account {
|
||||
program_owner: Program::authenticated_transfer_program().id(),
|
||||
balance: 100,
|
||||
..Account::default()
|
||||
};
|
||||
let attacker_commitment = Commitment::new(&attacker_id, &attacker_account);
|
||||
let mut commitment_set = CommitmentSet::with_capacity(1);
|
||||
commitment_set.extend(std::slice::from_ref(&attacker_commitment));
|
||||
let membership_proof = commitment_set
|
||||
.get_proof_for(&attacker_commitment)
|
||||
.expect("attacker commitment must be in the set");
|
||||
|
||||
let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id);
|
||||
|
||||
// The attacker supplies the victim's account data directly — it cannot be read from
|
||||
// public state. The injected balance and program_owner allow authenticated_transfer
|
||||
// to succeed inside the circuit, which has no access to chain state and cannot detect
|
||||
// that these values are fabricated.
|
||||
let instruction: InjectorInstruction = (
|
||||
Program::malicious_launderer().id(),
|
||||
Program::authenticated_transfer_program().id(),
|
||||
*victim_id.value(),
|
||||
victim_balance,
|
||||
0_u128, // nonce
|
||||
Program::authenticated_transfer_program().id(), // program_owner
|
||||
*recipient_id.value(),
|
||||
victim_balance,
|
||||
);
|
||||
let instruction_data = Program::serialize_instruction(instruction).unwrap();
|
||||
|
||||
let p2 = Program::malicious_launderer();
|
||||
let at = Program::authenticated_transfer_program();
|
||||
let program_with_deps = ProgramWithDependencies::new(
|
||||
Program::malicious_injector(),
|
||||
[(p2.id(), p2), (at.id(), at)].into(),
|
||||
);
|
||||
|
||||
// account_identities order must match self.pre_states as built by the circuit:
|
||||
// [0] attacker — first seen in P1's program_output.pre_states
|
||||
// [1] victim — first seen in authenticated_transfer's program_output.pre_states
|
||||
// [2] recipient — first seen in authenticated_transfer's program_output.pre_states
|
||||
//
|
||||
// Victim is marked Public: the attacker has no nsk for the victim's private account,
|
||||
// so PrivateAuthorizedUpdate is not an option.
|
||||
let account_identities = vec![
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
ssk: attacker_ssk,
|
||||
nsk: attacker_keys.nsk,
|
||||
membership_proof,
|
||||
identifier: 0,
|
||||
},
|
||||
InputAccountIdentity::Public, // victim — attacker lacks victim's nsk
|
||||
InputAccountIdentity::Public, // recipient
|
||||
];
|
||||
|
||||
// execute_and_prove succeeds: authenticated_transfer runs against the injected
|
||||
// victim(balance=5000, is_authorized=true) and produces valid inner receipts.
|
||||
// The outer circuit commits victim(is_authorized=true) to public_pre_states.
|
||||
let (circuit_output, proof) = execute_and_prove(
|
||||
vec![attacker_pre],
|
||||
instruction_data,
|
||||
account_identities,
|
||||
&program_with_deps,
|
||||
)
|
||||
.expect("execute_and_prove should succeed \u{2014} the programs execute correctly");
|
||||
|
||||
// public_account_ids lists the Public entries from account_identities, in order.
|
||||
// The single ciphertext belongs to attacker's private account update.
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![victim_id, recipient_id],
|
||||
vec![], // no public signers, no nonces
|
||||
vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)],
|
||||
circuit_output,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
|
||||
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(NssaError::InvalidPrivacyPreservingProof)),
|
||||
"attack on private victim should be rejected with InvalidPrivacyPreservingProof"
|
||||
);
|
||||
// Victim has no public balance to check; confirming the recipient received nothing
|
||||
// is sufficient to show no funds moved.
|
||||
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
|
||||
}
|
||||
|
||||
/// Two malicious programs (injector + launderer) attempt to drain a victim's balance
|
||||
/// without the victim signing anything. The test passes when the attack is rejected
|
||||
/// and the victim's balance is left untouched.
|
||||
///
|
||||
/// Attack flow:
|
||||
/// Transaction (attacker signs) → P1 (`malicious_injector`)
|
||||
/// → injects `victim(is_authorized=true)` into chained-call `pre_states` for P2
|
||||
/// P2 (`malicious_launderer`)
|
||||
/// → outputs empty pre/post states, forwarding the forged flag to `authenticated_transfer`
|
||||
/// → if `authorized_accounts` were built from the injected `pre_states`,
|
||||
/// `{victim}.contains(victim)` would pass and the transfer would execute.
|
||||
///
|
||||
/// The validator must reject this: `authorized_accounts` must be derived from the
|
||||
/// parent program's own validated `program_output.pre_states`, not from the chained-call
|
||||
/// input, so a forged `is_authorized=true` flag is never trusted.
|
||||
#[test]
|
||||
fn malicious_programs_cannot_drain_victim_without_signature() {
|
||||
// p2_id, auth_transfer_id, victim_id_raw, victim_balance, victim_nonce,
|
||||
// victim_program_owner, recipient_id_raw, amount.
|
||||
// Primitives only — AccountId/Account cannot round-trip through instruction_data
|
||||
// via risc0_zkvm::serde (SerializeDisplay issue).
|
||||
type InjectorInstruction = (
|
||||
nssa_core::program::ProgramId, // p2_id
|
||||
nssa_core::program::ProgramId, // auth_transfer_id
|
||||
[u8; 32], // victim_id_raw
|
||||
u128, // victim_balance
|
||||
u128, // victim_nonce
|
||||
nssa_core::program::ProgramId, // victim_program_owner
|
||||
[u8; 32], // recipient_id_raw
|
||||
u128, // amount
|
||||
);
|
||||
|
||||
let attacker_key = PrivateKey::try_new([10; 32]).unwrap();
|
||||
let attacker_id = AccountId::from(&PublicKey::new_from_private_key(&attacker_key));
|
||||
|
||||
let victim_key = PrivateKey::try_new([20; 32]).unwrap();
|
||||
let victim_id = AccountId::from(&PublicKey::new_from_private_key(&victim_key));
|
||||
|
||||
let recipient_id = AccountId::new([42; 32]);
|
||||
|
||||
let victim_balance = 5_000_u128;
|
||||
let mut state = V03State::new_with_genesis_accounts(
|
||||
&[
|
||||
(attacker_id, 100),
|
||||
(victim_id, victim_balance),
|
||||
(recipient_id, 0),
|
||||
],
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
|
||||
state.insert_program(Program::malicious_injector());
|
||||
state.insert_program(Program::malicious_launderer());
|
||||
|
||||
// Read victim state from chain, exactly as the attacker would.
|
||||
let victim_account = state.get_account_by_id(victim_id);
|
||||
|
||||
let instruction: InjectorInstruction = (
|
||||
Program::malicious_launderer().id(),
|
||||
Program::authenticated_transfer_program().id(),
|
||||
*victim_id.value(),
|
||||
victim_account.balance,
|
||||
victim_account.nonce.0,
|
||||
victim_account.program_owner,
|
||||
*recipient_id.value(),
|
||||
victim_balance,
|
||||
);
|
||||
|
||||
let message = Message::try_new(
|
||||
Program::malicious_injector().id(),
|
||||
vec![attacker_id],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, &[&attacker_key]);
|
||||
let tx = crate::PublicTransaction::new(message, witness_set);
|
||||
|
||||
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(NssaError::InvalidProgramBehavior(
|
||||
InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id }
|
||||
)) if account_id == victim_id
|
||||
),
|
||||
"attack transaction should be rejected with InvalidAccountAuthorization for the victim"
|
||||
);
|
||||
|
||||
// Confirm the victim's balance is untouched.
|
||||
let victim_balance_after = state.get_account_by_id(victim_id).balance;
|
||||
let recipient_balance_after = state.get_account_by_id(recipient_id).balance;
|
||||
|
||||
assert_eq!(
|
||||
victim_balance_after, victim_balance,
|
||||
"victim balance should be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
recipient_balance_after, 0,
|
||||
"recipient should receive nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,12 +326,22 @@ impl TestContextBuilder {
|
||||
|
||||
let initial_public_accounts = config::default_public_accounts_for_wallet();
|
||||
let initial_private_accounts = config::default_private_accounts_for_wallet();
|
||||
// Wallet genesis must always be present so that
|
||||
// setup_public/private_accounts_with_initial_supply can claim from the vault PDAs.
|
||||
// When a test supplies custom genesis, merge rather than replace.
|
||||
let wallet_genesis =
|
||||
config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts);
|
||||
let genesis = match genesis_transactions {
|
||||
Some(mut custom) => {
|
||||
custom.extend(wallet_genesis);
|
||||
custom
|
||||
}
|
||||
None => wallet_genesis,
|
||||
};
|
||||
let (sequencer_handle, temp_sequencer_dir) = setup_sequencer(
|
||||
sequencer_partial_config.unwrap_or_default(),
|
||||
bedrock_addr,
|
||||
genesis_transactions.unwrap_or_else(|| {
|
||||
config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts)
|
||||
}),
|
||||
genesis,
|
||||
)
|
||||
.await
|
||||
.context("Failed to setup Sequencer")?;
|
||||
|
||||
105
test_program_methods/guest/src/bin/malicious_injector.rs
Normal file
105
test_program_methods/guest/src/bin/malicious_injector.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
||||
program::{
|
||||
AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs,
|
||||
},
|
||||
};
|
||||
|
||||
/// Instruction uses only risc0-serde-compatible primitives — no `AccountId`/`Account` structs,
|
||||
/// which use `SerializeDisplay`/`DeserializeFromStr` and cannot round-trip through
|
||||
/// `instruction_data`.
|
||||
///
|
||||
/// Fields:
|
||||
/// `p2_id`: program ID of the launderer (P2)
|
||||
/// `auth_transfer_id`: program ID of `authenticated_transfer`, forwarded to P2
|
||||
/// `victim_id_raw`: raw `[u8; 32]` of the victim `AccountId`
|
||||
/// `victim_balance`: victim's current balance
|
||||
/// `victim_nonce`: victim's current nonce (inner `u128`)
|
||||
/// `victim_program_owner`: victim account's `program_owner` field
|
||||
/// `recipient_id_raw`: raw `[u8; 32]` of the recipient `AccountId`
|
||||
/// `amount`: balance to transfer out of the victim.
|
||||
type Instruction = (
|
||||
ProgramId,
|
||||
ProgramId,
|
||||
[u8; 32],
|
||||
u128,
|
||||
u128,
|
||||
ProgramId,
|
||||
[u8; 32],
|
||||
u128,
|
||||
);
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction:
|
||||
(
|
||||
p2_id,
|
||||
auth_transfer_id,
|
||||
victim_id_raw,
|
||||
victim_balance,
|
||||
victim_nonce,
|
||||
victim_program_owner,
|
||||
recipient_id_raw,
|
||||
amount,
|
||||
),
|
||||
},
|
||||
instruction_words,
|
||||
) = read_nssa_inputs::<Instruction>();
|
||||
|
||||
// Echo own pre_states (attacker's account) unchanged.
|
||||
let post_states = pre_states
|
||||
.iter()
|
||||
.map(|p| AccountPostState::new(p.account.clone()))
|
||||
.collect();
|
||||
|
||||
// Construct victim AccountWithMetadata from primitives, stamping is_authorized=true.
|
||||
// Victim has not signed anything — this flag is forged entirely by P1's logic.
|
||||
let victim = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: victim_program_owner,
|
||||
balance: victim_balance,
|
||||
data: Data::default(),
|
||||
nonce: Nonce(victim_nonce),
|
||||
},
|
||||
is_authorized: true,
|
||||
account_id: AccountId::new(victim_id_raw),
|
||||
};
|
||||
|
||||
// Recipient is already initialized under authenticated_transfer (program_owner =
|
||||
// auth_transfer_id, balance = 0). Using the default account would trigger
|
||||
// Claim::Authorized inside authenticated_transfer, which requires is_authorized=true
|
||||
// on the recipient — a check that would block the transfer.
|
||||
let recipient = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: auth_transfer_id,
|
||||
balance: 0,
|
||||
data: Data::default(),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: AccountId::new(recipient_id_raw),
|
||||
};
|
||||
|
||||
// Forward auth_transfer_id and amount to P2 so it can call authenticated_transfer.
|
||||
let p2_instruction = risc0_zkvm::serde::to_vec(&(auth_transfer_id, amount))
|
||||
.expect("serialization is infallible");
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
pre_states,
|
||||
post_states,
|
||||
)
|
||||
.with_chained_calls(vec![ChainedCall {
|
||||
program_id: p2_id,
|
||||
pre_states: vec![victim, recipient],
|
||||
instruction_data: p2_instruction,
|
||||
pda_seeds: vec![],
|
||||
}])
|
||||
.write();
|
||||
}
|
||||
43
test_program_methods/guest/src/bin/malicious_launderer.rs
Normal file
43
test_program_methods/guest/src/bin/malicious_launderer.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use nssa_core::program::{ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs};
|
||||
|
||||
/// Instruction: (`auth_transfer_id`, `amount`) — both primitive, safe for `risc0_zkvm::serde`.
|
||||
type Instruction = (ProgramId, u128);
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction: (auth_transfer_id, amount),
|
||||
},
|
||||
instruction_words,
|
||||
) = read_nssa_inputs::<Instruction>();
|
||||
|
||||
// Output empty pre/post states. P2 processes no accounts itself, so the
|
||||
// authorization check at validated_state_diff.rs:158-182 runs over nothing.
|
||||
// Victim is never compared against caller_data.authorized_accounts = {attacker}.
|
||||
//
|
||||
// The bug: authorized_accounts for authenticated_transfer is built from
|
||||
// chained_call.pre_states (this call's inputs, set by P1), which contains
|
||||
// victim(is_authorized=true). So authorized_accounts = {victim}, and the
|
||||
// subsequent check passes.
|
||||
let auth_transfer_instruction =
|
||||
risc0_zkvm::serde::to_vec(&authenticated_transfer_core::Instruction::Transfer { amount })
|
||||
.expect("serialization is infallible");
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.with_chained_calls(vec![ChainedCall {
|
||||
program_id: auth_transfer_id,
|
||||
pre_states,
|
||||
instruction_data: auth_transfer_instruction,
|
||||
pda_seeds: vec![],
|
||||
}])
|
||||
.write();
|
||||
}
|
||||
@ -8,11 +8,12 @@ publish = false
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
[dev-dependencies]
|
||||
key_protocol.workspace = true
|
||||
nssa_core = { workspace = true, features = ["host"] }
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rand = { workspace = true }
|
||||
criterion.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "primitives"
|
||||
harness = false
|
||||
|
||||
@ -1,20 +1,29 @@
|
||||
# crypto_primitives_bench
|
||||
|
||||
Cryptographic primitive microbenchmarks used by client/wallet code. Single host binary, no live sequencer or Bedrock needed.
|
||||
Criterion-driven microbenchmarks for the cryptographic primitives client/wallet code uses on every transaction. No live sequencer or Bedrock needed.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
cargo run --release -p crypto_primitives_bench
|
||||
cargo bench -p crypto_primitives_bench --bench primitives
|
||||
```
|
||||
|
||||
## What you'll see
|
||||
|
||||
Per-operation `best_us`, `mean_us`, and `stdev_us` over 100 iterations (plus 2 warmup):
|
||||
Criterion's per-operation report (point estimate, 95% CI, outlier counts) for:
|
||||
|
||||
- `KeyChain::new_os_random` — full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
|
||||
- `KeyChain::new_mnemonic` — same pipeline, mnemonic exposed.
|
||||
- `SharedSecretKey::new (sender DH)` — secp256k1 ECDH per recipient.
|
||||
- `EncryptionScheme::encrypt` / `decrypt` — ChaCha20 over an Account note.
|
||||
- `keychain/new_os_random`: full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
|
||||
- `keychain/new_mnemonic`: same pipeline, mnemonic exposed.
|
||||
- `shared_secret_key/sender_dh`: secp256k1 ECDH per recipient (includes ephemeral key gen).
|
||||
- `encryption/encrypt` / `decrypt`: ChaCha20 over an Account note.
|
||||
|
||||
JSON output is written to `target/crypto_primitives_bench.json`.
|
||||
Per-bench JSON estimates are written under `target/criterion/<group>/<bench>/`. HTML reports at `target/criterion/report/index.html`.
|
||||
|
||||
## Baseline comparison
|
||||
|
||||
```sh
|
||||
# On main:
|
||||
cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main
|
||||
# On your branch:
|
||||
cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main
|
||||
```
|
||||
|
||||
91
tools/crypto_primitives_bench/benches/primitives.rs
Normal file
91
tools/crypto_primitives_bench/benches/primitives.rs
Normal file
@ -0,0 +1,91 @@
|
||||
//! Criterion microbenchmarks for client/wallet cryptographic primitives.
|
||||
//!
|
||||
//! Measures:
|
||||
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
|
||||
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
|
||||
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
|
||||
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use key_protocol::key_management::KeyChain;
|
||||
use nssa_core::{
|
||||
Commitment, EncryptionScheme, SharedSecretKey,
|
||||
account::{Account, AccountId},
|
||||
encryption::{EphemeralPublicKey, EphemeralSecretKey},
|
||||
program::PrivateAccountKind,
|
||||
};
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
|
||||
fn bench_keychain(c: &mut Criterion) {
|
||||
let mut g = c.benchmark_group("keychain");
|
||||
g.sample_size(50).noise_threshold(0.05);
|
||||
g.bench_function("new_os_random", |b| b.iter(KeyChain::new_os_random));
|
||||
g.bench_function("new_mnemonic", |b| {
|
||||
b.iter(|| {
|
||||
let (_kc, _mnemonic) = KeyChain::new_mnemonic("");
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_shared_secret_key(c: &mut Criterion) {
|
||||
// One-time setup: recipient's viewing public key (sender side bench).
|
||||
let recipient_kc = KeyChain::new_os_random();
|
||||
let vpk = recipient_kc.viewing_public_key;
|
||||
|
||||
let mut g = c.benchmark_group("shared_secret_key");
|
||||
g.sample_size(50).noise_threshold(0.05);
|
||||
g.bench_function("sender_dh", |b| {
|
||||
b.iter(|| {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let esk: EphemeralSecretKey = bytes;
|
||||
let _epk = EphemeralPublicKey::from(&esk);
|
||||
SharedSecretKey::new(esk, &vpk)
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
fn bench_encryption(c: &mut Criterion) {
|
||||
// One-time setup: a fixed Account/Commitment and a SharedSecretKey to bench
|
||||
// encrypt/decrypt over a representative note. ESK gen is excluded from the
|
||||
// measured loop (covered by the SharedSecretKey bench above).
|
||||
let recipient_kc = KeyChain::new_os_random();
|
||||
let vpk = recipient_kc.viewing_public_key;
|
||||
let npk = recipient_kc.nullifier_public_key;
|
||||
let account = Account::default();
|
||||
let account_id = AccountId::for_regular_private_account(&npk, 0);
|
||||
let commitment = Commitment::new(&account_id, &account);
|
||||
let shared = {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let esk: EphemeralSecretKey = bytes;
|
||||
SharedSecretKey::new(esk, &vpk)
|
||||
};
|
||||
let kind = PrivateAccountKind::Regular(0_u128);
|
||||
let output_index: u32 = 0;
|
||||
|
||||
let mut g = c.benchmark_group("encryption");
|
||||
g.sample_size(50).noise_threshold(0.05);
|
||||
g.bench_function("encrypt", |b| {
|
||||
b.iter(|| EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index));
|
||||
});
|
||||
// One ciphertext for the decrypt bench (encrypt is deterministic given inputs).
|
||||
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
|
||||
g.bench_function("decrypt", |b| {
|
||||
b.iter(|| EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index));
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default()
|
||||
.warm_up_time(Duration::from_secs(2))
|
||||
.measurement_time(Duration::from_secs(10));
|
||||
targets = bench_keychain, bench_shared_secret_key, bench_encryption
|
||||
}
|
||||
criterion_main!(benches);
|
||||
@ -1,175 +0,0 @@
|
||||
//! Cryptographic primitive microbenchmarks used by client/wallet code.
|
||||
//!
|
||||
//! Measures:
|
||||
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
|
||||
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
|
||||
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
|
||||
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
|
||||
//!
|
||||
//! Reports best-of-N wall time per operation. No live stack required.
|
||||
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::as_conversions,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::float_arithmetic,
|
||||
clippy::print_stdout,
|
||||
reason = "Bench tool"
|
||||
)]
|
||||
|
||||
use std::{path::PathBuf, time::Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use key_protocol::key_management::KeyChain;
|
||||
use nssa_core::{
|
||||
Commitment, EncryptionScheme, SharedSecretKey,
|
||||
account::{Account, AccountId},
|
||||
encryption::{EphemeralPublicKey, EphemeralSecretKey},
|
||||
program::PrivateAccountKind,
|
||||
};
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
use serde::Serialize;
|
||||
|
||||
const ITERS: usize = 100;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpResult {
|
||||
op: &'static str,
|
||||
iters: usize,
|
||||
best_us: f64,
|
||||
mean_us: f64,
|
||||
stdev_us: f64,
|
||||
}
|
||||
|
||||
fn time<F: FnMut()>(op: &'static str, iters: usize, mut f: F) -> OpResult {
|
||||
// Warmup
|
||||
for _ in 0..2 {
|
||||
f();
|
||||
}
|
||||
let mut samples_ns: Vec<f64> = Vec::with_capacity(iters);
|
||||
for _ in 0..iters {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
samples_ns.push(t.elapsed().as_nanos() as f64);
|
||||
}
|
||||
let best_ns = samples_ns.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let mean_ns: f64 = samples_ns.iter().sum::<f64>() / iters as f64;
|
||||
let stdev_ns = if iters > 1 {
|
||||
let var: f64 = samples_ns
|
||||
.iter()
|
||||
.map(|s| (s - mean_ns).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (iters - 1) as f64;
|
||||
var.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
OpResult {
|
||||
op,
|
||||
iters,
|
||||
best_us: best_ns / 1_000.0,
|
||||
mean_us: mean_ns / 1_000.0,
|
||||
stdev_us: stdev_ns / 1_000.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut results: Vec<OpResult> = Vec::new();
|
||||
|
||||
results.push(time("KeyChain::new_os_random", ITERS, || {
|
||||
let _kc = KeyChain::new_os_random();
|
||||
}));
|
||||
|
||||
results.push(time("KeyChain::new_mnemonic", ITERS, || {
|
||||
let (_kc, _mnemonic) = KeyChain::new_mnemonic("");
|
||||
}));
|
||||
|
||||
// SharedSecretKey: caller has ephemeral secret, recipient has VSK→VPK.
|
||||
// We bench the SENDER side: derive ephemeral pubkey, then SharedSecretKey::new(scalar, point).
|
||||
let recipient_kc = KeyChain::new_os_random();
|
||||
let vpk = recipient_kc.viewing_public_key;
|
||||
results.push(time("SharedSecretKey::new (sender DH)", ITERS, || {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let esk: EphemeralSecretKey = bytes;
|
||||
let _epk = EphemeralPublicKey::from(&esk);
|
||||
let _ssk = SharedSecretKey::new(esk, &vpk);
|
||||
}));
|
||||
|
||||
// EncryptionScheme::encrypt / decrypt over a small Account note.
|
||||
let account = Account::default();
|
||||
let account_id = AccountId::new([7; 32]);
|
||||
let commitment = Commitment::new(&account_id, &account);
|
||||
let shared = {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let esk: EphemeralSecretKey = bytes;
|
||||
SharedSecretKey::new(esk, &vpk)
|
||||
};
|
||||
let kind = PrivateAccountKind::Regular(0_u128);
|
||||
let output_index: u32 = 0;
|
||||
|
||||
let mut produced_ct = None;
|
||||
results.push(time("EncryptionScheme::encrypt", ITERS, || {
|
||||
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
|
||||
produced_ct = Some(ct);
|
||||
}));
|
||||
let ct = produced_ct.expect("encrypt produced ciphertext");
|
||||
results.push(time("EncryptionScheme::decrypt", ITERS, || {
|
||||
let _decoded = EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index);
|
||||
}));
|
||||
|
||||
print_table(&results);
|
||||
write_json(&results)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_table(results: &[OpResult]) {
|
||||
let ow = results
|
||||
.iter()
|
||||
.map(|r| r.op.len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("op".len());
|
||||
let cw = 22_usize;
|
||||
println!(
|
||||
"{:<ow$} {:>6} {:>cw$} {:>cw$} {:>cw$}",
|
||||
"op", "iters", "best_us (ms)", "mean_us (ms)", "stdev_us (ms)",
|
||||
);
|
||||
println!("{}", "-".repeat(ow + 6 + cw * 3 + 8));
|
||||
for r in results {
|
||||
println!(
|
||||
"{:<ow$} {:>6} {:>cw$} {:>cw$} {:>cw$}",
|
||||
r.op,
|
||||
r.iters,
|
||||
fmt_us_ms(r.best_us),
|
||||
fmt_us_ms(r.mean_us),
|
||||
fmt_us_ms(r.stdev_us),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_us_ms(us: f64) -> String {
|
||||
let ms = us / 1_000.0;
|
||||
if ms < 0.01 {
|
||||
format!("{us:.2} (<0.01 ms)")
|
||||
} else {
|
||||
format!("{us:.2} ({ms:.2} ms)")
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json(results: &[OpResult]) -> Result<()> {
|
||||
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()?;
|
||||
let out_path = workspace_root
|
||||
.join("target")
|
||||
.join("crypto_primitives_bench.json");
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&out_path, serde_json::to_string_pretty(&results)?)?;
|
||||
println!("\nJSON written to {}", out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
@ -16,6 +16,7 @@ ppe = ["prove"]
|
||||
[dependencies]
|
||||
nssa = { workspace = true }
|
||||
nssa_core = { workspace = true, features = ["host"] }
|
||||
authenticated_transfer_core.workspace = true
|
||||
clock_core.workspace = true
|
||||
token_core.workspace = true
|
||||
amm_core.workspace = true
|
||||
@ -27,3 +28,11 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "verify"
|
||||
harness = false
|
||||
required-features = ["ppe"]
|
||||
|
||||
@ -4,6 +4,8 @@ Per-program Risc0 cycle counts, prover wall time, PPE composition cost, and veri
|
||||
|
||||
## Run
|
||||
|
||||
The binary handles executor cycles, prover wall time, and PPE composition cost:
|
||||
|
||||
```sh
|
||||
# Executor cycles only (fast, ~seconds)
|
||||
cargo run --release -p cycle_bench
|
||||
@ -13,16 +15,30 @@ cargo run --release -p cycle_bench --features prove -- --prove
|
||||
|
||||
# + PPE composition cases (very slow, ~hour)
|
||||
cargo run --release -p cycle_bench --features ppe -- --prove --ppe
|
||||
|
||||
# + verifier microbench (G_verify): generates one PPE receipt, times verify x1000
|
||||
cargo run --release -p cycle_bench --features ppe -- --verify --verify-iters 1000
|
||||
```
|
||||
|
||||
`RISC0_DEV_MODE=1` skips proving entirely and is only useful for the executor path. Combine flags freely; output is printed to stdout and written to `target/cycle_bench.json` for regression diffs.
|
||||
The verifier microbenchmark (`G_verify`) lives in a criterion bench under `benches/verify.rs`:
|
||||
|
||||
```sh
|
||||
# Generates one PPE receipt for auth_transfer Transfer (~minutes of setup),
|
||||
# then times Receipt::verify under criterion's statistical sampler.
|
||||
cargo bench -p cycle_bench --features ppe --bench verify
|
||||
```
|
||||
|
||||
`RISC0_DEV_MODE=1` skips proving entirely and is only useful for the executor path. The bin writes to `target/cycle_bench.json`; criterion writes per-bench estimates under `target/criterion/`.
|
||||
|
||||
## What you'll see
|
||||
|
||||
- Per-program executor cycles and segments, plus exec wall time as `best / mean ± stdev (n=N)`.
|
||||
- With `--prove`: prover total cycles, paging cycles, segments, and wall time.
|
||||
- With `--ppe`: end-to-end `execute_and_prove` wall time and S_agg (the borsh-serialized InnerReceipt length) for one auth-transfer-in-PPE case and a chain-caller depth sweep.
|
||||
- With `--verify`: verify wall time `best / mean ± stdev`, plus `proof_bytes` and `journal_bytes`.
|
||||
- With `--ppe`: end-to-end `execute_and_prove` wall time and `S_agg` (the borsh-serialized InnerReceipt length) for one auth-transfer-in-PPE case and a chain-caller depth sweep.
|
||||
- From the `verify` criterion bench: `ppe/verify_auth_transfer` slope-regression point estimate with 95% CI bounds.
|
||||
|
||||
## Baseline comparison (verify bench)
|
||||
|
||||
```sh
|
||||
# On main:
|
||||
cargo bench -p cycle_bench --features ppe --bench verify -- --save-baseline main
|
||||
# On your branch:
|
||||
cargo bench -p cycle_bench --features ppe --bench verify -- --baseline main
|
||||
```
|
||||
|
||||
47
tools/cycle_bench/benches/verify.rs
Normal file
47
tools/cycle_bench/benches/verify.rs
Normal file
@ -0,0 +1,47 @@
|
||||
//! Criterion bench for `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)`.
|
||||
//!
|
||||
//! Produces the `G_verify` fee-model parameter. Setup: one full PPE prove of an
|
||||
//! `auth_transfer` Transfer (minutes, runs once outside the timed loop). Measured
|
||||
//! op: `Receipt::verify` over a real PPE receipt.
|
||||
//!
|
||||
//! Run with: `cargo bench -p cycle_bench --features ppe --bench verify`.
|
||||
|
||||
use std::{hint::black_box, time::Duration};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use cycle_bench::ppe::prove_auth_transfer_in_ppe;
|
||||
use nssa::program_methods::PRIVACY_PRESERVING_CIRCUIT_ID;
|
||||
use risc0_zkvm::{InnerReceipt, Receipt};
|
||||
|
||||
fn bench_verify(c: &mut Criterion) {
|
||||
let (output, proof) = prove_auth_transfer_in_ppe().expect("prove auth_transfer in PPE");
|
||||
let journal = output.to_bytes();
|
||||
let proof_bytes = proof.into_inner();
|
||||
let inner: InnerReceipt = borsh::from_slice(&proof_bytes)
|
||||
.context("decode InnerReceipt")
|
||||
.expect("InnerReceipt deserialize");
|
||||
let receipt = Receipt::new(inner, journal);
|
||||
|
||||
// Sanity check before the timed loop.
|
||||
receipt
|
||||
.verify(PRIVACY_PRESERVING_CIRCUIT_ID)
|
||||
.expect("verify sanity check");
|
||||
|
||||
let mut g = c.benchmark_group("ppe");
|
||||
g.sample_size(100)
|
||||
.warm_up_time(Duration::from_secs(2))
|
||||
.measurement_time(Duration::from_secs(15))
|
||||
.noise_threshold(0.05);
|
||||
g.bench_function("verify_auth_transfer", |b| {
|
||||
b.iter(|| {
|
||||
receipt
|
||||
.verify(black_box(PRIVACY_PRESERVING_CIRCUIT_ID))
|
||||
.expect("verify failed mid-loop");
|
||||
});
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_verify);
|
||||
criterion_main!(benches);
|
||||
23
tools/cycle_bench/src/lib.rs
Normal file
23
tools/cycle_bench/src/lib.rs
Normal file
@ -0,0 +1,23 @@
|
||||
//! `cycle_bench` library: per-program executor/prover cycle measurement helpers
|
||||
//! shared between the `cycle_bench` binary and the `verify` criterion bench.
|
||||
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::as_conversions,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::float_arithmetic,
|
||||
clippy::print_literal,
|
||||
clippy::print_stdout,
|
||||
reason = "Bench library: stats arithmetic and table printing are bench-style"
|
||||
)]
|
||||
#![cfg_attr(
|
||||
feature = "ppe",
|
||||
expect(
|
||||
clippy::arbitrary_source_item_ordering,
|
||||
clippy::print_stderr,
|
||||
reason = "PPE module: re-export ordering and eprintln progress trip strict lints"
|
||||
)
|
||||
)]
|
||||
|
||||
pub mod ppe;
|
||||
pub mod stats;
|
||||
@ -9,15 +9,11 @@
|
||||
|
||||
#![expect(
|
||||
clippy::arithmetic_side_effects,
|
||||
clippy::as_conversions,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::float_arithmetic,
|
||||
clippy::missing_const_for_fn,
|
||||
clippy::non_ascii_literal,
|
||||
clippy::print_literal,
|
||||
clippy::print_stderr,
|
||||
clippy::print_stdout,
|
||||
clippy::ref_patterns,
|
||||
reason = "Bench tool: matches test-style fixture code"
|
||||
)]
|
||||
|
||||
@ -31,6 +27,7 @@ use clock_core::{
|
||||
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
|
||||
ClockAccountData,
|
||||
};
|
||||
use cycle_bench::{ppe, stats::Stats};
|
||||
use nssa::program_methods::{
|
||||
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, TOKEN_ELF,
|
||||
@ -43,12 +40,8 @@ use nssa_core::{
|
||||
};
|
||||
use risc0_zkvm::{ExecutorEnv, default_executor, default_prover};
|
||||
use serde::Serialize;
|
||||
use stats::Stats;
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
mod ppe;
|
||||
mod stats;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about = "Per-program executor and (optionally) prover cycle measurements")]
|
||||
struct Cli {
|
||||
@ -62,16 +55,6 @@ struct Cli {
|
||||
#[arg(long)]
|
||||
ppe: bool,
|
||||
|
||||
/// After running --ppe-style proving once for auth_transfer-in-PPE, time
|
||||
/// `receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID)` over many iterations.
|
||||
/// Produces `G_verify` for the fee model. Requires --features ppe.
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
|
||||
/// Iterations for --verify. Default matches the fee-model handoff target.
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
verify_iters: usize,
|
||||
|
||||
/// Iterations for executor wall-time sampling per case. First iter is
|
||||
/// discarded as warmup, remaining N feed the stats.
|
||||
#[arg(long, default_value_t = 5)]
|
||||
@ -428,7 +411,7 @@ fn main() -> Result<()> {
|
||||
AUTHENTICATED_TRANSFER_ELF,
|
||||
AUTHENTICATED_TRANSFER_ID,
|
||||
authenticated_transfer_transfer(),
|
||||
&5_000_u128,
|
||||
&authenticated_transfer_core::Instruction::Transfer { amount: 5_000 },
|
||||
)?,
|
||||
Case::new(
|
||||
"authenticated_transfer",
|
||||
@ -436,7 +419,7 @@ fn main() -> Result<()> {
|
||||
AUTHENTICATED_TRANSFER_ELF,
|
||||
AUTHENTICATED_TRANSFER_ID,
|
||||
authenticated_transfer_init(),
|
||||
&0_u128,
|
||||
&authenticated_transfer_core::Instruction::Initialize,
|
||||
)?,
|
||||
Case::new(
|
||||
"token",
|
||||
@ -532,23 +515,6 @@ fn main() -> Result<()> {
|
||||
ppe::print_table(&ppe_results);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ppe")]
|
||||
let verify_result = if cli.verify {
|
||||
Some(ppe::run_verify(cli.verify_iters)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(feature = "ppe"))]
|
||||
let verify_result: Option<ppe::VerifyBenchResult> = {
|
||||
if cli.verify {
|
||||
eprintln!("cycle_bench: --verify requires --features ppe at build time. Ignoring.");
|
||||
}
|
||||
None
|
||||
};
|
||||
if let Some(ref vr) = verify_result {
|
||||
ppe::print_verify(vr);
|
||||
}
|
||||
|
||||
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
@ -560,7 +526,6 @@ fn main() -> Result<()> {
|
||||
let combined = serde_json::json!({
|
||||
"standalone": results,
|
||||
"ppe": ppe_results,
|
||||
"verify": verify_result,
|
||||
});
|
||||
std::fs::write(&out_path, serde_json::to_string_pretty(&combined)?)?;
|
||||
println!("\nJSON written to {}", out_path.display());
|
||||
|
||||
@ -5,24 +5,23 @@
|
||||
//! that wraps the same program in the privacy circuit. Chained-call depth sweep
|
||||
//! uses the `chain_caller` test program (loaded from artifacts/) with N=1, 3, 5, 9.
|
||||
//!
|
||||
//! `run_verify` produces `G_verify` for the fee model: it generates one PPE
|
||||
//! receipt (`auth_transfer` Transfer in PPE) and times `Receipt::verify` over
|
||||
//! `iters` iterations. The proof bytes captured here are also the on-wire
|
||||
//! "outer proof" payload (`S_agg` in the fee model).
|
||||
//! `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` timings (the `G_verify` fee-model
|
||||
//! parameter) are measured by the `verify` criterion bench under `benches/verify.rs`,
|
||||
//! which reuses the `prove_auth_transfer_in_ppe` setup helper re-exported below.
|
||||
|
||||
#![allow(
|
||||
dead_code,
|
||||
reason = "Stubs are used when the `ppe` feature is disabled."
|
||||
)]
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::stats::Stats;
|
||||
|
||||
#[cfg(feature = "ppe")]
|
||||
mod ppe_impl;
|
||||
|
||||
#[cfg(feature = "ppe")]
|
||||
pub use ppe_impl::prove_auth_transfer_in_ppe;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PpeBenchResult {
|
||||
pub label: String,
|
||||
@ -33,20 +32,14 @@ pub struct PpeBenchResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct VerifyBenchResult {
|
||||
pub label: String,
|
||||
pub stats: Stats,
|
||||
pub proof_bytes: usize,
|
||||
pub journal_bytes: usize,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ppe"))]
|
||||
pub fn run_all() -> Vec<PpeBenchResult> {
|
||||
#[must_use]
|
||||
pub const fn run_all() -> Vec<PpeBenchResult> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(feature = "ppe")]
|
||||
#[must_use]
|
||||
pub fn run_all() -> Vec<PpeBenchResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@ -61,16 +54,6 @@ pub fn run_all() -> Vec<PpeBenchResult> {
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ppe"))]
|
||||
pub fn run_verify(_iters: usize) -> Result<VerifyBenchResult> {
|
||||
anyhow::bail!("--verify requires --features ppe at build time")
|
||||
}
|
||||
|
||||
#[cfg(feature = "ppe")]
|
||||
pub fn run_verify(iters: usize) -> Result<VerifyBenchResult> {
|
||||
ppe_impl::run_verify(iters)
|
||||
}
|
||||
|
||||
pub fn print_table(results: &[PpeBenchResult]) {
|
||||
let lw = results
|
||||
.iter()
|
||||
@ -109,14 +92,3 @@ pub fn print_table(results: &[PpeBenchResult]) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_verify(r: &VerifyBenchResult) {
|
||||
println!("\nVerify (G_verify):");
|
||||
println!(" case : {}", r.label);
|
||||
println!(
|
||||
" proof_bytes : {} (borsh InnerReceipt, S_agg)",
|
||||
r.proof_bytes
|
||||
);
|
||||
println!(" journal_bytes : {}", r.journal_bytes);
|
||||
println!(" verify_ms : {}", r.stats);
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
//! Feature-gated implementation of PPE composition and verify benches.
|
||||
//! Feature-gated implementation of PPE composition benches.
|
||||
//!
|
||||
//! `prove_auth_transfer_in_ppe` is reused by the `verify` criterion bench under
|
||||
//! `benches/verify.rs` (re-exported via `super::prove_auth_transfer_in_ppe`).
|
||||
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
@ -6,17 +9,15 @@ use nssa::{
|
||||
execute_and_prove,
|
||||
privacy_preserving_transaction::circuit::{ProgramWithDependencies, Proof},
|
||||
program::Program,
|
||||
program_methods::PRIVACY_PRESERVING_CIRCUIT_ID,
|
||||
};
|
||||
use nssa_core::{
|
||||
InputAccountIdentity, PrivacyPreservingCircuitOutput,
|
||||
account::{Account, AccountId, AccountWithMetadata},
|
||||
program::ProgramId,
|
||||
};
|
||||
use risc0_zkvm::{InnerReceipt, Receipt, serde::to_vec};
|
||||
use risc0_zkvm::serde::to_vec;
|
||||
|
||||
use super::{PpeBenchResult, VerifyBenchResult};
|
||||
use crate::stats::Stats;
|
||||
use super::PpeBenchResult;
|
||||
|
||||
const AUTH_TRANSFER_ID: ProgramId = nssa::program_methods::AUTHENTICATED_TRANSFER_ID;
|
||||
const AUTH_TRANSFER_ELF: &[u8] = nssa::program_methods::AUTHENTICATED_TRANSFER_ELF;
|
||||
@ -50,7 +51,7 @@ pub fn run_auth_transfer_in_ppe() -> PpeBenchResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> {
|
||||
pub fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> {
|
||||
let program = Program::new(AUTH_TRANSFER_ELF.to_vec())?;
|
||||
let pwd = ProgramWithDependencies::from(program);
|
||||
|
||||
@ -73,8 +74,8 @@ fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitOutpu
|
||||
};
|
||||
let pre_states = vec![sender, recipient];
|
||||
|
||||
let balance_to_move: u128 = 5_000;
|
||||
let instruction_data = to_vec(&balance_to_move)?;
|
||||
let instruction = authenticated_transfer_core::Instruction::Transfer { amount: 5_000 };
|
||||
let instruction_data = to_vec(&instruction)?;
|
||||
|
||||
let account_identities = vec![InputAccountIdentity::Public; pre_states.len()];
|
||||
|
||||
@ -156,39 +157,3 @@ fn prove_chain_caller(
|
||||
&pwd,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn run_verify(iters: usize) -> anyhow::Result<VerifyBenchResult> {
|
||||
eprintln!("verify: generating PPE receipt for auth_transfer Transfer (~1 prove)");
|
||||
let (output, proof) = prove_auth_transfer_in_ppe()?;
|
||||
let journal = output.to_bytes();
|
||||
let journal_bytes = journal.len();
|
||||
let proof_bytes_vec = proof.into_inner();
|
||||
let proof_bytes = proof_bytes_vec.len();
|
||||
|
||||
let inner: InnerReceipt = borsh::from_slice(&proof_bytes_vec)
|
||||
.map_err(|e| anyhow::anyhow!("InnerReceipt deserialize: {e}"))?;
|
||||
let receipt = Receipt::new(inner, journal);
|
||||
|
||||
// Sanity-check before the timing loop so we don't measure 1000 failures.
|
||||
receipt
|
||||
.verify(PRIVACY_PRESERVING_CIRCUIT_ID)
|
||||
.map_err(|e| anyhow::anyhow!("verify sanity check failed: {e}"))?;
|
||||
|
||||
eprintln!("verify: timing {iters} iters of receipt.verify(...)");
|
||||
let mut samples = Vec::with_capacity(iters);
|
||||
for _ in 0..iters {
|
||||
let started = Instant::now();
|
||||
receipt
|
||||
.verify(PRIVACY_PRESERVING_CIRCUIT_ID)
|
||||
.map_err(|e| anyhow::anyhow!("verify failed mid-loop: {e}"))?;
|
||||
samples.push(started.elapsed().as_secs_f64() * 1_000.0);
|
||||
}
|
||||
let stats = Stats::from_samples(&samples);
|
||||
|
||||
Ok(VerifyBenchResult {
|
||||
label: "auth_transfer Transfer in PPE".to_owned(),
|
||||
stats,
|
||||
proof_bytes,
|
||||
journal_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
use std::{ffi::CString, ptr};
|
||||
|
||||
use nssa::AccountId;
|
||||
use wallet::program_facades::native_token_transfer::NativeTokenTransfer;
|
||||
use wallet::{
|
||||
account::AccountIdWithPrivacy, cli::CliAccountMention,
|
||||
program_facades::native_token_transfer::NativeTokenTransfer,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
block_on,
|
||||
@ -72,7 +75,16 @@ pub unsafe extern "C" fn wallet_ffi_transfer_public(
|
||||
|
||||
let transfer = NativeTokenTransfer(&wallet);
|
||||
|
||||
match block_on(transfer.send_public_transfer(from_id, to_id, amount)) {
|
||||
let from_mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(from_id));
|
||||
let to_mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(to_id));
|
||||
|
||||
match block_on(transfer.send_public_transfer(
|
||||
from_id,
|
||||
to_id,
|
||||
amount,
|
||||
&from_mention,
|
||||
&to_mention,
|
||||
)) {
|
||||
Ok(tx_hash) => {
|
||||
let tx_hash = CString::new(tx_hash.to_string())
|
||||
.map_or(ptr::null_mut(), std::ffi::CString::into_raw);
|
||||
@ -591,7 +603,9 @@ pub unsafe extern "C" fn wallet_ffi_register_public_account(
|
||||
|
||||
let transfer = NativeTokenTransfer(&wallet);
|
||||
|
||||
match block_on(transfer.register_account(account_id)) {
|
||||
let mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id));
|
||||
|
||||
match block_on(transfer.register_account(account_id, &mention)) {
|
||||
Ok(tx_hash) => {
|
||||
let tx_hash = CString::new(tx_hash.to_string())
|
||||
.map_or(ptr::null_mut(), std::ffi::CString::into_raw);
|
||||
|
||||
@ -19,6 +19,10 @@ amm_core.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
ata_core.workspace = true
|
||||
bip39.workspace = true
|
||||
pyo3.workspace = true
|
||||
rpassword = "7"
|
||||
zeroize = "1"
|
||||
keycard_wallet.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
136
wallet/src/cli/keycard.rs
Normal file
136
wallet/src/cli/keycard.rs
Normal file
@ -0,0 +1,136 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use keycard_wallet::{KeycardWallet, clear_pairing, python_path};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::{
|
||||
WalletCore,
|
||||
cli::{SubcommandReturnValue, WalletSubcommand, read_mnemonic, read_pin},
|
||||
};
|
||||
|
||||
/// Represents generic chain CLI subcommand.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum KeycardSubcommand {
|
||||
Available,
|
||||
Connect,
|
||||
Disconnect,
|
||||
Init,
|
||||
Load,
|
||||
}
|
||||
|
||||
impl WalletSubcommand for KeycardSubcommand {
|
||||
async fn handle_subcommand(
|
||||
self,
|
||||
_wallet_core: &mut WalletCore,
|
||||
) -> Result<SubcommandReturnValue> {
|
||||
match self {
|
||||
Self::Available => {
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py).expect("keycard_wallet.py not found");
|
||||
|
||||
let wallet = KeycardWallet::new(py)
|
||||
.expect("`wallet::keycard::available`: invalid data received for pin");
|
||||
let available = wallet.is_unpaired_keycard_available(py).expect(
|
||||
"`wallet::keycard::available`: received invalid data from Keycard wrapper",
|
||||
);
|
||||
|
||||
if available {
|
||||
println!("\u{2705} Keycard is available.");
|
||||
} else {
|
||||
println!("\u{274c} Keycard is not available.");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
Self::Connect => {
|
||||
let pin = read_pin()?;
|
||||
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py).expect("keycard_wallet.py not found");
|
||||
|
||||
let wallet = KeycardWallet::new(py)
|
||||
.expect("`wallet::keycard::connect`: invalid keycard wallet provided");
|
||||
|
||||
wallet
|
||||
.connect(py, &pin)
|
||||
.expect("`wallet::keycard::connect`: failed to connect to keycard");
|
||||
|
||||
println!("\u{2705} Keycard paired and ready.");
|
||||
drop(wallet.close_session(py));
|
||||
});
|
||||
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
Self::Disconnect => {
|
||||
let pin = read_pin()?;
|
||||
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py).expect("keycard_wallet.py not found");
|
||||
|
||||
let wallet = KeycardWallet::new(py)
|
||||
.expect("`wallet::keycard::disconnect`: invalid keycard wallet provided");
|
||||
|
||||
wallet
|
||||
.connect(py, &pin)
|
||||
.expect("`wallet::keycard::disconnect`: failed to open session");
|
||||
|
||||
wallet
|
||||
.disconnect(py)
|
||||
.expect("`wallet::keycard::disconnect`: failed to unpair keycard");
|
||||
|
||||
clear_pairing();
|
||||
println!("\u{2705} Keycard unpaired and pairing cleared.");
|
||||
});
|
||||
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
Self::Init => {
|
||||
let pin = read_pin()?;
|
||||
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py).expect("keycard_wallet.py not found");
|
||||
|
||||
let wallet = KeycardWallet::new(py)
|
||||
.expect("`wallet::keycard::init`: invalid keycard wallet provided");
|
||||
|
||||
let initialized = wallet
|
||||
.initialize(py, &pin)
|
||||
.expect("`wallet::keycard::init`: failed to initialize keycard");
|
||||
|
||||
if initialized {
|
||||
clear_pairing();
|
||||
println!("\u{2705} Keycard initialized successfully.");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
Self::Load => {
|
||||
let pin = read_pin()?;
|
||||
let mnemonic = read_mnemonic()?;
|
||||
|
||||
Python::with_gil(|py| {
|
||||
python_path::add_python_path(py).expect("keycard_wallet.py not found");
|
||||
|
||||
let wallet = KeycardWallet::new(py)
|
||||
.expect("`wallet::keycard::load`: invalid keycard wallet provided");
|
||||
|
||||
wallet
|
||||
.connect(py, &pin)
|
||||
.expect("`wallet::keycard::load`: failed to connect to keycard");
|
||||
|
||||
println!("\u{2705} Keycard is now connected to wallet.");
|
||||
if wallet.load_mnemonic(py, &mnemonic).is_ok() {
|
||||
println!("\u{2705} Mnemonic phrase loaded successfully.");
|
||||
} else {
|
||||
println!("\u{274c} Failed to load mnemonic phrase.");
|
||||
}
|
||||
drop(wallet.close_session(py));
|
||||
});
|
||||
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ use futures::TryFutureExt as _;
|
||||
use nssa::{ProgramDeploymentTransaction, program::Program};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
|
||||
pub use crate::helperfunctions::{read_mnemonic, read_pin};
|
||||
use crate::{
|
||||
WalletCore,
|
||||
account::{AccountIdWithPrivacy, Label},
|
||||
@ -17,6 +18,7 @@ use crate::{
|
||||
chain::ChainSubcommand,
|
||||
config::ConfigSubcommand,
|
||||
group::GroupSubcommand,
|
||||
keycard::KeycardSubcommand,
|
||||
programs::{
|
||||
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand,
|
||||
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
|
||||
@ -30,6 +32,7 @@ pub mod account;
|
||||
pub mod chain;
|
||||
pub mod config;
|
||||
pub mod group;
|
||||
pub mod keycard;
|
||||
pub mod programs;
|
||||
|
||||
pub(crate) trait WalletSubcommand {
|
||||
@ -81,6 +84,9 @@ pub enum Command {
|
||||
},
|
||||
/// Deploy a program.
|
||||
DeployProgram { binary_filepath: PathBuf },
|
||||
/// Keycard hardware wallet management.
|
||||
#[command(subcommand)]
|
||||
Keycard(KeycardSubcommand),
|
||||
}
|
||||
|
||||
/// To execute commands, env var `NSSA_WALLET_HOME_DIR` must be set into directory with config.
|
||||
@ -113,10 +119,13 @@ pub enum SubcommandReturnValue {
|
||||
}
|
||||
|
||||
#[derive(Debug, Display, Clone, PartialEq, Eq, Hash)]
|
||||
#[display("{_0}")]
|
||||
pub enum CliAccountMention {
|
||||
#[display("{_0}")]
|
||||
Id(AccountIdWithPrivacy),
|
||||
#[display("{_0}")]
|
||||
Label(Label),
|
||||
#[display("{_0}")]
|
||||
KeyPath(String),
|
||||
}
|
||||
|
||||
impl CliAccountMention {
|
||||
@ -126,6 +135,14 @@ impl CliAccountMention {
|
||||
Self::Label(label) => storage
|
||||
.resolve_label(label)
|
||||
.ok_or_else(|| anyhow::anyhow!("No account found for label `{label}`")),
|
||||
Self::KeyPath(path) => {
|
||||
let pin = read_pin()?;
|
||||
let id_str =
|
||||
keycard_wallet::KeycardWallet::get_account_id_for_path_with_connect(&pin, path)
|
||||
.map_err(anyhow::Error::from)?;
|
||||
AccountIdWithPrivacy::from_str(&id_str)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid account id from keycard: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -134,6 +151,9 @@ impl FromStr for CliAccountMention {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
if s.starts_with("m/") {
|
||||
return Ok(Self::KeyPath(s.to_owned()));
|
||||
}
|
||||
AccountIdWithPrivacy::from_str(s).map_or_else(
|
||||
|_| Ok(Self::Label(Label::new(s.to_owned()))),
|
||||
|account_id| Ok(Self::Id(account_id)),
|
||||
@ -147,6 +167,12 @@ impl From<Label> for CliAccountMention {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CliAccountMention {
|
||||
fn default() -> Self {
|
||||
Self::Label(Label::new(String::new()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_subcommand(
|
||||
wallet_core: &mut WalletCore,
|
||||
command: Command,
|
||||
@ -208,6 +234,9 @@ pub async fn execute_subcommand(
|
||||
Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?,
|
||||
Command::Ata(ata_subcommand) => ata_subcommand.handle_subcommand(wallet_core).await?,
|
||||
Command::Group(group_subcommand) => group_subcommand.handle_subcommand(wallet_core).await?,
|
||||
Command::Keycard(keycard_subcommand) => {
|
||||
keycard_subcommand.handle_subcommand(wallet_core).await?
|
||||
}
|
||||
Command::Config(config_subcommand) => {
|
||||
config_subcommand.handle_subcommand(wallet_core).await?
|
||||
}
|
||||
|
||||
@ -58,9 +58,9 @@ impl WalletSubcommand for AuthTransferSubcommand {
|
||||
Self::Init { account_id } => {
|
||||
let resolved = account_id.resolve(wallet_core.storage())?;
|
||||
match resolved {
|
||||
AccountIdWithPrivacy::Public(account_id) => {
|
||||
AccountIdWithPrivacy::Public(pub_account_id) => {
|
||||
let tx_hash = NativeTokenTransfer(wallet_core)
|
||||
.register_account(account_id)
|
||||
.register_account(pub_account_id, &account_id)
|
||||
.await?;
|
||||
|
||||
println!("Transaction hash is {tx_hash}");
|
||||
@ -96,16 +96,17 @@ impl WalletSubcommand for AuthTransferSubcommand {
|
||||
Ok(SubcommandReturnValue::Empty)
|
||||
}
|
||||
Self::Send {
|
||||
from,
|
||||
to,
|
||||
from: from_account,
|
||||
to: to_account,
|
||||
to_npk,
|
||||
to_vpk,
|
||||
to_identifier,
|
||||
amount,
|
||||
} => {
|
||||
let from = from.resolve(wallet_core.storage())?;
|
||||
let to = to
|
||||
.map(|account_mention| account_mention.resolve(wallet_core.storage()))
|
||||
let from = from_account.resolve(wallet_core.storage())?;
|
||||
let to = to_account
|
||||
.as_ref()
|
||||
.map(|m| m.resolve(wallet_core.storage()))
|
||||
.transpose()?;
|
||||
let underlying_subcommand = match (to, to_npk, to_vpk) {
|
||||
(None, None, None) => {
|
||||
@ -123,7 +124,13 @@ impl WalletSubcommand for AuthTransferSubcommand {
|
||||
}
|
||||
(Some(to), None, None) => match (from, to) {
|
||||
(AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Public(to)) => {
|
||||
NativeTokenTransferProgramSubcommand::Public { from, to, amount }
|
||||
NativeTokenTransferProgramSubcommand::Public {
|
||||
from,
|
||||
to,
|
||||
amount,
|
||||
from_mention: from_account,
|
||||
to_mention: to_account.expect("matched Some branch"),
|
||||
}
|
||||
}
|
||||
(
|
||||
AccountIdWithPrivacy::Private(from),
|
||||
@ -196,6 +203,10 @@ pub enum NativeTokenTransferProgramSubcommand {
|
||||
/// amount - amount of balance to move.
|
||||
#[arg(long)]
|
||||
amount: u128,
|
||||
#[arg(skip)]
|
||||
from_mention: CliAccountMention,
|
||||
#[arg(skip)]
|
||||
to_mention: CliAccountMention,
|
||||
},
|
||||
/// Private execution.
|
||||
#[command(subcommand)]
|
||||
@ -476,9 +487,15 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommand {
|
||||
|
||||
Ok(SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash })
|
||||
}
|
||||
Self::Public { from, to, amount } => {
|
||||
Self::Public {
|
||||
from,
|
||||
to,
|
||||
amount,
|
||||
from_mention,
|
||||
to_mention,
|
||||
} => {
|
||||
let tx_hash = NativeTokenTransfer(wallet_core)
|
||||
.send_public_transfer(from, to, amount)
|
||||
.send_public_transfer(from, to, amount, &from_mention, &to_mention)
|
||||
.await?;
|
||||
|
||||
println!("Transaction hash is {tx_hash}");
|
||||
|
||||
@ -6,6 +6,33 @@ use rand::{RngCore as _, rngs::OsRng};
|
||||
|
||||
use crate::HOME_DIR_ENV_VAR;
|
||||
|
||||
/// Read the Keycard PIN without echoing it.
|
||||
///
|
||||
/// Checks `KEYCARD_PIN` first so non-interactive callers (CI, scripts) can
|
||||
/// supply it via the environment. Falls back to a TTY prompt via `rpassword`
|
||||
/// so the value never appears in argv, shell history, or `ps` output.
|
||||
pub fn read_pin() -> anyhow::Result<zeroize::Zeroizing<String>> {
|
||||
if let Ok(pin) = std::env::var("KEYCARD_PIN") {
|
||||
return Ok(zeroize::Zeroizing::new(pin));
|
||||
}
|
||||
rpassword::prompt_password("Keycard PIN: ")
|
||||
.map(zeroize::Zeroizing::new)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Read the mnemonic phrase without echoing it.
|
||||
///
|
||||
/// Checks `KEYCARD_MNEMONIC` first for non-interactive callers. Falls back to
|
||||
/// a TTY prompt so the phrase never appears in argv, shell history, or `ps`.
|
||||
pub fn read_mnemonic() -> anyhow::Result<zeroize::Zeroizing<String>> {
|
||||
if let Ok(mnemonic) = std::env::var("KEYCARD_MNEMONIC") {
|
||||
return Ok(zeroize::Zeroizing::new(mnemonic));
|
||||
}
|
||||
rpassword::prompt_password("Mnemonic phrase: ")
|
||||
.map(zeroize::Zeroizing::new)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get home dir for wallet. Env var `NSSA_WALLET_HOME_DIR` must be set before execution to succeed.
|
||||
fn get_home_nssa_var() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from_str(&std::env::var(HOME_DIR_ENV_VAR)?)?)
|
||||
|
||||
@ -43,6 +43,7 @@ pub mod helperfunctions;
|
||||
pub mod poller;
|
||||
mod privacy_preserving_tx;
|
||||
pub mod program_facades;
|
||||
pub mod signing;
|
||||
pub mod storage;
|
||||
|
||||
pub const HOME_DIR_ENV_VAR: &str = "NSSA_WALLET_HOME_DIR";
|
||||
@ -75,6 +76,8 @@ pub enum ExecutionFailureKind {
|
||||
AccountDataError(AccountId),
|
||||
#[error("Failed to build transaction: {0}")]
|
||||
TransactionBuildError(#[from] nssa::error::NssaError),
|
||||
#[error(transparent)]
|
||||
KeycardError(#[from] pyo3::PyErr),
|
||||
}
|
||||
|
||||
#[expect(clippy::partial_pub_fields, reason = "TODO: make all fields private")]
|
||||
|
||||
@ -5,10 +5,13 @@ use nssa::{
|
||||
program::Program,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use pyo3::exceptions::PyRuntimeError;
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
|
||||
use super::NativeTokenTransfer;
|
||||
use crate::ExecutionFailureKind;
|
||||
use crate::{
|
||||
ExecutionFailureKind, cli::CliAccountMention, helperfunctions::read_pin, signing::SigningGroups,
|
||||
};
|
||||
|
||||
impl NativeTokenTransfer<'_> {
|
||||
pub async fn send_public_transfer(
|
||||
@ -16,71 +19,65 @@ impl NativeTokenTransfer<'_> {
|
||||
from: AccountId,
|
||||
to: AccountId,
|
||||
balance_to_move: u128,
|
||||
from_mention: &CliAccountMention,
|
||||
to_mention: &CliAccountMention,
|
||||
) -> Result<HashType, ExecutionFailureKind> {
|
||||
let balance = self
|
||||
let mut groups = SigningGroups::new();
|
||||
groups
|
||||
.add_sender(from_mention, from, self.0)
|
||||
.and_then(|()| groups.add_recipient(to_mention, to, self.0))
|
||||
.map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<PyRuntimeError, _>(
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
let program_id = Program::authenticated_transfer_program().id();
|
||||
let nonces = self
|
||||
.0
|
||||
.get_account_balance(from)
|
||||
.get_accounts_nonces(groups.signing_ids())
|
||||
.await
|
||||
.map_err(ExecutionFailureKind::SequencerError)?;
|
||||
|
||||
if balance >= balance_to_move {
|
||||
let account_ids = vec![from, to];
|
||||
let program_id = Program::authenticated_transfer_program().id();
|
||||
let message = Message::try_new(
|
||||
program_id,
|
||||
vec![from, to],
|
||||
nonces,
|
||||
AuthTransferInstruction::Transfer {
|
||||
amount: balance_to_move,
|
||||
},
|
||||
)
|
||||
.map_err(ExecutionFailureKind::TransactionBuildError)?;
|
||||
|
||||
let mut nonces = self
|
||||
.0
|
||||
.get_accounts_nonces(vec![from])
|
||||
.await
|
||||
.map_err(ExecutionFailureKind::SequencerError)?;
|
||||
|
||||
let mut private_keys = Vec::new();
|
||||
let from_signing_key = self.0.storage.key_chain().pub_account_signing_key(from);
|
||||
let Some(from_signing_key) = from_signing_key else {
|
||||
return Err(ExecutionFailureKind::KeyNotFoundError);
|
||||
};
|
||||
private_keys.push(from_signing_key);
|
||||
|
||||
let to_signing_key = self.0.storage.key_chain().pub_account_signing_key(to);
|
||||
if let Some(to_signing_key) = to_signing_key {
|
||||
private_keys.push(to_signing_key);
|
||||
let to_nonces = self
|
||||
.0
|
||||
.get_accounts_nonces(vec![to])
|
||||
.await
|
||||
.map_err(ExecutionFailureKind::SequencerError)?;
|
||||
nonces.extend(to_nonces);
|
||||
} else {
|
||||
println!(
|
||||
"Receiver's account ({to}) private key not found in wallet. Proceeding with only sender's key."
|
||||
);
|
||||
}
|
||||
|
||||
let message = Message::try_new(
|
||||
program_id,
|
||||
account_ids,
|
||||
nonces,
|
||||
AuthTransferInstruction::Transfer {
|
||||
amount: balance_to_move,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, &private_keys);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
|
||||
Ok(self
|
||||
.0
|
||||
.sequencer_client
|
||||
.send_transaction(NSSATransaction::Public(tx))
|
||||
.await?)
|
||||
let pin = if groups.needs_pin() {
|
||||
read_pin()
|
||||
.map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<PyRuntimeError, _>(
|
||||
e.to_string(),
|
||||
))
|
||||
})?
|
||||
.as_str()
|
||||
.to_owned()
|
||||
} else {
|
||||
Err(ExecutionFailureKind::InsufficientFundsError)
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
|
||||
let sigs = groups.sign_all(&message.hash(), &pin).map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<PyRuntimeError, _>(e.to_string()))
|
||||
})?;
|
||||
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(sigs));
|
||||
Ok(self
|
||||
.0
|
||||
.sequencer_client
|
||||
.send_transaction(NSSATransaction::Public(tx))
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn register_account(
|
||||
&self,
|
||||
from: AccountId,
|
||||
account_mention: &CliAccountMention,
|
||||
) -> Result<HashType, ExecutionFailureKind> {
|
||||
let nonces = self
|
||||
.0
|
||||
@ -96,18 +93,35 @@ impl NativeTokenTransfer<'_> {
|
||||
nonces,
|
||||
AuthTransferInstruction::Initialize,
|
||||
)
|
||||
.unwrap();
|
||||
.map_err(ExecutionFailureKind::TransactionBuildError)?;
|
||||
|
||||
let signing_key = self.0.storage.key_chain().pub_account_signing_key(from);
|
||||
let mut groups = SigningGroups::new();
|
||||
groups
|
||||
.add_sender(account_mention, from, self.0)
|
||||
.map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<PyRuntimeError, _>(
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
let Some(signing_key) = signing_key else {
|
||||
return Err(ExecutionFailureKind::KeyNotFoundError);
|
||||
let pin = if groups.needs_pin() {
|
||||
read_pin()
|
||||
.map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<PyRuntimeError, _>(
|
||||
e.to_string(),
|
||||
))
|
||||
})?
|
||||
.as_str()
|
||||
.to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, &[signing_key]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
let sigs = groups.sign_all(&message.hash(), &pin).map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<PyRuntimeError, _>(e.to_string()))
|
||||
})?;
|
||||
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(sigs));
|
||||
Ok(self
|
||||
.0
|
||||
.sequencer_client
|
||||
|
||||
114
wallet/src/signing.rs
Normal file
114
wallet/src/signing.rs
Normal file
@ -0,0 +1,114 @@
|
||||
use anyhow::Result;
|
||||
use keycard_wallet::{KeycardWallet, python_path};
|
||||
use nssa::{AccountId, PrivateKey, PublicKey, Signature};
|
||||
|
||||
use crate::{WalletCore, cli::CliAccountMention};
|
||||
|
||||
/// Groups transaction signers by type to minimise Python GIL acquisition.
|
||||
///
|
||||
/// Local signers are signed in pure Rust; all keycard signers share a single Python session
|
||||
/// with one `connect` / `close_session` pair.
|
||||
#[derive(Default)]
|
||||
pub struct SigningGroups {
|
||||
local: Vec<(AccountId, PrivateKey)>,
|
||||
keycard: Vec<(AccountId, String)>,
|
||||
}
|
||||
|
||||
impl SigningGroups {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a sender. Keycard paths are queued for the hardware session; local accounts
|
||||
/// have their signing key resolved eagerly. Errors if no key is found.
|
||||
pub fn add_sender(
|
||||
&mut self,
|
||||
mention: &CliAccountMention,
|
||||
account_id: AccountId,
|
||||
wallet_core: &WalletCore,
|
||||
) -> Result<()> {
|
||||
if let CliAccountMention::KeyPath(path) = mention {
|
||||
self.keycard.push((account_id, path.clone()));
|
||||
return Ok(());
|
||||
}
|
||||
let key = wallet_core
|
||||
.storage()
|
||||
.key_chain()
|
||||
.pub_account_signing_key(account_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("signing key not found for account {account_id}"))?
|
||||
.clone();
|
||||
self.local.push((account_id, key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a recipient. Same as [`add_sender`] but silently skips accounts with no local
|
||||
/// key and no keycard path — they are foreign and require neither a signature nor a nonce.
|
||||
pub fn add_recipient(
|
||||
&mut self,
|
||||
mention: &CliAccountMention,
|
||||
account_id: AccountId,
|
||||
wallet_core: &WalletCore,
|
||||
) -> Result<()> {
|
||||
if let CliAccountMention::KeyPath(path) = mention {
|
||||
self.keycard.push((account_id, path.clone()));
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(key) = wallet_core
|
||||
.storage()
|
||||
.key_chain()
|
||||
.pub_account_signing_key(account_id)
|
||||
{
|
||||
self.local.push((account_id, key.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` when a PIN is required (at least one keycard signer is present).
|
||||
#[must_use]
|
||||
pub const fn needs_pin(&self) -> bool {
|
||||
!self.keycard.is_empty()
|
||||
}
|
||||
|
||||
/// Account IDs that require a nonce (every non-foreign signer).
|
||||
#[must_use]
|
||||
pub fn signing_ids(&self) -> Vec<AccountId> {
|
||||
self.local
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.chain(self.keycard.iter().map(|(id, _)| *id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sign `hash` for every account in the group.
|
||||
///
|
||||
/// Local accounts are signed in pure Rust. Keycard accounts share one Python session.
|
||||
pub fn sign_all(&self, hash: &[u8; 32], pin: &str) -> Result<Vec<(Signature, PublicKey)>> {
|
||||
let mut sigs: Vec<(Signature, PublicKey)> = self
|
||||
.local
|
||||
.iter()
|
||||
.map(|(_, key)| {
|
||||
(
|
||||
Signature::new(key, hash),
|
||||
PublicKey::new_from_private_key(key),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !self.keycard.is_empty() {
|
||||
pyo3::Python::with_gil(|py| -> pyo3::PyResult<()> {
|
||||
python_path::add_python_path(py)?;
|
||||
let wallet = KeycardWallet::new(py)?;
|
||||
wallet.connect(py, pin)?;
|
||||
for (_, path) in &self.keycard {
|
||||
sigs.push(wallet.sign_message_for_path(py, path, hash)?);
|
||||
}
|
||||
drop(wallet.close_session(py));
|
||||
Ok(())
|
||||
})
|
||||
.map_err(anyhow::Error::from)?;
|
||||
}
|
||||
|
||||
Ok(sigs)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user