mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-06-02 07:09:29 +00:00
update from main
This commit is contained in:
commit
12a2902a54
@ -16,6 +16,7 @@ ignore = [
|
||||
{ id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" },
|
||||
{ id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" },
|
||||
{ id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" },
|
||||
{ id = "RUSTSEC-2024-0370", reason = "transitive dependency of `logos-blockchain-http-api-common`, can't do anything than wait for upstream fix" },
|
||||
]
|
||||
yanked = "deny"
|
||||
unused-ignored-advisory = "deny"
|
||||
|
||||
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 }}
|
||||
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
@ -94,6 +94,12 @@ jobs:
|
||||
- name: Install active toolchain
|
||||
run: rustup install
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci-rust-cache
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Lint workspace
|
||||
env:
|
||||
RISC0_SKIP_BUILD: "1"
|
||||
@ -123,6 +129,12 @@ jobs:
|
||||
- name: Install active toolchain
|
||||
run: rustup install
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci-rust-cache
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install nextest
|
||||
run: cargo install --locked cargo-nextest
|
||||
|
||||
@ -132,9 +144,10 @@ jobs:
|
||||
RUST_LOG: "info"
|
||||
run: cargo nextest run --workspace --exclude integration_tests --all-features
|
||||
|
||||
integration-tests:
|
||||
integration-tests-prebuild:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90 # TODO: Apply CI cache to speed this up
|
||||
outputs:
|
||||
targets: ${{ steps.discover-targets.outputs.targets }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@ -151,6 +164,75 @@ jobs:
|
||||
- name: Install active toolchain
|
||||
run: rustup install
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci-rust-cache
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install nextest
|
||||
run: cargo install --locked cargo-nextest
|
||||
|
||||
- name: Build integration test archive
|
||||
env:
|
||||
RISC0_DEV_MODE: "1"
|
||||
run: cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager
|
||||
|
||||
- name: Upload integration test archive
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: integration-tests-archive
|
||||
path: integration-tests.tar.zst
|
||||
|
||||
- name: Discover integration test targets from archive
|
||||
id: discover-targets
|
||||
run: |
|
||||
cargo nextest list \
|
||||
--archive-file integration-tests.tar.zst \
|
||||
--list-type binaries-only \
|
||||
--message-format json \
|
||||
--no-pager > integration-tests-binaries.json
|
||||
|
||||
targets_json="$(jq -c '[."rust-binaries" | to_entries[] | select(.value.kind == "test" and .value."binary-name" != "tps") | .value."binary-name"] | sort | unique' integration-tests-binaries.json)"
|
||||
|
||||
if [[ "$targets_json" == "[]" ]]; then
|
||||
echo "No integration test targets were discovered." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "targets=$targets_json" >> "$GITHUB_OUTPUT"
|
||||
echo "Discovered integration targets: $targets_json"
|
||||
|
||||
integration-tests:
|
||||
needs: integration-tests-prebuild
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: ${{ fromJson(needs.integration-tests-prebuild.outputs.targets) }}
|
||||
name: integration-tests (${{ matrix.target }})
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.head_ref }}
|
||||
|
||||
- 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: Download integration test archive
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: integration-tests-archive
|
||||
|
||||
- name: Install nextest
|
||||
run: cargo install --locked cargo-nextest
|
||||
|
||||
@ -158,7 +240,7 @@ jobs:
|
||||
env:
|
||||
RISC0_DEV_MODE: "1"
|
||||
RUST_LOG: "info"
|
||||
run: cargo nextest run -p integration_tests -- --skip tps_test
|
||||
run: cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})"
|
||||
|
||||
valid-proof-test:
|
||||
runs-on: ubuntu-latest
|
||||
@ -179,6 +261,12 @@ jobs:
|
||||
- name: Install active toolchain
|
||||
run: rustup install
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci-rust-cache
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Test valid proof
|
||||
env:
|
||||
RUST_LOG: "info"
|
||||
@ -196,6 +284,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install-risc0
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci-rust-cache
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install just
|
||||
run: cargo install --locked just
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
873
Cargo.lock
generated
873
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@ -22,6 +22,7 @@ members = [
|
||||
"programs/associated_token_account",
|
||||
"programs/authenticated_transfer/core",
|
||||
"programs/faucet/core",
|
||||
"programs/bridge/core",
|
||||
"programs/vault/core",
|
||||
"sequencer/core",
|
||||
"sequencer/service",
|
||||
@ -41,6 +42,7 @@ members = [
|
||||
"examples/program_deployment/methods/guest",
|
||||
"testnet_initial_state",
|
||||
"indexer/ffi",
|
||||
"keycard_wallet",
|
||||
"test_fixtures",
|
||||
"tools/cycle_bench",
|
||||
"tools/crypto_primitives_bench",
|
||||
@ -74,9 +76,11 @@ ata_core = { path = "programs/associated_token_account/core" }
|
||||
ata_program = { path = "programs/associated_token_account" }
|
||||
authenticated_transfer_core = { path = "programs/authenticated_transfer/core" }
|
||||
faucet_core = { path = "programs/faucet/core" }
|
||||
bridge_core = { path = "programs/bridge/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,13 +135,15 @@ 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" }
|
||||
logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
|
||||
logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
|
||||
logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
|
||||
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
|
||||
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "dd055cc1ef7c130f710a52a190edd97bc7b0f71b" }
|
||||
|
||||
rocksdb = { version = "0.24.0", default-features = false, features = [
|
||||
"snappy",
|
||||
@ -158,6 +164,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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/program_methods/bridge.bin
Normal file
BIN
artifacts/program_methods/bridge.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/test_program_methods/pda_spend_proxy.bin
Normal file
BIN
artifacts/test_program_methods/pda_spend_proxy.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -67,7 +67,9 @@ cryptarchia:
|
||||
- opcode: 17
|
||||
payload:
|
||||
channel_id: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
inscription: '67656e65736973'
|
||||
# chain_id_len=12 (u64_le), chain_id=logos-devnet (utf-8),
|
||||
# genesis_time=2026-01-10T07:47:56Z (u64_le), epoch_nonce=[0u8; 32]
|
||||
inscription: '0c000000000000006c6f676f732d6465766e65742c046269000000000000000000000000000000000000000000000000000000000000000000000000'
|
||||
parent: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
signer: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
execution_gas_price: 0
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
services:
|
||||
|
||||
logos-blockchain-node-0:
|
||||
image: ghcr.io/logos-blockchain/logos-blockchain@sha256:9f1829dea335c56f6ff68ae37ea872ed5313b96b69e8ffe143c02b7217de85fc
|
||||
image: ghcr.io/logos-blockchain/logos-blockchain@sha256:f160cfbf898a06554451cc066d84cfd0f8ab62d59bd3e62d9cde3bd5582c12ab
|
||||
ports:
|
||||
- "${PORT:-8080}:18080/tcp"
|
||||
volumes:
|
||||
|
||||
@ -67,7 +67,7 @@ impl NSSATransaction {
|
||||
}
|
||||
|
||||
/// Validates the transaction against the current state and returns the resulting diff
|
||||
/// without applying it. Rejects transactions that modify clock or faucet system accounts,
|
||||
/// without applying it. Rejects transactions that modify clock, faucet or bridge accounts,
|
||||
/// whether directly or indirectly via chain calls.
|
||||
///
|
||||
/// This check is required for all user transactions. Only sequencer transactions may bypass
|
||||
@ -90,26 +90,12 @@ impl NSSATransaction {
|
||||
}
|
||||
}?;
|
||||
|
||||
let public_diff = diff.public_diff();
|
||||
let touches_clock = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().any(|id| {
|
||||
public_diff
|
||||
.get(id)
|
||||
.is_some_and(|post| *post != state.get_account_by_id(*id))
|
||||
});
|
||||
if touches_clock {
|
||||
return Err(nssa::error::NssaError::InvalidInput(
|
||||
"Transaction modifies system clock accounts".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let faucet_id = nssa::system_faucet_account_id();
|
||||
if public_diff
|
||||
.get(&faucet_id)
|
||||
.is_some_and(|post| *post != state.get_account_by_id(faucet_id))
|
||||
{
|
||||
return Err(nssa::error::NssaError::InvalidInput(
|
||||
"Transaction modifies system faucet account".into(),
|
||||
));
|
||||
let system_accounts = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().copied().chain([
|
||||
nssa::system_faucet_account_id(),
|
||||
nssa::system_bridge_account_id(),
|
||||
]);
|
||||
for account_id in system_accounts {
|
||||
validate_doesnt_modify_account(state, &diff, account_id)?;
|
||||
}
|
||||
|
||||
Ok(diff)
|
||||
@ -184,3 +170,21 @@ pub fn clock_invocation(timestamp: clock_core::Instruction) -> nssa::PublicTrans
|
||||
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_doesnt_modify_account(
|
||||
state: &V03State,
|
||||
diff: &ValidatedStateDiff,
|
||||
account_id: AccountId,
|
||||
) -> Result<(), nssa::error::NssaError> {
|
||||
if diff
|
||||
.public_diff()
|
||||
.get(&account_id)
|
||||
.is_some_and(|post| *post != state.get_account_by_id(account_id))
|
||||
{
|
||||
Err(nssa::error::NssaError::InvalidInput(format!(
|
||||
"Transaction modifies restricted system account {account_id}"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,11 @@
|
||||
},
|
||||
"indexer_rpc_url": "ws://indexer_service:8779",
|
||||
"genesis": [
|
||||
{
|
||||
"supply_bridge_account": {
|
||||
"balance": 1000000
|
||||
}
|
||||
},
|
||||
{
|
||||
"supply_account": {
|
||||
"account_id": "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV",
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -332,7 +332,7 @@ Unlike the public version, `run_hello_world_private.rs` must:
|
||||
|
||||
Luckily all that complexity is hidden behind the `wallet_core.send_privacy_preserving_tx` function:
|
||||
```rust
|
||||
let accounts = vec![PrivacyPreservingAccount::PrivateOwned(account_id)];
|
||||
let accounts = vec![AccountIdentity::PrivateOwned(account_id)];
|
||||
|
||||
// Construct and submit the privacy-preserving transaction
|
||||
wallet_core
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use nssa::{AccountId, program::Program};
|
||||
use wallet::{PrivacyPreservingAccount, WalletCore};
|
||||
use wallet::{AccountIdentity, WalletCore};
|
||||
|
||||
// Before running this example, compile the `hello_world.rs` guest program with:
|
||||
//
|
||||
@ -44,7 +44,7 @@ async fn main() {
|
||||
// Define the desired greeting in ASCII
|
||||
let greeting: Vec<u8> = vec![72, 111, 108, 97, 32, 109, 117, 110, 100, 111, 33];
|
||||
|
||||
let accounts = vec![PrivacyPreservingAccount::PrivateOwned(account_id)];
|
||||
let accounts = vec![AccountIdentity::PrivateOwned(account_id)];
|
||||
|
||||
// Construct and submit the privacy-preserving transaction
|
||||
wallet_core
|
||||
|
||||
@ -4,7 +4,7 @@ use nssa::{
|
||||
AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies,
|
||||
program::Program,
|
||||
};
|
||||
use wallet::{PrivacyPreservingAccount, WalletCore};
|
||||
use wallet::{AccountIdentity, WalletCore};
|
||||
|
||||
// Before running this example, compile the `simple_tail_call.rs` guest program with:
|
||||
//
|
||||
@ -51,7 +51,7 @@ async fn main() {
|
||||
std::iter::once((hello_world.id(), hello_world)).collect();
|
||||
let program_with_dependencies = ProgramWithDependencies::new(simple_tail_call, dependencies);
|
||||
|
||||
let accounts = vec![PrivacyPreservingAccount::PrivateOwned(account_id)];
|
||||
let accounts = vec![AccountIdentity::PrivateOwned(account_id)];
|
||||
|
||||
// Construct and submit the privacy-preserving transaction
|
||||
let instruction = ();
|
||||
|
||||
@ -2,7 +2,7 @@ use clap::{Parser, Subcommand};
|
||||
use common::transaction::NSSATransaction;
|
||||
use nssa::{PublicTransaction, program::Program, public_transaction};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use wallet::{PrivacyPreservingAccount, WalletCore};
|
||||
use wallet::{AccountIdentity, WalletCore};
|
||||
|
||||
// Before running this example, compile the `hello_world_with_move_function.rs` guest program with:
|
||||
//
|
||||
@ -99,7 +99,7 @@ async fn main() {
|
||||
} => {
|
||||
let instruction: Instruction = (WRITE_FUNCTION_ID, greeting.into_bytes());
|
||||
let account_id = account_id.parse().unwrap();
|
||||
let accounts = vec![PrivacyPreservingAccount::PrivateOwned(account_id)];
|
||||
let accounts = vec![AccountIdentity::PrivateOwned(account_id)];
|
||||
|
||||
wallet_core
|
||||
.send_privacy_preserving_tx(
|
||||
@ -138,8 +138,8 @@ async fn main() {
|
||||
let to = to.parse().unwrap();
|
||||
|
||||
let accounts = vec![
|
||||
PrivacyPreservingAccount::Public(from),
|
||||
PrivacyPreservingAccount::PrivateOwned(to),
|
||||
AccountIdentity::Public(from),
|
||||
AccountIdentity::PrivateOwned(to),
|
||||
];
|
||||
|
||||
wallet_core
|
||||
|
||||
@ -6,7 +6,7 @@ use common::{
|
||||
transaction::{NSSATransaction, clock_invocation},
|
||||
};
|
||||
use log::info;
|
||||
use logos_blockchain_core::{header::HeaderId, mantle::ops::channel::MsgId};
|
||||
use logos_blockchain_core::header::HeaderId;
|
||||
use logos_blockchain_zone_sdk::Slot;
|
||||
use nssa::{Account, AccountId, V03State};
|
||||
use nssa_core::BlockId;
|
||||
@ -97,16 +97,16 @@ impl IndexerStore {
|
||||
Ok(self.dbio.calculate_state_for_id(block_id)?)
|
||||
}
|
||||
|
||||
pub fn get_zone_cursor(&self) -> Result<Option<(MsgId, Slot)>> {
|
||||
pub fn get_zone_cursor(&self) -> Result<Option<Slot>> {
|
||||
let Some(bytes) = self.dbio.get_zone_sdk_indexer_cursor_bytes()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let cursor: (MsgId, Slot) = serde_json::from_slice(&bytes)
|
||||
let cursor: Slot = serde_json::from_slice(&bytes)
|
||||
.context("Failed to deserialize stored zone-sdk indexer cursor")?;
|
||||
Ok(Some(cursor))
|
||||
}
|
||||
|
||||
pub fn set_zone_cursor(&self, cursor: &(MsgId, Slot)) -> Result<()> {
|
||||
pub fn set_zone_cursor(&self, cursor: &Slot) -> Result<()> {
|
||||
let bytes =
|
||||
serde_json::to_vec(cursor).context("Failed to serialize zone-sdk indexer cursor")?;
|
||||
self.dbio.put_zone_sdk_indexer_cursor_bytes(&bytes)?;
|
||||
|
||||
@ -81,8 +81,8 @@ impl IndexerCore {
|
||||
error!("Failed to deserialize L2 block from zone-sdk: {e}");
|
||||
// Advance past the broken inscription so we don't
|
||||
// re-process it on restart.
|
||||
cursor = Some((zone_block.id, slot));
|
||||
if let Err(err) = self.store.set_zone_cursor(&(zone_block.id, slot)) {
|
||||
cursor = Some(slot);
|
||||
if let Err(err) = self.store.set_zone_cursor(&slot) {
|
||||
warn!("Failed to persist indexer cursor: {err:#}");
|
||||
}
|
||||
continue;
|
||||
@ -98,8 +98,8 @@ impl IndexerCore {
|
||||
error!("Failed to store block {}: {err:#}", block.header.block_id);
|
||||
}
|
||||
|
||||
cursor = Some((zone_block.id, slot));
|
||||
if let Err(err) = self.store.set_zone_cursor(&(zone_block.id, slot)) {
|
||||
cursor = Some(slot);
|
||||
if let Err(err) = self.store.set_zone_cursor(&slot) {
|
||||
warn!("Failed to persist indexer cursor: {err:#}");
|
||||
}
|
||||
yield Ok(block);
|
||||
|
||||
@ -22,6 +22,7 @@ token_core.workspace = true
|
||||
ata_core.workspace = true
|
||||
vault_core.workspace = true
|
||||
faucet_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
indexer_service_rpc = { workspace = true, features = ["client"] }
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
wallet-ffi.workspace = true
|
||||
@ -34,3 +35,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
hex.workspace = true
|
||||
tempfile.workspace = true
|
||||
bytesize.workspace = true
|
||||
reqwest.workspace = true
|
||||
borsh.workspace = true
|
||||
logos-blockchain-http-api-common.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
|
||||
@ -7,10 +7,13 @@ use integration_tests::{
|
||||
public_mention, verify_commitment_is_in_state,
|
||||
};
|
||||
use log::info;
|
||||
use nssa::{AccountId, program::Program};
|
||||
use nssa::{
|
||||
AccountId, SharedSecretKey, execute_and_prove,
|
||||
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
|
||||
};
|
||||
use nssa_core::{
|
||||
NullifierPublicKey,
|
||||
encryption::{MlKem768EncapsulationKey, ViewingPublicKey},
|
||||
InputAccountIdentity, NullifierPublicKey, account::AccountWithMetadata,
|
||||
encryption::{EphemeralPublicKey, MlKem768EncapsulationKey, ViewingPublicKey},
|
||||
};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
@ -639,13 +642,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
|
||||
use nssa::{
|
||||
EphemeralPublicKey, SharedSecretKey, execute_and_prove,
|
||||
privacy_preserving_transaction::{self, circuit::ProgramWithDependencies},
|
||||
};
|
||||
use nssa_core::{InputAccountIdentity, account::AccountWithMetadata};
|
||||
|
||||
async fn ppt_cant_chain_call_faucet() -> Result<()> {
|
||||
let ctx = TestContext::new().await?;
|
||||
|
||||
let binary = std::fs::read(
|
||||
@ -708,7 +705,7 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
|
||||
let instruction =
|
||||
Program::serialize_instruction((faucet_program_id, vault_program_id, attacker_id, amount))?;
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
let res = execute_and_prove(
|
||||
vec![faucet_pre, vault_pda_pre],
|
||||
instruction,
|
||||
vec![
|
||||
@ -717,50 +714,13 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
|
||||
npk,
|
||||
ssk,
|
||||
identifier: 1337,
|
||||
seed: None,
|
||||
},
|
||||
],
|
||||
&program_with_deps,
|
||||
)?;
|
||||
);
|
||||
|
||||
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
|
||||
vec![faucet_account_id],
|
||||
vec![],
|
||||
vec![(npk, vpk, epk)],
|
||||
output,
|
||||
)?;
|
||||
let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
|
||||
let attack_ppt = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new(
|
||||
message,
|
||||
witness_set,
|
||||
));
|
||||
|
||||
let faucet_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(faucet_account_id)
|
||||
.await?;
|
||||
let vault_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(attacker_vault_id)
|
||||
.await?;
|
||||
|
||||
let tx_hash = ctx.sequencer_client().send_transaction(attack_ppt).await?;
|
||||
|
||||
info!("Waiting for next block creation");
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
let faucet_balance_after = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(faucet_account_id)
|
||||
.await?;
|
||||
let vault_balance_after = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(attacker_vault_id)
|
||||
.await?;
|
||||
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
|
||||
|
||||
assert_eq!(faucet_balance_after, faucet_balance_before);
|
||||
assert_eq!(vault_balance_after, vault_balance_before);
|
||||
assert!(tx_on_chain.is_none());
|
||||
assert!(res.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -427,7 +427,7 @@ async fn cannot_execute_faucet_program() -> Result<()> {
|
||||
Program::faucet().id(),
|
||||
vec![faucet_account_id, recipient_vault_id],
|
||||
vec![],
|
||||
faucet_core::Instruction::Transfer {
|
||||
faucet_core::Instruction::GenesisTransferVault {
|
||||
vault_program_id,
|
||||
recipient_id: recipient,
|
||||
amount,
|
||||
|
||||
450
integration_tests/tests/bridge.rs
Normal file
450
integration_tests/tests/bridge.rs
Normal file
@ -0,0 +1,450 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
clippy::arithmetic_side_effects,
|
||||
reason = "We don't care about these in tests"
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use borsh::BorshSerialize;
|
||||
use common::transaction::NSSATransaction;
|
||||
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext};
|
||||
use log::info;
|
||||
use logos_blockchain_core::mantle::{Value, ledger::Inputs, ops::channel::deposit::DepositOp};
|
||||
use logos_blockchain_http_api_common::bodies::{
|
||||
channel::ChannelDepositRequestBody,
|
||||
wallet::{
|
||||
balance::WalletBalanceResponseBody,
|
||||
transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody},
|
||||
},
|
||||
};
|
||||
use nssa::{
|
||||
AccountId, execute_and_prove, privacy_preserving_transaction, program::Program,
|
||||
public_transaction,
|
||||
};
|
||||
use nssa_core::{InputAccountIdentity, account::AccountWithMetadata};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
|
||||
const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(2);
|
||||
|
||||
#[test]
|
||||
async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
|
||||
let ctx = TestContext::new().await?;
|
||||
|
||||
let recipient_id = ctx.existing_public_accounts()[0];
|
||||
let bridge_account_id = nssa::system_bridge_account_id();
|
||||
let vault_program_id = Program::vault().id();
|
||||
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Program::bridge().id(),
|
||||
vec![bridge_account_id, recipient_vault_id],
|
||||
vec![],
|
||||
bridge_core::Instruction::Deposit {
|
||||
vault_program_id,
|
||||
recipient_id,
|
||||
amount: 1,
|
||||
},
|
||||
)
|
||||
.context("Failed to build public bridge deposit transaction")?;
|
||||
|
||||
let attack_tx = NSSATransaction::Public(nssa::PublicTransaction::new(
|
||||
message,
|
||||
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
));
|
||||
|
||||
let bridge_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(bridge_account_id)
|
||||
.await?;
|
||||
let vault_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_vault_id)
|
||||
.await?;
|
||||
|
||||
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
let bridge_balance_after = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(bridge_account_id)
|
||||
.await?;
|
||||
let vault_balance_after = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_vault_id)
|
||||
.await?;
|
||||
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
|
||||
|
||||
assert_eq!(bridge_balance_after, bridge_balance_before);
|
||||
assert_eq!(vault_balance_after, vault_balance_before);
|
||||
assert!(
|
||||
tx_on_chain.is_none(),
|
||||
"Direct public bridge::Deposit invocation should be rejected"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
|
||||
let ctx = TestContext::new().await?;
|
||||
|
||||
let recipient_id = ctx.existing_public_accounts()[0];
|
||||
let bridge_account_id = nssa::system_bridge_account_id();
|
||||
let vault_program_id = Program::vault().id();
|
||||
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
||||
|
||||
// Get pre-state of bridge and vault accounts
|
||||
let bridge_pre = AccountWithMetadata::new(
|
||||
ctx.sequencer_client()
|
||||
.get_account(bridge_account_id)
|
||||
.await?,
|
||||
false,
|
||||
bridge_account_id,
|
||||
);
|
||||
let vault_pre = AccountWithMetadata::new(
|
||||
ctx.sequencer_client()
|
||||
.get_account(recipient_vault_id)
|
||||
.await?,
|
||||
false,
|
||||
recipient_vault_id,
|
||||
);
|
||||
|
||||
// Create program with dependencies
|
||||
let program_with_deps =
|
||||
nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies::new(
|
||||
Program::bridge(),
|
||||
[
|
||||
(vault_program_id, Program::vault()),
|
||||
(
|
||||
Program::authenticated_transfer_program().id(),
|
||||
Program::authenticated_transfer_program(),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Serialize the bridge deposit instruction
|
||||
let instruction = Program::serialize_instruction(bridge_core::Instruction::Deposit {
|
||||
vault_program_id,
|
||||
recipient_id,
|
||||
amount: 1,
|
||||
})
|
||||
.context("Failed to serialize bridge deposit instruction")?;
|
||||
|
||||
// Execute and prove the bridge deposit
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![bridge_pre.clone(), vault_pre.clone()],
|
||||
instruction,
|
||||
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
|
||||
&program_with_deps,
|
||||
)
|
||||
.context("Failed to execute/prove bridge deposit")?;
|
||||
|
||||
// Create privacy-preserving transaction from circuit output
|
||||
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
|
||||
vec![bridge_account_id, recipient_vault_id],
|
||||
vec![bridge_pre.account.nonce, vault_pre.account.nonce],
|
||||
vec![],
|
||||
output,
|
||||
)
|
||||
.context("Failed to build privacy-preserving bridge deposit message")?;
|
||||
|
||||
let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
|
||||
let attack_tx = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new(
|
||||
message,
|
||||
witness_set,
|
||||
));
|
||||
|
||||
let bridge_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(bridge_account_id)
|
||||
.await?;
|
||||
let vault_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_vault_id)
|
||||
.await?;
|
||||
|
||||
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
let bridge_balance_after = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(bridge_account_id)
|
||||
.await?;
|
||||
let vault_balance_after = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_vault_id)
|
||||
.await?;
|
||||
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
|
||||
|
||||
assert_eq!(bridge_balance_after, bridge_balance_before);
|
||||
assert_eq!(vault_balance_after, vault_balance_before);
|
||||
assert!(
|
||||
tx_on_chain.is_none(),
|
||||
"Privacy-preserving bridge::Deposit invocation should be rejected"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn submit_bedrock_deposit(
|
||||
bedrock_addr: std::net::SocketAddr,
|
||||
recipient_id: AccountId,
|
||||
amount: u128,
|
||||
) -> anyhow::Result<()> {
|
||||
#[derive(BorshSerialize)]
|
||||
struct DepositMetadata {
|
||||
recipient_id: AccountId,
|
||||
}
|
||||
|
||||
// Encode deposit metadata
|
||||
let metadata = borsh::to_vec(&DepositMetadata { recipient_id })
|
||||
.context("Failed to encode deposit metadata")?;
|
||||
|
||||
let funding_key = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26";
|
||||
|
||||
let amount: Value = amount
|
||||
.try_into()
|
||||
.context("Deposit amount does not fit Bedrock Value type")?;
|
||||
let channel_id = integration_tests::config::bedrock_channel_id();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let query_balance = || async {
|
||||
let balance_response = client
|
||||
.get(format!(
|
||||
"http://{bedrock_addr}/wallet/{funding_key}/balance"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to query Bedrock wallet balance")?;
|
||||
|
||||
let balance_response = check_response_success(balance_response).await?;
|
||||
|
||||
balance_response
|
||||
.json::<WalletBalanceResponseBody>()
|
||||
.await
|
||||
.context("Failed to decode Bedrock balance response")
|
||||
};
|
||||
|
||||
let mut balance = query_balance().await?;
|
||||
|
||||
info!(
|
||||
"Queried Bedrock balance for key {funding_key}: {:?}",
|
||||
balance.balance
|
||||
);
|
||||
|
||||
if balance.balance < amount {
|
||||
anyhow::bail!(
|
||||
"Bedrock wallet with key {funding_key} has insufficient balance {:?} for deposit amount {:?}",
|
||||
balance.balance,
|
||||
amount
|
||||
);
|
||||
}
|
||||
|
||||
let mut selected_note_id = balance
|
||||
.notes
|
||||
.iter()
|
||||
.find_map(|(note_id, value)| (*value == amount).then_some(*note_id));
|
||||
|
||||
if selected_note_id.is_none() {
|
||||
let transfer_body = WalletTransferFundsRequestBody {
|
||||
tip: None,
|
||||
change_public_key: balance.address,
|
||||
funding_public_keys: vec![balance.address],
|
||||
recipient_public_key: balance.address,
|
||||
amount,
|
||||
};
|
||||
|
||||
let transfer_response = client
|
||||
.post(format!(
|
||||
"http://{bedrock_addr}/wallet/transactions/transfer-funds"
|
||||
))
|
||||
.json(&transfer_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to submit Bedrock transfer-funds request")?;
|
||||
let transfer_response = check_response_success(transfer_response).await?;
|
||||
|
||||
let transfer: WalletTransferFundsResponseBody = transfer_response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to decode Bedrock transfer-funds response")?;
|
||||
|
||||
info!(
|
||||
"Submitted transfer-funds to create exact deposit note, tx hash {:?}",
|
||||
transfer.hash
|
||||
);
|
||||
|
||||
let mut found_note = None;
|
||||
for _ in 0..20 {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
balance = query_balance().await?;
|
||||
found_note = balance
|
||||
.notes
|
||||
.iter()
|
||||
.find_map(|(note_id, value)| (*value == amount).then_some(*note_id));
|
||||
if found_note.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selected_note_id = found_note;
|
||||
}
|
||||
|
||||
let Some(selected_note_id) = selected_note_id else {
|
||||
anyhow::bail!(
|
||||
"Failed to locate exact-value note {amount:?} for Bedrock deposit; available notes: {:?}",
|
||||
balance.notes,
|
||||
);
|
||||
};
|
||||
|
||||
let body = ChannelDepositRequestBody {
|
||||
tip: None,
|
||||
deposit: DepositOp {
|
||||
channel_id,
|
||||
inputs: Inputs::new(vec![selected_note_id]),
|
||||
metadata,
|
||||
},
|
||||
change_public_key: balance.address,
|
||||
funding_public_keys: vec![balance.address],
|
||||
max_tx_fee: 1_000_u64.into(),
|
||||
};
|
||||
|
||||
let response = client
|
||||
.post(format!("http://{bedrock_addr}/channel/deposit"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to submit Bedrock deposit request")?;
|
||||
let response = check_response_success(response).await?;
|
||||
|
||||
let body_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<failed to decode>".to_owned());
|
||||
info!(
|
||||
"Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount {amount}, response body: {body_text}",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_response_success(response: reqwest::Response) -> anyhow::Result<reqwest::Response> {
|
||||
if response.status().is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Request failed with status {status} and body {body_text}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_vault_balance(
|
||||
ctx: &TestContext,
|
||||
vault_id: AccountId,
|
||||
expected_balance: u128,
|
||||
) -> anyhow::Result<()> {
|
||||
let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK
|
||||
+ Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS);
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
let balance = ctx.sequencer_client().get_account_balance(vault_id).await?;
|
||||
if balance == expected_balance {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Timed out waiting for vault {vault_id} balance to reach {expected_balance}")
|
||||
})?
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<()> {
|
||||
let ctx = TestContext::new().await?;
|
||||
|
||||
let recipient_id = ctx.existing_public_accounts()[0];
|
||||
let vault_program_id = Program::vault().id();
|
||||
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
|
||||
|
||||
let vault_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_vault_id)
|
||||
.await?;
|
||||
let recipient_balance_before = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_id)
|
||||
.await?;
|
||||
|
||||
// Submit deposit to Bedrock
|
||||
submit_bedrock_deposit(ctx.bedrock_addr(), recipient_id, 1).await?;
|
||||
|
||||
// Wait for vault to receive the deposit (minted from bridge to vault)
|
||||
wait_for_vault_balance(&ctx, recipient_vault_id, vault_balance_before + 1).await?;
|
||||
|
||||
// Now claim funds from vault back to recipient
|
||||
let nonces = ctx
|
||||
.wallet()
|
||||
.get_accounts_nonces(vec![recipient_id])
|
||||
.await
|
||||
.context("Failed to get nonce for vault claim")?;
|
||||
|
||||
let signing_key = ctx
|
||||
.wallet()
|
||||
.storage()
|
||||
.key_chain()
|
||||
.pub_account_signing_key(recipient_id)
|
||||
.with_context(|| format!("Missing signing key for account {recipient_id}"))?;
|
||||
|
||||
let claim_message = public_transaction::Message::try_new(
|
||||
vault_program_id,
|
||||
vec![recipient_id, recipient_vault_id],
|
||||
nonces,
|
||||
vault_core::Instruction::Claim { amount: 1 },
|
||||
)
|
||||
.context("Failed to build vault claim message")?;
|
||||
|
||||
let claim_witness_set =
|
||||
public_transaction::WitnessSet::for_message(&claim_message, &[signing_key]);
|
||||
let claim_tx = NSSATransaction::Public(nssa::PublicTransaction::new(
|
||||
claim_message,
|
||||
claim_witness_set,
|
||||
));
|
||||
|
||||
let claim_hash = ctx.sequencer_client().send_transaction(claim_tx).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?;
|
||||
let vault_balance_after_claim = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_vault_id)
|
||||
.await?;
|
||||
let recipient_balance_after_claim = ctx
|
||||
.sequencer_client()
|
||||
.get_account_balance(recipient_id)
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
claim_on_chain.is_some(),
|
||||
"Vault claim transaction must be included on-chain"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_balance_after_claim, vault_balance_before,
|
||||
"Vault balance should return to initial state after claim"
|
||||
);
|
||||
assert_eq!(
|
||||
recipient_balance_after_claim,
|
||||
recipient_balance_before + 1,
|
||||
"Recipient balance should increase by claimed amount"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -20,13 +20,12 @@ use wallet::{
|
||||
};
|
||||
|
||||
/// Maximum time to wait for the indexer to catch up to the sequencer.
|
||||
const L2_TO_L1_TIMEOUT_MILLIS: u64 = 180_000;
|
||||
const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(6);
|
||||
|
||||
/// Poll the indexer until its last finalized block id reaches the sequencer's
|
||||
/// current last block id or until [`L2_TO_L1_TIMEOUT_MILLIS`] elapses.
|
||||
/// Returns the last indexer block id observed.
|
||||
async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> Result<u64> {
|
||||
let timeout = Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS);
|
||||
let block_id_to_catch_up =
|
||||
sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?;
|
||||
let mut last_ind: u64 = 1;
|
||||
@ -50,11 +49,11 @@ async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> Result<u64> {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(timeout, inner)
|
||||
tokio::time::timeout(L2_TO_L1_TIMEOUT, inner)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Indexer failed to catch up within {L2_TO_L1_TIMEOUT_MILLIS} milliseconds. Last indexer block id observed: {last_ind}, but needed to catch up to at least {block_id_to_catch_up}"
|
||||
"Indexer failed to catch up within {L2_TO_L1_TIMEOUT:?}. Last indexer block id observed: {last_ind}, but needed to catch up to at least {block_id_to_catch_up}"
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ use std::{
|
||||
fs::File,
|
||||
io::Write as _,
|
||||
net::SocketAddr,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
@ -34,7 +35,7 @@ use wallet::{
|
||||
};
|
||||
|
||||
/// Maximum time to wait for the indexer to catch up to the sequencer.
|
||||
const L2_TO_L1_TIMEOUT_MILLIS: u64 = 180_000;
|
||||
const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(6);
|
||||
|
||||
unsafe extern "C" {
|
||||
unsafe fn query_last_block(
|
||||
@ -114,7 +115,7 @@ fn indexer_test_run_ffi() -> Result<()> {
|
||||
let (ctx, indexer_ffi, _indexer_dir) = setup()?;
|
||||
|
||||
// RUN OBSERVATION
|
||||
std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS));
|
||||
std::thread::sleep(L2_TO_L1_TIMEOUT);
|
||||
|
||||
// Safety: ctx runtime is valid for the lifetime of the returned Runtime
|
||||
let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) };
|
||||
@ -138,7 +139,7 @@ fn indexer_ffi_block_batching() -> Result<()> {
|
||||
|
||||
// WAIT
|
||||
info!("Waiting for indexer to parse blocks");
|
||||
std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS));
|
||||
std::thread::sleep(L2_TO_L1_TIMEOUT);
|
||||
|
||||
// Safety: ctx runtime is valid for the lifetime of the returned Runtime
|
||||
let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) };
|
||||
@ -267,7 +268,7 @@ fn indexer_ffi_state_consistency() -> Result<()> {
|
||||
|
||||
// WAIT
|
||||
info!("Waiting for indexer to parse blocks");
|
||||
std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS));
|
||||
std::thread::sleep(L2_TO_L1_TIMEOUT);
|
||||
|
||||
// Safety: ctx runtime is valid for the lifetime of the returned Runtime
|
||||
let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) };
|
||||
@ -374,7 +375,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> {
|
||||
assert_eq!(acc_2_balance, 20100);
|
||||
|
||||
info!("Waiting for indexer to parse blocks");
|
||||
std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS));
|
||||
std::thread::sleep(L2_TO_L1_TIMEOUT);
|
||||
|
||||
// Safety: ctx runtime is valid for the lifetime of the returned Runtime
|
||||
let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) };
|
||||
|
||||
@ -6,27 +6,37 @@
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use authenticated_transfer_core::Instruction as AuthTransferInstruction;
|
||||
use common::transaction::NSSATransaction;
|
||||
use integration_tests::{
|
||||
NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext,
|
||||
NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext,
|
||||
verify_commitment_is_in_state,
|
||||
};
|
||||
use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder;
|
||||
use log::info;
|
||||
use nssa::{
|
||||
AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies,
|
||||
AccountId, PrivacyPreservingTransaction, ProgramId,
|
||||
privacy_preserving_transaction::{
|
||||
circuit::{ProgramWithDependencies, execute_and_prove},
|
||||
message::Message,
|
||||
witness_set::WitnessSet,
|
||||
},
|
||||
program::Program,
|
||||
};
|
||||
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey, program::PdaSeed};
|
||||
use nssa_core::{
|
||||
InputAccountIdentity, NullifierPublicKey,
|
||||
account::{Account, AccountWithMetadata},
|
||||
encryption::ViewingPublicKey,
|
||||
program::PdaSeed,
|
||||
};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
use wallet::{
|
||||
PrivacyPreservingAccount, WalletCore,
|
||||
AccountIdentity, WalletCore,
|
||||
cli::{Command, account::AccountSubcommand},
|
||||
};
|
||||
|
||||
/// Funds a private PDA via the proxy program with a chained call to `auth_transfer`.
|
||||
///
|
||||
/// A direct call to `auth_transfer` cannot establish the PDA-to-npk binding because it uses
|
||||
/// `Claim::Authorized` rather than `Claim::Pda`. Routing through the proxy provides the binding
|
||||
/// via `pda_seeds` in the chained call to `auth_transfer`.
|
||||
/// Funds a private PDA by calling `auth_transfer` directly.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "test helper — grouping args would obscure intent"
|
||||
@ -34,32 +44,68 @@ use wallet::{
|
||||
async fn fund_private_pda(
|
||||
wallet: &WalletCore,
|
||||
sender: AccountId,
|
||||
pda_account_id: AccountId,
|
||||
npk: NullifierPublicKey,
|
||||
vpk: ViewingPublicKey,
|
||||
identifier: u128,
|
||||
seed: PdaSeed,
|
||||
authority_program_id: ProgramId,
|
||||
amount: u128,
|
||||
proxy_program: &ProgramWithDependencies,
|
||||
auth_transfer_id: ProgramId,
|
||||
auth_transfer: &ProgramWithDependencies,
|
||||
) -> Result<()> {
|
||||
wallet
|
||||
.send_privacy_preserving_tx(
|
||||
vec![
|
||||
PrivacyPreservingAccount::Public(sender),
|
||||
PrivacyPreservingAccount::PrivatePdaForeign {
|
||||
account_id: pda_account_id,
|
||||
npk,
|
||||
vpk,
|
||||
identifier,
|
||||
},
|
||||
],
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id, true))
|
||||
.context("failed to serialize pda_fund_spend_proxy fund instruction")?,
|
||||
proxy_program,
|
||||
)
|
||||
let pda_account_id = AccountId::for_private_pda(&authority_program_id, &seed, &npk, identifier);
|
||||
let sender_account = wallet
|
||||
.get_account_public(sender)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to get sender account: {e}"))?;
|
||||
let sender_sk = wallet
|
||||
.get_account_public_signing_key(sender)
|
||||
.context("sender signing key not found")?;
|
||||
|
||||
let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender);
|
||||
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_account_id);
|
||||
|
||||
let eph_holder = EphemeralKeyHolder::new(&npk);
|
||||
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
|
||||
let epk = eph_holder.generate_ephemeral_public_key();
|
||||
|
||||
let instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
|
||||
.context("failed to serialize auth_transfer instruction")?;
|
||||
|
||||
let account_identities = vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivatePdaInit {
|
||||
npk,
|
||||
ssk,
|
||||
identifier,
|
||||
seed: Some((seed, authority_program_id)),
|
||||
},
|
||||
];
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![sender_pre, pda_pre],
|
||||
instruction,
|
||||
account_identities,
|
||||
auth_transfer,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("circuit proving failed: {e}"))?;
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![sender],
|
||||
vec![sender_account.nonce],
|
||||
vec![(npk, vpk, epk)],
|
||||
output,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("message build failed: {e}"))?;
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[sender_sk]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
|
||||
wallet
|
||||
.sequencer_client
|
||||
.send_transaction(NSSATransaction::PrivacyPreserving(tx))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("send transaction failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -78,20 +124,20 @@ async fn spend_private_pda(
|
||||
seed: PdaSeed,
|
||||
amount: u128,
|
||||
spend_program: &ProgramWithDependencies,
|
||||
auth_transfer_id: nssa::ProgramId,
|
||||
auth_transfer_id: ProgramId,
|
||||
) -> Result<()> {
|
||||
wallet
|
||||
.send_privacy_preserving_tx(
|
||||
vec![
|
||||
PrivacyPreservingAccount::PrivatePdaOwned(pda_account_id),
|
||||
PrivacyPreservingAccount::PrivateForeign {
|
||||
AccountIdentity::PrivatePdaOwned(pda_account_id),
|
||||
AccountIdentity::PrivateForeign {
|
||||
npk: recipient_npk,
|
||||
vpk: recipient_vpk,
|
||||
identifier: 0,
|
||||
},
|
||||
],
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id, false))
|
||||
.context("failed to serialize pda_fund_spend_proxy instruction")?,
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id))
|
||||
.context("failed to serialize pda_spend_proxy instruction")?,
|
||||
spend_program,
|
||||
)
|
||||
.await
|
||||
@ -124,9 +170,9 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
|
||||
let proxy = {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../artifacts/test_program_methods")
|
||||
.join(NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY);
|
||||
.join(NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY);
|
||||
Program::new(std::fs::read(&path).with_context(|| format!("reading {path:?}"))?)
|
||||
.context("invalid pda_fund_spend_proxy binary")?
|
||||
.context("invalid pda_spend_proxy binary")?
|
||||
};
|
||||
let auth_transfer = Program::authenticated_transfer_program();
|
||||
let proxy_id = proxy.id();
|
||||
@ -134,6 +180,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
|
||||
let seed = PdaSeed::new([42; 32]);
|
||||
let amount: u128 = 100;
|
||||
|
||||
let auth_transfer_program = ProgramWithDependencies::new(auth_transfer.clone(), [].into());
|
||||
let spend_program =
|
||||
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
|
||||
|
||||
@ -151,14 +198,13 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
|
||||
fund_private_pda(
|
||||
ctx.wallet(),
|
||||
sender_0,
|
||||
alice_pda_0_id,
|
||||
alice_npk,
|
||||
alice_vpk.clone(),
|
||||
0,
|
||||
seed,
|
||||
proxy_id,
|
||||
amount,
|
||||
&spend_program,
|
||||
auth_transfer_id,
|
||||
&auth_transfer_program,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -166,14 +212,13 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
|
||||
fund_private_pda(
|
||||
ctx.wallet(),
|
||||
sender_1,
|
||||
alice_pda_1_id,
|
||||
alice_npk,
|
||||
alice_vpk.clone(),
|
||||
1,
|
||||
seed,
|
||||
proxy_id,
|
||||
amount,
|
||||
&spend_program,
|
||||
auth_transfer_id,
|
||||
&auth_transfer_program,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@ -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
|
||||
@ -48,6 +48,14 @@ pub struct MemPoolHandle<T> {
|
||||
sender: Sender<T>,
|
||||
}
|
||||
|
||||
impl<T> Clone for MemPoolHandle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
sender: self.sender.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> MemPoolHandle<T> {
|
||||
const fn new(sender: Sender<T>) -> Self {
|
||||
Self { sender }
|
||||
|
||||
@ -11,10 +11,11 @@ workspace = true
|
||||
nssa_core = { workspace = true, features = ["host"] }
|
||||
clock_core.workspace = true
|
||||
faucet_core.workspace = true
|
||||
bridge_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
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::{
|
||||
NullifierSecretKey, SharedSecretKey,
|
||||
account::{Account, AccountWithMetadata},
|
||||
encryption::Ciphertext,
|
||||
program::{BlockValidityWindow, ProgramId, ProgramOutput, TimestampValidityWindow},
|
||||
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@ -60,15 +60,28 @@ pub enum InputAccountIdentity {
|
||||
npk: NullifierPublicKey,
|
||||
ssk: SharedSecretKey,
|
||||
identifier: Identifier,
|
||||
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
|
||||
/// external derivation check
|
||||
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
|
||||
/// pre_state.account_id` rather than requiring a `Claim::Pda` or caller
|
||||
/// `pda_seeds` to establish the binding. The `pre_state` must have `is_authorized
|
||||
/// == false`.
|
||||
seed: Option<(PdaSeed, ProgramId)>,
|
||||
},
|
||||
/// Update of an existing private PDA, authorized, with membership proof. `npk` is derived
|
||||
/// from `nsk`. Authorization is established upstream by a caller `pda_seeds` match or a
|
||||
/// Update of an existing private PDA, with membership proof. `npk` is derived
|
||||
/// from `nsk`. Authorization may be established upstream by a caller `pda_seeds` match or a
|
||||
/// previously-seen authorization in a chained call.
|
||||
PrivatePdaUpdate {
|
||||
ssk: SharedSecretKey,
|
||||
nsk: NullifierSecretKey,
|
||||
membership_proof: MembershipProof,
|
||||
identifier: Identifier,
|
||||
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
|
||||
/// external derivation check
|
||||
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
|
||||
/// pre_state.account_id` rather than requiring a caller `pda_seeds` to establish
|
||||
/// the binding. The `pre_state` must have `is_authorized == false`.
|
||||
seed: Option<(PdaSeed, ProgramId)>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -7,8 +7,9 @@ use crate::account::{Account, AccountId};
|
||||
/// A commitment to all zero data.
|
||||
/// ```python
|
||||
/// from hashlib import sha256
|
||||
/// prefix = b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
/// hasher = sha256()
|
||||
/// hasher.update(bytes([0] * 32 + [0] * 32 + [0] * 16 + [0] * 16 + list(sha256().digest())))
|
||||
/// hasher.update(prefix + bytes([0] * 32 + [0] * 32 + [0] * 16 + [0] * 16 + list(sha256().digest())))
|
||||
/// DUMMY_COMMITMENT = hasher.digest()
|
||||
/// ```
|
||||
pub const DUMMY_COMMITMENT: Commitment = Commitment([
|
||||
|
||||
@ -96,6 +96,9 @@ pub enum InvalidProgramBehaviorError {
|
||||
#[error("Unauthorized account marked as authorized")]
|
||||
InvalidAccountAuthorization { account_id: AccountId },
|
||||
|
||||
#[error("Authorized account marked as not authorized")]
|
||||
AuthorizedAccountMarkedAsNotAuthorized { account_id: AccountId },
|
||||
|
||||
#[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")]
|
||||
MismatchedProgramId {
|
||||
expected: ProgramId,
|
||||
|
||||
@ -18,7 +18,7 @@ pub use public_transaction::PublicTransaction;
|
||||
pub use signature::{PrivateKey, PublicKey, Signature};
|
||||
pub use state::{
|
||||
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
|
||||
CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_faucet_account_id,
|
||||
CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_bridge_account_id, system_faucet_account_id,
|
||||
};
|
||||
pub use validated_state_diff::ValidatedStateDiff;
|
||||
|
||||
|
||||
@ -462,6 +462,7 @@ mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier,
|
||||
seed: None,
|
||||
}],
|
||||
&program.clone().into(),
|
||||
)
|
||||
@ -490,7 +491,7 @@ mod tests {
|
||||
let shared_secret_pda =
|
||||
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
|
||||
|
||||
// PDA (new, mask 3)
|
||||
// PDA (new, private PDA)
|
||||
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
|
||||
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
|
||||
|
||||
@ -508,6 +509,7 @@ mod tests {
|
||||
npk,
|
||||
ssk: shared_secret_pda,
|
||||
identifier: 0,
|
||||
seed: None,
|
||||
}],
|
||||
&program_with_deps,
|
||||
);
|
||||
@ -560,6 +562,7 @@ mod tests {
|
||||
npk,
|
||||
ssk: shared_secret_pda,
|
||||
identifier: 0,
|
||||
seed: None,
|
||||
},
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
@ -751,7 +754,7 @@ mod tests {
|
||||
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
|
||||
#[test]
|
||||
fn private_pda_update_encrypts_pda_kind_with_identifier() {
|
||||
let program = Program::pda_fund_spend_proxy();
|
||||
let program = Program::pda_spend_proxy();
|
||||
let auth_transfer = Program::authenticated_transfer_program();
|
||||
let keys = test_private_account_keys_1();
|
||||
let npk = keys.npk();
|
||||
@ -788,6 +791,7 @@ mod tests {
|
||||
nsk: keys.nsk,
|
||||
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
||||
identifier,
|
||||
seed: None,
|
||||
},
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
@ -824,6 +828,7 @@ mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier: 99,
|
||||
seed: None,
|
||||
}],
|
||||
&program.into(),
|
||||
);
|
||||
@ -833,7 +838,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn private_pda_update_identifier_mismatch_fails() {
|
||||
let program = Program::pda_fund_spend_proxy();
|
||||
let program = Program::pda_spend_proxy();
|
||||
let auth_transfer = Program::authenticated_transfer_program();
|
||||
let keys = test_private_account_keys_1();
|
||||
let npk = keys.npk();
|
||||
@ -867,6 +872,7 @@ mod tests {
|
||||
nsk: keys.nsk,
|
||||
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
|
||||
identifier: 99,
|
||||
seed: None,
|
||||
},
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
|
||||
@ -10,8 +10,9 @@ use crate::{
|
||||
error::NssaError,
|
||||
program_methods::{
|
||||
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF,
|
||||
FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF,
|
||||
CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF,
|
||||
VAULT_ID,
|
||||
},
|
||||
};
|
||||
|
||||
@ -164,6 +165,14 @@ impl Program {
|
||||
elf: FAUCET_ELF.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bridge() -> Self {
|
||||
Self {
|
||||
id: BRIDGE_ID,
|
||||
elf: BRIDGE_ELF.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Testnet only. Refactor to prevent compilation on mainnet.
|
||||
@ -194,9 +203,9 @@ mod tests {
|
||||
program::Program,
|
||||
program_methods::{
|
||||
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF,
|
||||
FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, TOKEN_ELF,
|
||||
TOKEN_ID, VAULT_ELF, VAULT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID,
|
||||
CLOCK_ELF, CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF,
|
||||
PINATA_TOKEN_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
|
||||
},
|
||||
};
|
||||
|
||||
@ -350,12 +359,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pda_fund_spend_proxy() -> Self {
|
||||
use test_program_methods::{PDA_FUND_SPEND_PROXY_ELF, PDA_FUND_SPEND_PROXY_ID};
|
||||
pub fn pda_spend_proxy() -> Self {
|
||||
use test_program_methods::{PDA_SPEND_PROXY_ELF, PDA_SPEND_PROXY_ID};
|
||||
|
||||
Self {
|
||||
id: PDA_FUND_SPEND_PROXY_ID,
|
||||
elf: PDA_FUND_SPEND_PROXY_ELF.to_vec(),
|
||||
id: PDA_SPEND_PROXY_ID,
|
||||
elf: PDA_SPEND_PROXY_ELF.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -529,6 +538,7 @@ mod tests {
|
||||
let token_program = Program::token();
|
||||
let vault_program = Program::vault();
|
||||
let faucet_program = Program::faucet();
|
||||
let bridge_program = Program::bridge();
|
||||
let pinata_program = Program::pinata();
|
||||
|
||||
assert_eq!(auth_transfer_program.id, AUTHENTICATED_TRANSFER_ID);
|
||||
@ -539,6 +549,8 @@ mod tests {
|
||||
assert_eq!(vault_program.elf, VAULT_ELF);
|
||||
assert_eq!(faucet_program.id, FAUCET_ID);
|
||||
assert_eq!(faucet_program.elf, FAUCET_ELF);
|
||||
assert_eq!(bridge_program.id, BRIDGE_ID);
|
||||
assert_eq!(bridge_program.elf, BRIDGE_ELF);
|
||||
assert_eq!(pinata_program.id, PINATA_ID);
|
||||
assert_eq!(pinata_program.elf, PINATA_ELF);
|
||||
}
|
||||
@ -551,6 +563,7 @@ mod tests {
|
||||
(ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID),
|
||||
(CLOCK_ELF, CLOCK_ID),
|
||||
(FAUCET_ELF, FAUCET_ID),
|
||||
(BRIDGE_ELF, BRIDGE_ID),
|
||||
(PINATA_ELF, PINATA_ID),
|
||||
(PINATA_TOKEN_ELF, PINATA_TOKEN_ID),
|
||||
(TOKEN_ELF, TOKEN_ID),
|
||||
|
||||
@ -126,8 +126,11 @@ impl Default for V03State {
|
||||
fn default() -> Self {
|
||||
let faucet_account_id = system_faucet_account_id();
|
||||
let faucet_account = system_faucet_account();
|
||||
let bridge_account_id = system_bridge_account_id();
|
||||
let bridge_account = system_bridge_account();
|
||||
let mut public_state = HashMap::new();
|
||||
public_state.insert(faucet_account_id, faucet_account);
|
||||
public_state.insert(bridge_account_id, bridge_account);
|
||||
|
||||
Self {
|
||||
public_state,
|
||||
@ -150,6 +153,7 @@ impl V03State {
|
||||
genesis_timestamp: nssa_core::Timestamp,
|
||||
) -> Self {
|
||||
let faucet_account_id = system_faucet_account_id();
|
||||
let bridge_account_id = system_bridge_account_id();
|
||||
let authenticated_transfer_program = Program::authenticated_transfer_program();
|
||||
let mut public_state: HashMap<_, _> = initial_data
|
||||
.iter()
|
||||
@ -164,7 +168,9 @@ impl V03State {
|
||||
})
|
||||
.collect();
|
||||
let faucet_account = system_faucet_account();
|
||||
let bridge_account = system_bridge_account();
|
||||
public_state.insert(faucet_account_id, faucet_account);
|
||||
public_state.insert(bridge_account_id, bridge_account);
|
||||
|
||||
let mut commitment_set = CommitmentSet::with_capacity(32);
|
||||
commitment_set.extend(&[DUMMY_COMMITMENT]);
|
||||
@ -190,6 +196,7 @@ impl V03State {
|
||||
this.insert_program(Program::ata());
|
||||
this.insert_program(Program::vault());
|
||||
this.insert_program(Program::faucet());
|
||||
this.insert_program(Program::bridge());
|
||||
|
||||
this
|
||||
}
|
||||
@ -384,11 +391,23 @@ fn system_faucet_account() -> Account {
|
||||
}
|
||||
}
|
||||
|
||||
fn system_bridge_account() -> Account {
|
||||
Account {
|
||||
program_owner: Program::authenticated_transfer_program().id(),
|
||||
..Account::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn system_faucet_account_id() -> AccountId {
|
||||
faucet_core::compute_faucet_account_id(Program::faucet().id())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn system_bridge_account_id() -> AccountId {
|
||||
bridge_core::compute_bridge_account_id(Program::bridge().id())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
#![expect(
|
||||
@ -426,9 +445,10 @@ pub mod tests {
|
||||
signature::PrivateKey,
|
||||
state::{
|
||||
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
|
||||
CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_faucet_account,
|
||||
CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_bridge_account,
|
||||
system_faucet_account,
|
||||
},
|
||||
system_faucet_account_id,
|
||||
system_bridge_account_id, system_faucet_account_id,
|
||||
};
|
||||
|
||||
impl V03State {
|
||||
@ -623,6 +643,7 @@ pub mod tests {
|
||||
},
|
||||
);
|
||||
this.insert(system_faucet_account_id(), system_faucet_account());
|
||||
this.insert(system_bridge_account_id(), system_bridge_account());
|
||||
for account_id in CLOCK_PROGRAM_ACCOUNT_IDS {
|
||||
this.insert(
|
||||
account_id,
|
||||
@ -647,6 +668,7 @@ pub mod tests {
|
||||
this.insert(Program::ata().id(), Program::ata());
|
||||
this.insert(Program::vault().id(), Program::vault());
|
||||
this.insert(Program::faucet().id(), Program::faucet());
|
||||
this.insert(Program::bridge().id(), Program::bridge());
|
||||
this
|
||||
};
|
||||
|
||||
@ -2277,7 +2299,7 @@ pub mod tests {
|
||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||
}
|
||||
|
||||
/// A mask-3 account that no program claims via `Claim::Pda` and no caller authorizes via
|
||||
/// A private PDA account that no program claims via `Claim::Pda` and no caller authorizes via
|
||||
/// `ChainedCall.pda_seeds` has no binding between its supplied npk and its `account_id`,
|
||||
/// so the circuit must reject. Here `simple_balance_transfer` emits no claim for the
|
||||
/// second account, leaving position 1 unbound.
|
||||
@ -2309,6 +2331,7 @@ pub mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
},
|
||||
],
|
||||
&program.into(),
|
||||
@ -2317,7 +2340,7 @@ pub mod tests {
|
||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||
}
|
||||
|
||||
/// Happy path: a program claims a new mask-3 account via `Claim::Pda(seed)`. The circuit
|
||||
/// Happy path: a program claims a new private PDA via `Claim::Pda(seed)`. The circuit
|
||||
/// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s
|
||||
/// position, derives `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`, and
|
||||
/// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim
|
||||
@ -2341,11 +2364,12 @@ pub mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
}],
|
||||
&program.into(),
|
||||
);
|
||||
|
||||
let (output, _proof) = result.expect("mask-3 private PDA claim should succeed");
|
||||
let (output, _proof) = result.expect("private PDA claim should succeed");
|
||||
assert_eq!(output.new_nullifiers.len(), 1);
|
||||
assert_eq!(output.new_commitments.len(), 1);
|
||||
assert_eq!(output.ciphertexts.len(), 1);
|
||||
@ -2381,6 +2405,7 @@ pub mod tests {
|
||||
npk: npk_b,
|
||||
ssk: shared_secret,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
}],
|
||||
&program.into(),
|
||||
);
|
||||
@ -2388,7 +2413,7 @@ pub mod tests {
|
||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||
}
|
||||
|
||||
/// Happy path for the caller-seeds authorization of a mask-3 PDA. The delegator claims a
|
||||
/// Happy path for the caller-seeds authorization of a private PDA. The delegator claims a
|
||||
/// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same
|
||||
/// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state`'s authorization
|
||||
/// is established via the private derivation
|
||||
@ -2417,12 +2442,13 @@ pub mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
}],
|
||||
&program_with_deps,
|
||||
);
|
||||
|
||||
let (output, _proof) =
|
||||
result.expect("caller-seeds authorization of mask-3 private PDA should succeed");
|
||||
result.expect("caller-seeds authorization of private PDA should succeed");
|
||||
assert_eq!(output.new_commitments.len(), 1);
|
||||
assert_eq!(output.new_nullifiers.len(), 1);
|
||||
}
|
||||
@ -2456,6 +2482,7 @@ pub mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
}],
|
||||
&program_with_deps,
|
||||
);
|
||||
@ -2465,8 +2492,8 @@ pub mod tests {
|
||||
|
||||
/// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of
|
||||
/// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide
|
||||
/// family-binding check, a program could claim `PDA_alice` (mask-3, `alice_npk`) and
|
||||
/// `PDA_bob` (mask-3, `bob_npk`) under the same seed in one transaction, and once reuse
|
||||
/// family-binding check, a program could claim `PDA_alice` (`alice_npk`) and
|
||||
/// `PDA_bob` (`bob_npk`) under the same seed in one transaction, and once reuse
|
||||
/// is supported a later chained call could delegate both to a callee via
|
||||
/// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup
|
||||
/// here: after the first claim records `(program, seed) → PDA_alice`, the second claim
|
||||
@ -2494,11 +2521,13 @@ pub mod tests {
|
||||
npk: keys_a.npk(),
|
||||
ssk: shared_a,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
},
|
||||
InputAccountIdentity::PrivatePdaInit {
|
||||
npk: keys_b.npk(),
|
||||
ssk: shared_b,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
},
|
||||
],
|
||||
&program.into(),
|
||||
@ -2507,17 +2536,11 @@ pub mod tests {
|
||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||
}
|
||||
|
||||
/// Pins the current limitation: a mask-3 PDA that was claimed in a previous transaction
|
||||
/// cannot be re-used in a new transaction as-is. This PR only binds supplied npks via a
|
||||
/// fresh `Claim::Pda` or a caller's `ChainedCall.pda_seeds`, neither is present when a
|
||||
/// program operates on an already-owned private PDA at top level. The reject site is the
|
||||
/// post-loop `private_pda_bound_positions` assertion in
|
||||
/// `privacy_preserving_circuit.rs`: `noop` emits no `Claim::Pda` and there is no caller
|
||||
/// A private PDA that is reused at top level without an external seed in the identity still
|
||||
/// fails binding. The noop program emits no `Claim::Pda` and there is no caller
|
||||
/// `ChainedCall.pda_seeds`, so position 0 is never bound and the assertion fires.
|
||||
// TODO: a follow-up PR in the Private PDAs series needs to let the wallet supply a
|
||||
// `(seed, original_owner_program_id)` side input per mask-3 `pre_state` so the circuit
|
||||
// can re-verify `AccountId::for_private_pda(owner, seed, npk) == pre.account_id` without a
|
||||
// claim.
|
||||
/// Supplying `seed: Some((seed, owner_program_id))` in the `PrivatePdaUpdate` identity is
|
||||
/// the correct path for top-level reuse; this test pins the failure when no seed is provided.
|
||||
#[test]
|
||||
fn private_pda_top_level_reuse_rejected_by_binding_check() {
|
||||
let program = Program::noop();
|
||||
@ -2546,6 +2569,7 @@ pub mod tests {
|
||||
npk,
|
||||
ssk: shared_secret,
|
||||
identifier: u128::MAX,
|
||||
seed: None,
|
||||
}],
|
||||
&program.into(),
|
||||
);
|
||||
@ -4432,15 +4456,15 @@ pub mod tests {
|
||||
let alice_keys = test_private_account_keys_1();
|
||||
let alice_npk = alice_keys.npk();
|
||||
|
||||
let proxy = Program::pda_fund_spend_proxy();
|
||||
let proxy = Program::pda_spend_proxy();
|
||||
let auth_transfer = Program::authenticated_transfer_program();
|
||||
let proxy_id = proxy.id();
|
||||
let auth_transfer_id = auth_transfer.id();
|
||||
let seed = PdaSeed::new([42; 32]);
|
||||
let amount: u128 = 100;
|
||||
|
||||
let program_with_deps =
|
||||
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
|
||||
let spend_with_deps =
|
||||
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer.clone())].into());
|
||||
|
||||
let funder_id = funder_keys.account_id();
|
||||
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0);
|
||||
@ -4468,7 +4492,7 @@ pub mod tests {
|
||||
let (alice_shared_1, alice_epk_1) =
|
||||
SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 1);
|
||||
|
||||
// Fund alice_pda_0
|
||||
// Fund alice_pda_0 via authenticated_transfer directly.
|
||||
{
|
||||
let funder_account = state.get_account_by_id(funder_id);
|
||||
let funder_nonce = funder_account.nonce;
|
||||
@ -4477,16 +4501,18 @@ pub mod tests {
|
||||
AccountWithMetadata::new(funder_account, true, funder_id),
|
||||
AccountWithMetadata::new(Account::default(), false, alice_pda_0_id),
|
||||
],
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id, true)).unwrap(),
|
||||
Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
|
||||
.unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivatePdaInit {
|
||||
npk: alice_npk,
|
||||
ssk: alice_shared_0,
|
||||
identifier: 0,
|
||||
seed: Some((seed, proxy_id)),
|
||||
},
|
||||
],
|
||||
&program_with_deps,
|
||||
&auth_transfer.clone().into(),
|
||||
)
|
||||
.unwrap();
|
||||
let message = Message::try_from_circuit_output(
|
||||
@ -4506,7 +4532,7 @@ pub mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Fund alice_pda_1
|
||||
// Fund alice_pda_1 the same way with identifier 1.
|
||||
{
|
||||
let funder_account = state.get_account_by_id(funder_id);
|
||||
let funder_nonce = funder_account.nonce;
|
||||
@ -4515,16 +4541,18 @@ pub mod tests {
|
||||
AccountWithMetadata::new(funder_account, true, funder_id),
|
||||
AccountWithMetadata::new(Account::default(), false, alice_pda_1_id),
|
||||
],
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id, true)).unwrap(),
|
||||
Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
|
||||
.unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivatePdaInit {
|
||||
npk: alice_npk,
|
||||
ssk: alice_shared_1,
|
||||
identifier: 1,
|
||||
seed: Some((seed, proxy_id)),
|
||||
},
|
||||
],
|
||||
&program_with_deps,
|
||||
&auth_transfer.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let message = Message::try_from_circuit_output(
|
||||
@ -4558,7 +4586,7 @@ pub mod tests {
|
||||
AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id),
|
||||
AccountWithMetadata::new(recipient_account, true, recipient_id),
|
||||
],
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
ssk: alice_shared_0,
|
||||
@ -4567,10 +4595,11 @@ pub mod tests {
|
||||
.get_proof_for_commitment(&commitment_pda_0)
|
||||
.expect("pda_0 must be in state"),
|
||||
identifier: 0,
|
||||
seed: None,
|
||||
},
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
&program_with_deps,
|
||||
&spend_with_deps,
|
||||
)
|
||||
.unwrap();
|
||||
let message = Message::try_from_circuit_output(
|
||||
@ -4595,10 +4624,10 @@ pub mod tests {
|
||||
let recipient_account = state.get_account_by_id(recipient_id);
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![
|
||||
AccountWithMetadata::new(alice_pda_1_account, true, alice_pda_1_id),
|
||||
AccountWithMetadata::new(alice_pda_1_account.clone(), true, alice_pda_1_id),
|
||||
AccountWithMetadata::new(recipient_account, false, recipient_id),
|
||||
],
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
|
||||
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
ssk: alice_shared_1,
|
||||
@ -4607,10 +4636,11 @@ pub mod tests {
|
||||
.get_proof_for_commitment(&commitment_pda_1)
|
||||
.expect("pda_1 must be in state"),
|
||||
identifier: 1,
|
||||
seed: None,
|
||||
},
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
&program_with_deps,
|
||||
&spend_with_deps,
|
||||
)
|
||||
.unwrap();
|
||||
let message = Message::try_from_circuit_output(
|
||||
@ -4631,5 +4661,70 @@ pub mod tests {
|
||||
}
|
||||
|
||||
assert_eq!(state.get_account_by_id(recipient_id).balance, 2 * amount);
|
||||
|
||||
// Re-fund alice_pda_1 top-level via auth_transfer using PrivatePdaUpdate with an
|
||||
// external seed.
|
||||
let alice_pda_1_account_after_spend = Account {
|
||||
program_owner: auth_transfer_id,
|
||||
balance: 0,
|
||||
nonce: alice_pda_1_account
|
||||
.nonce
|
||||
.private_account_nonce_increment(&alice_keys.nsk),
|
||||
..Account::default()
|
||||
};
|
||||
let commitment_pda_1_after_spend =
|
||||
Commitment::new(&alice_pda_1_id, &alice_pda_1_account_after_spend);
|
||||
let alice_shared_1_refund = SharedSecretKey::new([12; 32], &alice_keys.vpk());
|
||||
{
|
||||
let recipient_account = state.get_account_by_id(recipient_id);
|
||||
let recipient_nonce = recipient_account.nonce;
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![
|
||||
AccountWithMetadata::new(recipient_account, true, recipient_id),
|
||||
AccountWithMetadata::new(
|
||||
alice_pda_1_account_after_spend,
|
||||
false,
|
||||
alice_pda_1_id,
|
||||
),
|
||||
],
|
||||
Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
|
||||
.unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
nsk: alice_keys.nsk,
|
||||
ssk: alice_shared_1_refund,
|
||||
membership_proof: state
|
||||
.get_proof_for_commitment(&commitment_pda_1_after_spend)
|
||||
.expect("pda_1 after spend must be in state"),
|
||||
identifier: 1,
|
||||
seed: Some((seed, proxy_id)),
|
||||
},
|
||||
],
|
||||
&Program::authenticated_transfer_program().into(),
|
||||
)
|
||||
.unwrap();
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![recipient_id],
|
||||
vec![recipient_nonce],
|
||||
vec![(
|
||||
alice_npk,
|
||||
alice_keys.vpk(),
|
||||
EphemeralPublicKey::from_scalar([12; 32]),
|
||||
)],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(
|
||||
&PrivacyPreservingTransaction::new(message, witness_set),
|
||||
5,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(state.get_account_by_id(recipient_id).balance, amount);
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,12 +173,18 @@ impl ValidatedStateDiff {
|
||||
);
|
||||
|
||||
// Check that the program output pre_states marked as authorized are indeed
|
||||
// authorized.
|
||||
// authorized, and vice-versa.
|
||||
let is_indeed_authorized = is_authorized(&account_id);
|
||||
ensure!(
|
||||
!pre.is_authorized || is_indeed_authorized,
|
||||
InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id }
|
||||
);
|
||||
ensure!(
|
||||
pre.is_authorized || !is_indeed_authorized,
|
||||
InvalidProgramBehaviorError::AuthorizedAccountMarkedAsNotAuthorized {
|
||||
account_id
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the program output's self_program_id matches the expected program ID.
|
||||
@ -269,11 +275,20 @@ impl ValidatedStateDiff {
|
||||
// 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)
|
||||
.map(|pre| pre.account_id)
|
||||
//
|
||||
// Union with the caller's authorized set so that authorization is monotonically
|
||||
// growing: once an account is authorized at any point in the chain it remains
|
||||
// authorized for all subsequent calls.
|
||||
let authorized_accounts: HashSet<_> = caller_data
|
||||
.authorized_accounts
|
||||
.into_iter()
|
||||
.chain(
|
||||
program_output
|
||||
.pre_states
|
||||
.iter()
|
||||
.filter(|pre| pre.is_authorized)
|
||||
.map(|pre| pre.account_id),
|
||||
)
|
||||
.collect();
|
||||
for new_call in program_output.chained_calls.into_iter().rev() {
|
||||
chained_calls.push_front((
|
||||
@ -341,7 +356,13 @@ impl ValidatedStateDiff {
|
||||
|
||||
// Check there are no duplicate nullifiers in the new_nullifiers list
|
||||
ensure!(
|
||||
n_unique(&message.new_nullifiers) == message.new_nullifiers.len(),
|
||||
n_unique(
|
||||
&message
|
||||
.new_nullifiers
|
||||
.iter()
|
||||
.map(|(n, _)| n)
|
||||
.collect::<Vec<_>>()
|
||||
) == message.new_nullifiers.len(),
|
||||
NssaError::InvalidInput("Duplicate nullifiers found in message".into())
|
||||
);
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ amm_program.workspace = true
|
||||
ata_core.workspace = true
|
||||
ata_program.workspace = true
|
||||
faucet_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
vault_core.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
serde = { workspace = true, default-features = false }
|
||||
|
||||
82
program_methods/guest/src/bin/bridge.rs
Normal file
82
program_methods/guest/src/bin/bridge.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use bridge_core::Instruction;
|
||||
use nssa_core::program::{
|
||||
AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_nssa_inputs,
|
||||
};
|
||||
|
||||
fn unchanged_post_states(
|
||||
pre_states: &[nssa_core::account::AccountWithMetadata],
|
||||
) -> Vec<AccountPostState> {
|
||||
pre_states
|
||||
.iter()
|
||||
.map(|pre_state| AccountPostState::new(pre_state.account.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_nssa_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"Bridge cannot be invoked through chain calls"
|
||||
);
|
||||
|
||||
let pre_states_clone = pre_states.clone();
|
||||
let post_states = unchanged_post_states(&pre_states_clone);
|
||||
|
||||
let chained_calls = match instruction {
|
||||
Instruction::Deposit {
|
||||
vault_program_id,
|
||||
recipient_id,
|
||||
amount,
|
||||
} => {
|
||||
let [bridge, recipient_vault] = pre_states
|
||||
.try_into()
|
||||
.expect("Deposit requires exactly 2 accounts");
|
||||
|
||||
assert_eq!(
|
||||
bridge.account_id,
|
||||
bridge_core::compute_bridge_account_id(self_program_id),
|
||||
"First account must be bridge PDA"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
recipient_vault.account_id,
|
||||
vault_core::compute_vault_account_id(vault_program_id, recipient_id),
|
||||
"Second account must be recipient vault PDA"
|
||||
);
|
||||
|
||||
let mut bridge_for_vault = bridge;
|
||||
bridge_for_vault.is_authorized = true;
|
||||
|
||||
vec![
|
||||
ChainedCall::new(
|
||||
vault_program_id,
|
||||
vec![bridge_for_vault, recipient_vault],
|
||||
&vault_core::Instruction::Transfer {
|
||||
recipient_id,
|
||||
amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![bridge_core::compute_bridge_seed()]),
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
pre_states_clone,
|
||||
post_states,
|
||||
)
|
||||
.with_chained_calls(chained_calls)
|
||||
.write();
|
||||
}
|
||||
@ -23,11 +23,16 @@ fn main() {
|
||||
instruction_words,
|
||||
) = read_nssa_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"Faucet cannot be invoked through chain calls"
|
||||
);
|
||||
|
||||
let pre_states_clone = pre_states.clone();
|
||||
let post_states = unchanged_post_states(&pre_states_clone);
|
||||
|
||||
let chained_calls = match instruction {
|
||||
Instruction::Transfer {
|
||||
Instruction::GenesisTransferVault {
|
||||
vault_program_id,
|
||||
recipient_id,
|
||||
amount,
|
||||
@ -57,6 +62,29 @@ fn main() {
|
||||
.with_pda_seeds(vec![faucet_core::compute_faucet_seed()]),
|
||||
]
|
||||
}
|
||||
Instruction::GenesisTransferDirect { amount } => {
|
||||
let [faucet, recipient] = pre_states
|
||||
.try_into()
|
||||
.expect("TransferDirect requires exactly 2 accounts");
|
||||
|
||||
assert_eq!(
|
||||
faucet.account_id,
|
||||
faucet_core::compute_faucet_account_id(self_program_id),
|
||||
"First account must be faucet PDA"
|
||||
);
|
||||
|
||||
let mut faucet_for_transfer = faucet;
|
||||
faucet_for_transfer.is_authorized = true;
|
||||
|
||||
vec![
|
||||
ChainedCall::new(
|
||||
faucet_for_transfer.account.program_owner,
|
||||
vec![faucet_for_transfer, recipient],
|
||||
&authenticated_transfer_core::Instruction::Transfer { amount },
|
||||
)
|
||||
.with_pda_seeds(vec![faucet_core::compute_faucet_seed()]),
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
ProgramOutput::new(
|
||||
|
||||
@ -305,6 +305,68 @@ impl ExecutionState {
|
||||
}
|
||||
Entry::Vacant(_) => {
|
||||
// Pre state for the initial call
|
||||
let pre_state_position = self.pre_states.len();
|
||||
let external_seed = match account_identities.get(pre_state_position) {
|
||||
Some(InputAccountIdentity::PrivatePdaInit {
|
||||
npk,
|
||||
identifier,
|
||||
seed: Some((seed, authority_program_id)),
|
||||
..
|
||||
}) => {
|
||||
let expected = AccountId::for_private_pda(
|
||||
authority_program_id,
|
||||
seed,
|
||||
npk,
|
||||
*identifier,
|
||||
);
|
||||
assert_eq!(
|
||||
pre_account_id, expected,
|
||||
"External seed mismatch for PrivatePdaInit at position {pre_state_position}"
|
||||
);
|
||||
Some((*seed, *authority_program_id))
|
||||
}
|
||||
Some(InputAccountIdentity::PrivatePdaUpdate {
|
||||
nsk,
|
||||
identifier,
|
||||
seed: Some((seed, authority_program_id)),
|
||||
..
|
||||
}) => {
|
||||
let npk = NullifierPublicKey::from(nsk);
|
||||
let expected = AccountId::for_private_pda(
|
||||
authority_program_id,
|
||||
seed,
|
||||
&npk,
|
||||
*identifier,
|
||||
);
|
||||
assert_eq!(
|
||||
pre_account_id, expected,
|
||||
"External seed mismatch for PrivatePdaUpdate at position {pre_state_position}"
|
||||
);
|
||||
Some((*seed, *authority_program_id))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// External seed is only consulted the first time the account is seen.
|
||||
// Subsequent calls need no re-check because the entry is already recorded on
|
||||
// private_pda_bound_positions.
|
||||
if let Some((seed, authority_program_id)) = external_seed {
|
||||
assert!(
|
||||
!pre.is_authorized,
|
||||
"Private PDA with externally-provided seed must not be authorized at position {pre_state_position}"
|
||||
);
|
||||
bind_private_pda_position(
|
||||
&mut self.private_pda_bound_positions,
|
||||
pre_state_position,
|
||||
authority_program_id,
|
||||
seed,
|
||||
);
|
||||
assert_family_binding(
|
||||
&mut self.pda_family_binding,
|
||||
authority_program_id,
|
||||
seed,
|
||||
pre_account_id,
|
||||
);
|
||||
}
|
||||
self.pre_states.push(pre);
|
||||
}
|
||||
}
|
||||
@ -348,14 +410,11 @@ impl ExecutionState {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if account_identity.is_private_pda() {
|
||||
} else {
|
||||
// Private accounts: don't enforce the claim semantics. Unauthorized private
|
||||
// claiming is intentionally allowed
|
||||
match claim {
|
||||
Claim::Authorized => {
|
||||
assert!(
|
||||
pre_is_authorized,
|
||||
"Cannot claim unauthorized private PDA {pre_account_id}"
|
||||
);
|
||||
}
|
||||
Claim::Authorized => {}
|
||||
Claim::Pda(seed) => {
|
||||
let (npk, identifier) = self
|
||||
.private_pda_npk_by_position
|
||||
@ -383,10 +442,6 @@ impl ExecutionState {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standalone private accounts: don't enforce the claim semantics.
|
||||
// Unauthorized private claiming is intentionally allowed since operating
|
||||
// these accounts requires the npk/nsk keypair anyway.
|
||||
}
|
||||
|
||||
post.account_mut().program_owner = program_id;
|
||||
|
||||
@ -62,7 +62,7 @@ pub fn compute_circuit_output(
|
||||
Nullifier::for_account_initialization(&account_id),
|
||||
DUMMY_COMMITMENT_HASH,
|
||||
);
|
||||
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
|
||||
let new_nonce = Nonce::private_account_nonce_init(&account_id);
|
||||
|
||||
emit_private_output(
|
||||
&mut output,
|
||||
@ -148,6 +148,7 @@ pub fn compute_circuit_output(
|
||||
npk: _,
|
||||
ssk,
|
||||
identifier,
|
||||
seed: _,
|
||||
} => {
|
||||
// The npk-to-account_id binding is established upstream in
|
||||
// `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds`
|
||||
@ -172,7 +173,7 @@ pub fn compute_circuit_output(
|
||||
let new_nonce = Nonce::private_account_nonce_init(&pre_state.account_id);
|
||||
|
||||
let account_id = pre_state.account_id;
|
||||
let (pda_program_id, seed) = pda_seed_by_position
|
||||
let (authority_program_id, seed) = pda_seed_by_position
|
||||
.get(&pos)
|
||||
.expect("PrivatePdaInit position must be in pda_seed_by_position");
|
||||
emit_private_output(
|
||||
@ -181,7 +182,7 @@ pub fn compute_circuit_output(
|
||||
post_state,
|
||||
&account_id,
|
||||
&PrivateAccountKind::Pda {
|
||||
program_id: *pda_program_id,
|
||||
program_id: *authority_program_id,
|
||||
seed: *seed,
|
||||
identifier: *identifier,
|
||||
},
|
||||
@ -195,14 +196,16 @@ pub fn compute_circuit_output(
|
||||
nsk,
|
||||
membership_proof,
|
||||
identifier,
|
||||
seed: external_seed,
|
||||
} => {
|
||||
// The npk binding is established upstream. Authorization must already be set;
|
||||
// an unauthorized PrivatePdaUpdate would mean the prover supplied an nsk for an
|
||||
// unbound PDA, which the upstream binding check would have rejected anyway,
|
||||
// but we assert here to fail fast and document the precondition.
|
||||
// With an external seed the binding comes from the circuit input and the
|
||||
// pre_state is intentionally unauthorized; without one the binding comes from
|
||||
// a Claim or caller pda_seeds, so the pre_state must already be authorized.
|
||||
// When `external_seed` is `Some`, execution_state already asserted
|
||||
// `!pre_state.is_authorized`.
|
||||
assert!(
|
||||
pre_state.is_authorized,
|
||||
"PrivatePdaUpdate requires authorized pre_state"
|
||||
pre_state.is_authorized ^ external_seed.is_some(),
|
||||
"PrivatePdaUpdate requires authorized pre_state or external seed"
|
||||
);
|
||||
|
||||
let new_nullifier = compute_update_nullifier_and_set_digest(
|
||||
@ -214,7 +217,7 @@ pub fn compute_circuit_output(
|
||||
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
|
||||
|
||||
let account_id = pre_state.account_id;
|
||||
let (pda_program_id, seed) = pda_seed_by_position
|
||||
let (authority_program_id, seed) = pda_seed_by_position
|
||||
.get(&pos)
|
||||
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
|
||||
emit_private_output(
|
||||
@ -223,7 +226,7 @@ pub fn compute_circuit_output(
|
||||
post_state,
|
||||
&account_id,
|
||||
&PrivateAccountKind::Pda {
|
||||
program_id: *pda_program_id,
|
||||
program_id: *authority_program_id,
|
||||
seed: *seed,
|
||||
identifier: *identifier,
|
||||
},
|
||||
|
||||
12
programs/bridge/core/Cargo.toml
Normal file
12
programs/bridge/core/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "bridge_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa_core.workspace = true
|
||||
serde = { workspace = true, default-features = false }
|
||||
29
programs/bridge/core/src/lib.rs
Normal file
29
programs/bridge/core/src/lib.rs
Normal file
@ -0,0 +1,29 @@
|
||||
pub use nssa_core::program::PdaSeed;
|
||||
use nssa_core::{account::AccountId, program::ProgramId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Transfers native tokens from the bridge PDA account to a recipient vault.
|
||||
///
|
||||
/// Required accounts (2):
|
||||
/// - Bridge PDA account
|
||||
/// - Recipient vault PDA account
|
||||
Deposit {
|
||||
vault_program_id: ProgramId,
|
||||
recipient_id: AccountId,
|
||||
amount: u128,
|
||||
},
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn compute_bridge_seed() -> PdaSeed {
|
||||
PdaSeed::new(BRIDGE_SEED_DOMAIN_SEPARATOR)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed())
|
||||
}
|
||||
@ -8,14 +8,25 @@ const FAUCET_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/FaucetSeed/000000000
|
||||
pub enum Instruction {
|
||||
/// Transfers native tokens from system faucet to recipient's vault.
|
||||
///
|
||||
/// Executed only in genesis block by sequencer it-self. User transactions will be denied.
|
||||
///
|
||||
/// Required accounts (2):
|
||||
/// - Faucet PDA account
|
||||
/// - Recipient vault PDA account
|
||||
Transfer {
|
||||
GenesisTransferVault {
|
||||
vault_program_id: ProgramId,
|
||||
recipient_id: AccountId,
|
||||
amount: u128,
|
||||
},
|
||||
|
||||
/// Transfers native tokens from system faucet directly to a recipient account.
|
||||
///
|
||||
/// Executed only in genesis block by sequencer it-self. User transactions will be denied.
|
||||
///
|
||||
/// Required accounts (2):
|
||||
/// - Faucet PDA account
|
||||
/// - Recipient account
|
||||
GenesisTransferDirect { amount: u128 },
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@ -16,6 +16,7 @@ mempool.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
faucet_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
vault_core.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
@ -31,6 +32,7 @@ logos-blockchain-core.workspace = true
|
||||
rand.workspace = true
|
||||
borsh.workspace = true
|
||||
bytesize.workspace = true
|
||||
hex.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[features]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::{pin::Pin, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::block::Block;
|
||||
use log::warn;
|
||||
pub use logos_blockchain_core::mantle::ops::channel::MsgId;
|
||||
@ -10,7 +10,7 @@ use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient,
|
||||
adapter::NodeHttpClient,
|
||||
sequencer::{Event, SequencerConfig as ZoneSdkSequencerConfig, SequencerHandle, ZoneSequencer},
|
||||
state::InscriptionInfo,
|
||||
state::{DepositInfo, FinalizedOp, InscriptionInfo},
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@ -18,12 +18,16 @@ use crate::config::BedrockConfig;
|
||||
|
||||
/// Sink for `Event::Published` checkpoints emitted by the drive task.
|
||||
/// Caller is responsible for persistence (e.g. writing to rocksdb).
|
||||
pub type CheckpointSink = Box<dyn Fn(SequencerCheckpoint) + Send + Sync + 'static>;
|
||||
pub type CheckpointSink = Box<dyn Fn(SequencerCheckpoint) + Send + 'static>;
|
||||
|
||||
/// Sink for finalized L2 block ids derived from `Event::TxsFinalized` and
|
||||
/// `Event::FinalizedInscriptions`. Caller is responsible for cleanup
|
||||
/// (e.g. marking pending blocks as finalized in storage).
|
||||
pub type FinalizedBlockSink = Box<dyn Fn(u64) + Send + Sync + 'static>;
|
||||
pub type FinalizedBlockSink = Box<dyn Fn(u64) + Send + 'static>;
|
||||
|
||||
/// Sink for finalized Bedrock deposit events.
|
||||
pub type OnDepositEventSink =
|
||||
Box<dyn Fn(DepositInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
|
||||
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
||||
pub trait BlockPublisherTrait: Clone {
|
||||
@ -34,6 +38,7 @@ pub trait BlockPublisherTrait: Clone {
|
||||
initial_checkpoint: Option<SequencerCheckpoint>,
|
||||
on_checkpoint: CheckpointSink,
|
||||
on_finalized_block: FinalizedBlockSink,
|
||||
on_deposit_event: OnDepositEventSink,
|
||||
) -> Result<Self>;
|
||||
|
||||
/// Fire-and-forget publish. Zone-sdk drives the actual submission and
|
||||
@ -65,6 +70,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
initial_checkpoint: Option<SequencerCheckpoint>,
|
||||
on_checkpoint: CheckpointSink,
|
||||
on_finalized_block: FinalizedBlockSink,
|
||||
on_deposit_event: OnDepositEventSink,
|
||||
) -> Result<Self> {
|
||||
let basic_auth = config.auth.clone().map(Into::into);
|
||||
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone());
|
||||
@ -89,10 +95,20 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
};
|
||||
match event {
|
||||
Event::Published { checkpoint, .. } => on_checkpoint(checkpoint),
|
||||
Event::TxsFinalized { inscriptions, .. }
|
||||
| Event::FinalizedInscriptions { inscriptions } => {
|
||||
if let Some(max_block_id) = max_block_id_from_inscriptions(&inscriptions) {
|
||||
on_finalized_block(max_block_id);
|
||||
Event::TxsFinalized { items } => {
|
||||
for op in items.into_iter().flat_map(|item| item.ops) {
|
||||
match op {
|
||||
FinalizedOp::Inscription(inscription) => {
|
||||
if let Some(block_id) = block_id_from_inscription(&inscription)
|
||||
{
|
||||
on_finalized_block(block_id);
|
||||
}
|
||||
}
|
||||
FinalizedOp::Deposit(deposit) => {
|
||||
on_deposit_event(deposit).await;
|
||||
}
|
||||
FinalizedOp::Withdraw(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ChannelUpdate { .. } | Event::Ready => {}
|
||||
@ -110,27 +126,26 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
|
||||
async fn publish_block(&self, block: &Block) -> Result<()> {
|
||||
let data = borsh::to_vec(block).context("Failed to serialize block")?;
|
||||
let data_bounded = data
|
||||
.try_into()
|
||||
.context("Block data exceeds maximum allowed size")?;
|
||||
|
||||
self.handle
|
||||
.publish_message(data)
|
||||
.publish_message(data_bounded)
|
||||
.await
|
||||
.map_err(|e| anyhow!("zone-sdk publish failed: {e}"))?;
|
||||
.context("Failed to publish block")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize each inscription payload as a `Block` and return the highest
|
||||
/// `block_id`. Bad payloads are logged and skipped.
|
||||
fn max_block_id_from_inscriptions(inscriptions: &[InscriptionInfo]) -> Option<u64> {
|
||||
inscriptions
|
||||
.iter()
|
||||
.filter_map(
|
||||
|inscription| match borsh::from_slice::<Block>(&inscription.payload) {
|
||||
Ok(block) => Some(block.header.block_id),
|
||||
Err(err) => {
|
||||
warn!("Failed to deserialize finalized inscription as Block: {err:#}");
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.max()
|
||||
/// Deserialize inscription payload as a `Block` and return it's`block_id`.
|
||||
/// Bad payloads are logged and skipped.
|
||||
fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option<u64> {
|
||||
borsh::from_slice::<Block>(&inscription.payload)
|
||||
.inspect_err(|err| {
|
||||
warn!("Failed to deserialize block from inscription: {err:?}");
|
||||
})
|
||||
.ok()
|
||||
.map(|block| block.header.block_id)
|
||||
}
|
||||
|
||||
@ -22,6 +22,9 @@ pub enum GenesisAction {
|
||||
account_id: AccountId,
|
||||
balance: u128,
|
||||
},
|
||||
SupplyBridgeAccount {
|
||||
balance: u128,
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: Provide default values
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user