Merge branch 'main' into Pravdyvy/programs-elfs-deployments

This commit is contained in:
Pravdyvy 2026-06-10 20:09:24 +03:00
commit b85e6d31e0
139 changed files with 4056 additions and 1527 deletions

View File

@ -13,9 +13,11 @@ ignore = [
{ id = "RUSTSEC-2025-0055", reason = "`tracing-subscriber` v0.2.25 pulled in by ark-relations v0.4.0 - will be addressed before mainnet" },
{ id = "RUSTSEC-2025-0141", reason = "`bincode` is unmaintained but continuing to use it." },
{ id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is pulled transitively via risc0-zkvm; waiting on upstream fix (see https://github.com/risc0/risc0/issues/3453)" },
{ 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-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" },
{ id = "RUSTSEC-2026-0173", reason = "`proc-macro-error2` is unmaintained; pulled in transitively via `leptos_macro` and `overwatch-derive`, waiting on upstream fix" },
]
yanked = "deny"
unused-ignored-advisory = "deny"
@ -56,6 +58,7 @@ unused-allowed-license = "deny"
allow-git = [
"https://github.com/EspressoSystems/jellyfish.git",
"https://github.com/logos-blockchain/logos-blockchain.git",
"https://github.com/logos-blockchain/logos-blockchain-circuits.git",
]
unknown-git = "deny"
unknown-registry = "deny"

3
.gitignore vendored
View File

@ -12,3 +12,6 @@ result
wallet-ffi/wallet_ffi.h
bedrock_signing_key
integration_tests/configs/debug/
venv/
keycard_wallet/python/__pycache__/
keycard_wallet/python/keycard-py/

582
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -137,15 +137,14 @@ 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 = "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" }
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" }
rocksdb = { version = "0.24.0", default-features = false, features = [
"snappy",
@ -159,6 +158,7 @@ k256 = { version = "0.13.3", features = [
"serde",
"pem",
] }
ml-kem = { version = "0.3", features = ["hazmat"] }
elliptic-curve = { version = "0.13.8", features = ["arithmetic"] }
actix-web = { version = "4.13.0", default-features = false, features = [
"macros",
@ -166,6 +166,8 @@ 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"] }
zeroize = "1"
criterion = { version = "0.8", features = ["html_reports"] }
# Profile for leptos WASM release builds
[profile.wasm-release]

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.

View File

@ -60,17 +60,44 @@ Unset it when done:
unset KEYCARD_PIN
```
## Pairing password
The pairing password is used to establish a secure channel between the wallet and the card. It is set permanently on the card during `wallet keycard init` and must match on every subsequent re-pair.
The default password (`KeycardDefaultPairing`) is [recommended](https://docs.keycard.tech/en/developers/core) for most users. Wallet CLI allows advance users the flexibility to set their own pairing password.
To use a custom pairing password, set it before `init`:
```bash
# Note: Keep the leading space before this command.
# Leading space prevents this command from being stored in shell history
# (when HISTCONTROL=ignorespace is enabled).
export KEYCARD_PAIRING_PASSWORD=my-custom-password
wallet keycard init
```
After a successful initializaation, subsequent commands (`connect`, transfers) use the cached pairing index and key — the pairing password is not needed again until the pairing is cleared.
**Important:** if you initialized with a custom password, `KEYCARD_PAIRING_PASSWORD` must be set in every session where re-pairing can occur (after `disconnect`, or on a new machine). If the env var is missing then wallet CLI will attempt to use the default password. As a result, pairing will fail.
Unset the pairing password variable when done:
```bash
unset KEYCARD_PAIRING_PASSWORD
```
## 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 |
| 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 |
| `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path — **debug builds only** (see below) |
1. Check keycard availability
```bash
@ -122,6 +149,31 @@ Keycard PIN:
✅ Keycard unpaired and pairing cleared.
```
6. Get private keys for a BIP-32 path (**debug builds only**)
`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. The command is only available in debug builds and requires `--reveal` to confirm intent.
First install the wallet with the `keycard-debug` feature:
```bash
cargo install --path lez/wallet --force --features keycard-debug
```
Then run the command:
```bash
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal
# Output:
WARNING: NSK and VSK are being printed to stdout. Any terminal log, scrollback, or screen recording captures these keys.
Keycard PIN:
NSK: 55e505bf925e536c843a12ebc08c41ca5f4761eeeb7fa33725f0b44e6f1ac2e4
VSK: 30f798893977a7b7263d1f77abf58e11e014428c92030d6a02fe363cceb41ffa
```
To restore the standard build without `keycard-debug` afterwards:
```bash
cargo install --path lez/wallet --force
```
### Pinata (testnet)
| Command | Description |
@ -213,25 +265,270 @@ Keycard PIN:
Transaction hash is 7d4c1b8e2f903a56fd19084b3c8b25d07e8f243829bc50addf6e2c78b4b09e45
```
### Token program
`--definition`, `--holder`, `--from`, and `--to` each accept any of:
- A BIP-32 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`)
The token program requires both the definition account and the holder/recipient to sign when both are owned. If only one is a Keycard path, only that account signs via the card; the other signs locally or is treated as foreign.
**Shielded transfers** (public Keycard sender → private recipient) are supported. The Keycard signs the public sender's authorization; the ZK circuit handles the private recipient side.
| Command | Description |
|--------------------|-------------------------------------------------------|
| `wallet token new` | Creates a new token definition with an initial supply |
| `wallet token send`| Transfers tokens between accounts |
| `wallet token mint`| Mints tokens to a holder account |
| `wallet token burn`| Burns tokens from a holder account |
1. Create a new token — definition and supply both on Keycard
```bash
wallet token new \
--definition-account-id "m/44'/60'/0'/0/2" \
--supply-account-id "m/44'/60'/0'/0/3" \
--name LEZ \
--total-supply 100000
# Output:
Keycard PIN:
Transaction hash is a3f1c8e2049b7d56fe19084b3c8b25d07e8f243829bc50addf6e2c78b4b09d11
Transaction data is ...
```
2. Transfer tokens between two Keycard accounts (public → public)
```bash
wallet token send \
--from "m/44'/60'/0'/0/3" \
--to "m/44'/60'/0'/0/6" \
--amount 20000
# Output:
Keycard PIN:
Transaction hash is b2e4d9f1038c6e45ad28175c4d9c36e18bf9354930cd61beef59f3e89c5a0e22
Transaction data is ...
```
3. Transfer tokens from a Keycard account to a private account (shielded)
```bash
wallet token send \
--from "m/44'/60'/0'/0/6" \
--to "Private/CJwKfrb3DFMmFvujQSB5ARcRTAa8EdP6eWm2hmSkF7Rb" \
--amount 500
# Output:
Keycard PIN:
Transaction hash is c5f7e0a2149d8f67be39286d5eaa47f29cg0465041de72cff06a4f9ad6b1f33
```
4. Mint tokens — Keycard definition account mints to a Keycard holder
```bash
wallet token mint \
--definition "m/44'/60'/0'/0/2" \
--holder "m/44'/60'/0'/0/6" \
--amount 2000
# Output:
Keycard PIN:
Transaction hash is d6g8f1b3250e9a78cf4a397e6fbb58g3ah1567152ef83dgg17b5g0be7c2g0g44
Transaction data is ...
```
5. Burn tokens — Keycard holder burns from its own account
```bash
wallet token burn \
--definition "Public/9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \
--holder "m/44'/60'/0'/0/6" \
--amount 500
# Output:
Keycard PIN:
Transaction hash is e7h9g2c4361f0b89dg5b408f7gcc69h4bi2678263fg94ehh28c6h1cf8d3h1h55
Transaction data is ...
```
### AMM program
AMM operations are **public only** — all holdings involved must be public accounts. Keycard accounts can be used for any or all of the holding accounts.
`--user-holding-a`, `--user-holding-b`, and `--user-holding-lp` each accept any of:
- A BIP-32 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 swaps, only the seller's holding signs — the wallet identifies which holding corresponds to the input token and signs only that account.
| Command | Description |
|----------------------------|-------------------------------------------------------|
| `wallet amm new` | Creates a new AMM liquidity pool |
| `wallet amm swap-exact-input` | Swaps specifying exact input amount |
| `wallet amm swap-exact-output` | Swaps specifying exact output amount |
| `wallet amm add-liquidity` | Adds liquidity to an existing pool |
| `wallet amm remove-liquidity` | Removes liquidity from a pool |
1. Create a new AMM pool — all holdings on Keycard
```bash
wallet amm new \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--user-holding-lp "m/44'/60'/0'/0/8" \
--balance-a 10000 \
--balance-b 10000
# Output:
Keycard PIN:
Transaction hash is f8i0h3d5472g1c90eh6c519g8hdd70i5cj3789374gh05fii39d7i2dg9e4i2i66
Transaction data is ...
```
2. Swap exact input — Keycard account sells LEE, receives LEZ
```bash
wallet amm swap-exact-input \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--amount-in 500 \
--min-amount-out 1 \
--token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6"
# Output:
Keycard PIN:
Transaction hash is g9j1i4e6583h2d01fi7d620h9iee81j6dk4890485hi16gjj40e8j3eh0f5j3j77
Transaction data is ...
```
3. Add liquidity — all three holdings on Keycard
```bash
wallet amm add-liquidity \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--user-holding-lp "m/44'/60'/0'/0/8" \
--max-amount-a 1000 \
--max-amount-b 1000 \
--min-amount-lp 1
# Output:
Keycard PIN:
Transaction hash is h0k2j5f7694i3e12gj8e731i0jff92k7el5901596ij27hkk51f9k4fi1g6k4k88
Transaction data is ...
```
4. Remove liquidity — LP holding on Keycard
```bash
wallet amm remove-liquidity \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--user-holding-lp "m/44'/60'/0'/0/8" \
--balance-lp 500 \
--min-amount-a 1 \
--min-amount-b 1
# Output:
Keycard PIN:
Transaction hash is i1l3k6g8705j4f23hk9f842j1kgg03l8fm6012607jk38ill62g0l5gj2h7l5l99
Transaction data is ...
```
### ATA program
The Associated Token Account program derives a deterministic token holding address from an owner account and a token definition. Keycard accounts can be used as the owner.
`--owner` and `--from`/`--holder` accept any of:
- A BIP-32 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`)
| Command | Description |
|--------------------|------------------------------------------------------------------|
| `wallet ata address` | Derives and prints the ATA address (local only, no network) |
| `wallet ata create` | Creates the ATA on-chain |
| `wallet ata send` | Sends tokens from the owner's ATA to a recipient |
| `wallet ata burn` | Burns tokens from the owner's ATA |
| `wallet ata list` | Lists ATAs for a given owner across token definitions |
1. Derive an ATA address for a Keycard account
```bash
# First resolve the Keycard account ID
OWNER_ID=$(wallet account id --account-id "m/44'/60'/0'/0/9")
wallet ata address \
--owner "$OWNER_ID" \
--token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6"
# Output:
DFMmFvujQSB5ARcRTAa8EdP6eWm2hmSkF7RbCJwKfrb3
```
2. Create an ATA — Keycard account as owner
```bash
wallet ata create \
--owner "m/44'/60'/0'/0/9" \
--token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6"
# Output:
Keycard PIN:
Transaction hash is j2m4l7h9816k5g34il0g953k2lhh14m9gn7123718kl49jmm73h1m6hk3i8m6m00
Transaction data is ...
```
3. Send tokens from a Keycard ATA to another account
```bash
wallet ata send \
--from "m/44'/60'/0'/0/9" \
--token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \
--to "DFMmFvujQSB5ARcRTAa8EdP6eWm2hmSkF7RbCJwKfrb3" \
--amount 500
# Output:
Keycard PIN:
Transaction hash is k3n5m8i0927l6h45jm1h064l3mii25n0ho8234829lm50knn84i2n7il4j9n7n11
Transaction data is ...
```
4. Burn tokens from a Keycard ATA
```bash
wallet ata burn \
--holder "m/44'/60'/0'/0/9" \
--token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \
--amount 200
# Output:
Keycard PIN:
Transaction hash is l4o6n9j1038m7i56kn2i175m4njj36o1ip9345930mn61loo95j3o8jm5k0o8o22
Transaction data is ...
```
## Testing
Tests for Keycard commands are in `lez/keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected:
Tests for Keycard commands are in `lez/keycard_wallet/tests/`.
| Test file | Description |
|---|---|
| `keycard_tests.sh` | Core Keycard wallet commands and `auth-transfer` commands |
| `keycard_tests_2.sh` | Tests Keycard wallet commands for `amma`, `token` and `ata` programs |
| `keycard_test_3.sh` | Demonstrates retrieving private account keys from keycard |
| `keycard_power_recovery_tests.sh` | Modified test file of `keycard_tests.sh` to test power recovery paths |
Run from the repo root with a Keycard connected:
```bash
bash lez/keycard_wallet/tests/keycard_tests.sh
bash lez/keycard_wallet/tests/keycard_tests_2.sh
bash lez/keycard_wallet/tests/keycard_test_3.sh
bash lez/keycard_wallet/tests/keycard_power_recovery_tests.sh
```
## SigningGroups
## SigningGroup
`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.
`SigningGroup` (`lez/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 {
SigningGroup {
local: [(AccountId, PrivateKey)], // signed in pure Rust
keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session
}
```
```

View File

@ -155,7 +155,7 @@ wallet account new private
# Output:
Generated new account with account_id Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL
With npk e6366f79d026c8bd64ae6b3d601f0506832ec682ab54897f205fffe64ec0d951
With vpk 02ddc96d0eb56e00ce14994cfdaec5ae1f76244180a919545983156e3519940a17
With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded>
```
> [!Tip]
@ -231,19 +231,29 @@ wallet account new private-accounts-key
# Output:
Generated new private accounts key at path /1
With npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e
With vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72
With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded>
```
> [!Tip]
> Ignore the account ID here and use the `npk` and `vpk` values to send to a foreign private account.
> [!Important]
> The VPK is now a 1184-byte ML-KEM-768 encapsulation key — too large to copy-paste into a command.
> The recommended workflow is:
>
> **Recipient:** export both keys to a single file and send the file to the sender (e.g. as an email attachment):
> ```bash
> wallet account show-keys --account-id Private/<account-id> > recipient.keys
> # Send recipient.keys to the sender out-of-band
> ```
> The file contains two lines: the npk (hex) on line 1, the vpk (hex) on line 2.
>
> **Sender:** reference the received file with `--to-keys`:
### b. Send 3 tokens using the recipients npk and vpk
### b. Send 3 tokens using the recipients keys file
```bash
# The sender has received recipient.keys from the recipient out-of-band
wallet auth-transfer send \
--from Public/Ev1JprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPAWS \
--to-npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e \
--to-vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72 \
--to-keys recipient.keys \
--amount 3
```
@ -270,18 +280,19 @@ wallet account new private-accounts-key
# Output:
Generated new private accounts key at path /2
With npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345
With vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c
With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded>
```
Alice shares the `npk` and `vpk` values with Bob and Charlie out of band.
### b. Bob sends 10 tokens to Alice using identifier 1
Bob uses the received `alice.keys` file:
```bash
wallet auth-transfer send \
--from Public/BobXqJprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPA \
--to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \
--to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \
--to-keys alice.keys \
--to-identifier 1 \
--amount 10
```
@ -291,8 +302,7 @@ wallet auth-transfer send \
```bash
wallet auth-transfer send \
--from Public/CharlieYrP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPB \
--to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \
--to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \
--to-keys alice.keys \
--to-identifier 2 \
--amount 5
```

View File

@ -132,6 +132,7 @@ async fn amm_public() -> Result<()> {
to: Some(public_mention(recipient_account_id_1)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 7,
};
@ -158,6 +159,7 @@ async fn amm_public() -> Result<()> {
to: Some(public_mention(recipient_account_id_2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 7,
};
@ -530,6 +532,7 @@ async fn amm_new_pool_using_labels() -> Result<()> {
to: Some(public_mention(holding_a_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 5,
};
@ -551,6 +554,7 @@ async fn amm_new_pool_using_labels() -> Result<()> {
to: Some(public_mention(holding_b_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 5,
};

View File

@ -260,6 +260,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
to: Some(public_mention(sender_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
@ -487,6 +488,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
to: Some(public_mention(sender_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
@ -598,6 +600,7 @@ async fn burn_via_ata_private_owner() -> Result<()> {
to: Some(public_mention(holder_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),

View File

@ -11,8 +11,9 @@ use lee::{
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
};
use lee_core::{
InputAccountIdentity, NullifierPublicKey, account::AccountWithMetadata,
encryption::shared_key_derivation::Secp256k1Point,
InputAccountIdentity, NullifierPublicKey,
account::AccountWithMetadata,
encryption::{EphemeralPublicKey, ViewingPublicKey},
};
use log::info;
use sequencer_service_rpc::RpcClient as _;
@ -38,6 +39,7 @@ async fn private_transfer_to_owned_account() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -71,13 +73,14 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
let to_npk = NullifierPublicKey([42; 32]);
let to_npk_string = hex::encode(to_npk.0);
let to_vpk = Secp256k1Point::from_scalar(to_npk.0);
let to_vpk = ViewingPublicKey::from_seed(&[0_u8; 32], &[1_u8; 32]);
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: None,
to_npk: Some(to_npk_string),
to_vpk: Some(hex::encode(to_vpk.0)),
to_vpk: Some(hex::encode(to_vpk.to_bytes())),
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -127,6 +130,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> {
to: Some(public_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -189,7 +193,8 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
from: private_mention(from),
to: None,
to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)),
to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)),
to_vpk: Some(hex::encode(to.key_chain.viewing_public_key.to_bytes())),
to_keys: None,
to_identifier: Some(to.kind.identifier()),
amount: 100,
});
@ -239,6 +244,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -274,14 +280,15 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
let to_npk = NullifierPublicKey([42; 32]);
let to_npk_string = hex::encode(to_npk.0);
let to_vpk = Secp256k1Point::from_scalar(to_npk.0);
let to_vpk = ViewingPublicKey::from_seed(&[0_u8; 32], &[1_u8; 32]);
let from: AccountId = ctx.existing_public_accounts()[0];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(from),
to: None,
to_npk: Some(to_npk_string),
to_vpk: Some(hex::encode(to_vpk.0)),
to_vpk: Some(hex::encode(to_vpk.to_bytes())),
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -351,7 +358,8 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
from: private_mention(from),
to: None,
to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)),
to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)),
to_vpk: Some(hex::encode(to.key_chain.viewing_public_key.to_bytes())),
to_keys: None,
to_identifier: Some(to.kind.identifier()),
amount: 100,
});
@ -452,6 +460,7 @@ async fn private_transfer_using_from_label() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -545,7 +554,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
};
let npk_hex = hex::encode(npk.0);
let vpk_hex = hex::encode(vpk.0);
let vpk_hex = hex::encode(vpk.to_bytes());
let identifier_1 = 1_u128;
let identifier_2 = 2_u128;
@ -560,6 +569,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
to: None,
to_npk: Some(npk_hex.clone()),
to_vpk: Some(vpk_hex.clone()),
to_keys: None,
to_identifier: Some(identifier_1),
amount: 100,
}),
@ -573,6 +583,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
to: None,
to_npk: Some(npk_hex),
to_vpk: Some(vpk_hex),
to_keys: None,
to_identifier: Some(identifier_2),
amount: 200,
}),
@ -654,8 +665,9 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
let auth_transfer_program_id = Program::authenticated_transfer_program().id();
let nsk: lee_core::NullifierSecretKey = [3; 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = Secp256k1Point::from_scalar([4; 32]);
let ssk = SharedSecretKey::new([55; 32], &vpk);
let _vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap();
let ssk = SharedSecretKey([55_u8; 32]);
let _epk = EphemeralPublicKey(vec![55_u8; 1088]);
let attacker_vault_id = {
let seed = vault_core::compute_vault_seed(attacker_id);
AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337)

View File

@ -25,6 +25,7 @@ async fn successful_transfer_to_existing_account() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -83,6 +84,7 @@ pub async fn successful_transfer_to_new_account() -> Result<()> {
to: Some(public_mention(new_persistent_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -120,6 +122,7 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 1_000_000,
});
@ -159,6 +162,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -192,6 +196,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -274,6 +279,7 @@ async fn successful_transfer_using_from_label() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -319,6 +325,7 @@ async fn successful_transfer_using_to_label() -> Result<()> {
to: Some(CliAccountMention::Label(label)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});

View File

@ -43,6 +43,7 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
vec![bridge_account_id, recipient_vault_id],
vec![],
bridge_core::Instruction::Deposit {
l1_deposit_op_id: [0_u8; 32],
vault_program_id,
recipient_id,
amount: 1,
@ -129,6 +130,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
// Serialize the bridge deposit instruction
let instruction = Program::serialize_instruction(bridge_core::Instruction::Deposit {
l1_deposit_op_id: [0_u8; 32],
vault_program_id,
recipient_id,
amount: 1,
@ -204,7 +206,9 @@ async fn submit_bedrock_deposit(
// Encode deposit metadata
let metadata = borsh::to_vec(&DepositMetadata { recipient_id })
.context("Failed to encode deposit metadata")?;
.context("Failed to encode deposit metadata")?
.try_into()
.context("Encoded metadata is too big")?;
let funding_key = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26";
@ -307,7 +311,7 @@ async fn submit_bedrock_deposit(
tip: None,
deposit: DepositOp {
channel_id,
inputs: Inputs::new(vec![selected_note_id]),
inputs: Inputs::new(selected_note_id),
metadata,
},
change_public_key: balance.address,

View File

@ -30,6 +30,7 @@ fn indexer_ffi_state_consistency() -> Result<()> {
to: Some(public_mention(ctx.ctx().existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
amount: 100,
to_identifier: Some(0),
});
@ -67,6 +68,7 @@ fn indexer_ffi_state_consistency() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
amount: 100,
to_identifier: Some(0),
});

View File

@ -46,6 +46,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> {
to: Some(to_label.into()),
to_npk: None,
to_vpk: None,
to_keys: None,
amount: 100,
to_identifier: Some(0),
});

View File

@ -25,6 +25,7 @@ async fn indexer_state_consistency() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -60,6 +61,7 @@ async fn indexer_state_consistency() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});

View File

@ -43,6 +43,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> {
to: Some(CliAccountMention::Label(to_label)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});

View File

@ -71,7 +71,10 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
from: private_mention(from),
to: None,
to_npk: Some(hex::encode(to_account.key_chain.nullifier_public_key.0)),
to_vpk: Some(hex::encode(&to_account.key_chain.viewing_public_key.0)),
to_vpk: Some(hex::encode(
to_account.key_chain.viewing_public_key.to_bytes(),
)),
to_keys: None,
to_identifier: Some(to_account.kind.identifier()),
amount: 100,
});
@ -147,6 +150,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(private_mention(to_account_id1)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -158,6 +162,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(private_mention(to_account_id2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 101,
});
@ -197,6 +202,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(public_mention(to_account_id3)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 102,
});
@ -208,6 +214,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(public_mention(to_account_id4)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 103,
});
@ -268,6 +275,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(private_mention(to_account_id2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 10,
});
@ -278,6 +286,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(public_mention(to_account_id4)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 11,
});

View File

@ -64,9 +64,9 @@ async fn fund_private_pda(
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 eph_holder = EphemeralKeyHolder::new(&vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
let instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.context("failed to serialize auth_transfer instruction")?;
@ -272,10 +272,10 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
// Fresh recipients — hardcoded npks not in any wallet.
let recipient_npk_0 = NullifierPublicKey([0xAA; 32]);
let recipient_vpk_0 = ViewingPublicKey::from_scalar(recipient_npk_0.0);
let recipient_vpk_0 = ViewingPublicKey::from_seed(&[0_u8; 32], &[1_u8; 32]);
let recipient_npk_1 = NullifierPublicKey([0xBB; 32]);
let recipient_vpk_1 = ViewingPublicKey::from_scalar(recipient_npk_1.0);
let recipient_vpk_1 = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]);
let amount_spend_0: u128 = 13;
let amount_spend_1: u128 = 37;

View File

@ -107,8 +107,11 @@ async fn group_invite_join_key_agreement() -> Result<()> {
.key_chain()
.sealing_secret_key()
.context("Sealing key not found")?;
let sealing_pk =
key_protocol::key_management::group_key_holder::SealingPublicKey::from_scalar(sealing_sk);
let sealing_pk = key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes(
lee_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.z)
.to_bytes()
.to_vec(),
);
let holder = ctx
.wallet()
@ -204,6 +207,7 @@ async fn fund_shared_account_from_public() -> Result<()> {
to: Some(private_mention(shared_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: None,
amount: 100,
});

View File

@ -133,6 +133,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -223,6 +224,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
holder: Some(public_mention(recipient_account_id)),
holder_npk: None,
holder_vpk: None,
holder_keys: None,
holder_identifier: None,
amount: mint_amount,
};
@ -365,6 +367,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
to: Some(private_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -554,6 +557,7 @@ async fn create_token_with_private_definition() -> Result<()> {
holder: Some(public_mention(recipient_account_id_public)),
holder_npk: None,
holder_vpk: None,
holder_keys: None,
holder_identifier: None,
amount: mint_amount_public,
};
@ -601,6 +605,7 @@ async fn create_token_with_private_definition() -> Result<()> {
holder: Some(private_mention(recipient_account_id_private)),
holder_npk: None,
holder_vpk: None,
holder_keys: None,
holder_identifier: None,
amount: mint_amount_private,
};
@ -740,6 +745,7 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> {
to: Some(private_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -868,6 +874,7 @@ async fn shielded_token_transfer() -> Result<()> {
to: Some(private_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -991,6 +998,7 @@ async fn deshielded_token_transfer() -> Result<()> {
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -1124,7 +1132,8 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
definition: private_mention(definition_account_id),
holder: None,
holder_npk: Some(hex::encode(holder_keys.nullifier_public_key.0)),
holder_vpk: Some(hex::encode(&holder_keys.viewing_public_key.0)),
holder_vpk: Some(hex::encode(holder_keys.viewing_public_key.to_bytes())),
holder_keys: None,
holder_identifier: Some(holder_identifier),
amount: mint_amount,
};
@ -1323,6 +1332,7 @@ async fn transfer_token_using_from_label() -> Result<()> {
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};

View File

@ -256,8 +256,7 @@ pub async fn tps_test() -> Result<()> {
fn build_privacy_transaction() -> PrivacyPreservingTransaction {
let program = Program::authenticated_transfer_program();
let sender_nsk = [1; 32];
let sender_vsk = [99; 32];
let sender_vpk = ViewingPublicKey::from_scalar(sender_vsk);
let sender_vpk = ViewingPublicKey::from_seed(&[99_u8; 32], &[100_u8; 32]);
let sender_npk = NullifierPublicKey::from(&sender_nsk);
let sender_pre = AccountWithMetadata::new(
Account {
@ -270,8 +269,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
AccountId::for_regular_private_account(&sender_npk, 0),
);
let recipient_nsk = [2; 32];
let recipient_vsk = [99; 32];
let recipient_vpk = ViewingPublicKey::from_scalar(recipient_vsk);
let recipient_vpk = ViewingPublicKey::from_seed(&[101_u8; 32], &[102_u8; 32]);
let recipient_npk = NullifierPublicKey::from(&recipient_nsk);
let recipient_pre = AccountWithMetadata::new(
Account::default(),
@ -279,13 +277,13 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
AccountId::for_regular_private_account(&recipient_npk, 0),
);
let eph_holder_from = EphemeralKeyHolder::new(&sender_npk);
let sender_ss = eph_holder_from.calculate_shared_secret_sender(&sender_vpk);
let sender_epk = eph_holder_from.generate_ephemeral_public_key();
let eph_holder_from = EphemeralKeyHolder::new(&sender_vpk);
let sender_ss = eph_holder_from.calculate_shared_secret_sender();
let sender_epk = eph_holder_from.ephemeral_public_key().clone();
let eph_holder_to = EphemeralKeyHolder::new(&recipient_npk);
let recipient_ss = eph_holder_to.calculate_shared_secret_sender(&recipient_vpk);
let recipient_epk = eph_holder_from.generate_ephemeral_public_key();
let eph_holder_to = EphemeralKeyHolder::new(&recipient_vpk);
let recipient_ss = eph_holder_to.calculate_shared_secret_sender();
let recipient_epk = eph_holder_to.ephemeral_public_key().clone();
let balance_to_move: u128 = 1;
let proof: MembershipProof = (

View File

@ -142,6 +142,7 @@ unsafe extern "C" {
to_keys: *const FfiPrivateAccountKeys,
to_identifier: *const FfiU128,
amount: *const [u8; 16],
key_path: *const c_char,
out_result: *mut FfiTransferResult,
) -> error::WalletFfiError;
@ -923,6 +924,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> {
&raw const to_keys,
&raw const to_identifier,
&raw const amount,
std::ptr::null(),
&raw mut transfer_result,
)
.unwrap();

View File

@ -19,6 +19,7 @@ common.workspace = true
anyhow.workspace = true
serde.workspace = true
k256.workspace = true
ml-kem.workspace = true
sha2.workspace = true
rand.workspace = true
hex.workspace = true

View File

@ -1,53 +1,61 @@
use lee_core::{
NullifierPublicKey, SharedSecretKey,
encryption::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey},
SharedSecretKey,
encryption::{EphemeralPublicKey, ViewingPublicKey},
};
use rand::{RngCore as _, rngs::OsRng};
use sha2::Digest as _;
#[derive(Debug)]
/// Ephemeral secret key holder. Non-clonable as intended for one-time use. Produces ephemeral
/// public keys. Can produce shared secret for sender.
/// Ephemeral key holder for the sender side of a KEM-based shared-secret exchange.
///
/// Non-clonable as intended for one-time use: construction encapsulates once and
/// stores both the shared secret and the ciphertext (`EphemeralPublicKey`) that must
/// be sent to the receiver.
pub struct EphemeralKeyHolder {
ephemeral_secret_key: EphemeralSecretKey,
shared_secret: SharedSecretKey,
ephemeral_public_key: EphemeralPublicKey,
}
// SharedSecretKey does not implement Debug (intentional — leaking key material via
// debug output would be a security risk). We implement Debug manually here, redacting the
// shared secret while still allowing the ephemeral public key (KEM ciphertext) to be inspected.
impl std::fmt::Debug for EphemeralKeyHolder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EphemeralKeyHolder")
.field("shared_secret", &"<redacted>")
.field("ephemeral_public_key", &self.ephemeral_public_key)
.finish()
}
}
impl EphemeralKeyHolder {
#[must_use]
pub fn new(receiver_nullifier_public_key: &NullifierPublicKey) -> Self {
let mut nonce_bytes = [0; 16];
OsRng.fill_bytes(&mut nonce_bytes);
let mut hasher = sha2::Sha256::new();
hasher.update(receiver_nullifier_public_key);
hasher.update(nonce_bytes);
pub fn new(receiver_viewing_public_key: &ViewingPublicKey) -> Self {
let (shared_secret, ephemeral_public_key) =
SharedSecretKey::encapsulate(receiver_viewing_public_key);
Self {
ephemeral_secret_key: hasher.finalize().into(),
shared_secret,
ephemeral_public_key,
}
}
/// Returns the KEM ciphertext to be transmitted to the receiver as the `EphemeralPublicKey`.
#[must_use]
pub fn generate_ephemeral_public_key(&self) -> EphemeralPublicKey {
EphemeralPublicKey::from_scalar(self.ephemeral_secret_key)
pub const fn ephemeral_public_key(&self) -> &EphemeralPublicKey {
&self.ephemeral_public_key
}
/// Returns the sender-side shared secret (established at construction time).
#[must_use]
pub fn calculate_shared_secret_sender(
&self,
receiver_viewing_public_key: &ViewingPublicKey,
) -> SharedSecretKey {
SharedSecretKey::new(self.ephemeral_secret_key, receiver_viewing_public_key)
pub const fn calculate_shared_secret_sender(&self) -> SharedSecretKey {
self.shared_secret
}
}
/// Encapsulates a fresh shared secret toward `vpk` and returns `(shared_secret, ciphertext)`.
///
/// Used when the local side is acting as an "ephemeral receiver" — i.e. generating a
/// one-sided encryption that only the holder of the VSK can decrypt.
#[must_use]
pub fn produce_one_sided_shared_secret_receiver(
vpk: &ViewingPublicKey,
) -> (SharedSecretKey, EphemeralPublicKey) {
let mut esk = [0; 32];
OsRng.fill_bytes(&mut esk);
(
SharedSecretKey::new(esk, vpk),
EphemeralPublicKey::from_scalar(esk),
)
SharedSecretKey::encapsulate(vpk)
}

View File

@ -1,44 +1,39 @@
use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
use lee_core::{
SharedSecretKey,
encryption::{Scalar, shared_key_derivation::Secp256k1Point},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{PdaSeed, ProgramId},
};
use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, digest::FixedOutput as _};
use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey};
use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey};
/// Public key used to seal a `GroupKeyHolder` for distribution to a recipient.
///
/// Wraps a secp256k1 point but is a distinct type from `ViewingPublicKey` to enforce
/// key separation: viewing keys encrypt account state, sealing keys encrypt the GMS
/// for off-chain distribution.
pub struct SealingPublicKey(Secp256k1Point);
/// Wraps the ML-KEM-768 encapsulation key bytes (1184 bytes). Distinct from
/// `ViewingPublicKey` to enforce key separation: viewing keys encrypt account state,
/// sealing keys encrypt the GMS for off-chain distribution.
pub struct SealingPublicKey(Vec<u8>);
impl SealingPublicKey {
/// Derive the sealing public key from a secret scalar.
#[must_use]
pub fn from_scalar(scalar: Scalar) -> Self {
Self(Secp256k1Point::from_scalar(scalar))
}
/// Construct from raw serialized bytes (e.g. received from another wallet).
/// Construct from raw serialized encapsulation-key bytes (e.g. received from another wallet).
#[must_use]
pub const fn from_bytes(bytes: Vec<u8>) -> Self {
Self(Secp256k1Point(bytes))
Self(bytes)
}
/// Returns the raw bytes for display or transmission.
#[must_use]
pub fn to_bytes(&self) -> &[u8] {
&self.0.0
&self.0
}
}
/// Secret key used to unseal a `GroupKeyHolder` received from another member.
pub type SealingSecretKey = Scalar;
/// Holds the two 32-byte FIPS 203 seed halves `d` and `z`.
pub type SealingSecretKey = ViewingSecretKey;
/// Manages shared viewing keys for a group of controllers owning private PDAs.
///
@ -153,18 +148,17 @@ impl GroupKeyHolder {
/// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`].
///
/// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM
/// to encrypt the payload. The returned bytes are
/// `ephemeral_pubkey (33) || nonce (12) || ciphertext+tag (48)` = 93 bytes.
/// Uses ML-KEM-768 encapsulation to derive a shared secret, then AES-256-GCM to encrypt
/// the payload. The returned bytes are
/// `kem_ciphertext (1088) || nonce (12) || ciphertext+tag (48)` = 1148 bytes.
///
/// Each call generates a fresh ephemeral key, so two seals of the same holder produce
/// Each call generates a fresh KEM encapsulation, so two seals of the same holder produce
/// different ciphertexts.
#[must_use]
pub fn seal_for(&self, recipient_key: &SealingPublicKey) -> Vec<u8> {
let mut ephemeral_scalar: Scalar = [0_u8; 32];
OsRng.fill_bytes(&mut ephemeral_scalar);
let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar);
let shared = SharedSecretKey::new(ephemeral_scalar, &recipient_key.0);
let sealing_key = ViewingPublicKey::from_bytes(recipient_key.0.clone())
.expect("key_protocol::group_key_holder::GroupKeyHolder::seal_for: SealingPublicKey must be a valid ML-KEM-768 encapsulation key");
let (shared, kem_ct) = SharedSecretKey::encapsulate(&sealing_key);
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -176,12 +170,12 @@ impl GroupKeyHolder {
.encrypt(&nonce, self.gms.as_ref())
.expect("AES-GCM encryption should not fail with valid key/nonce");
let capacity = 33_usize
let capacity = 1088_usize
.checked_add(12)
.and_then(|n| n.checked_add(ciphertext.len()))
.expect("seal capacity overflow");
let mut out = Vec::with_capacity(capacity);
out.extend_from_slice(&ephemeral_pubkey.0);
out.extend_from_slice(&kem_ct.0);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
out
@ -189,20 +183,24 @@ impl GroupKeyHolder {
/// Decrypts a sealed `GroupKeyHolder` using the recipient's [`SealingSecretKey`].
///
/// Returns `Err` if the ciphertext is too short, the ECDH point is invalid, or the
/// AES-GCM authentication tag doesn't verify (wrong key or tampered data).
pub fn unseal(sealed: &[u8], own_key: SealingSecretKey) -> Result<Self, SealError> {
const HEADER_LEN: usize = 33 + 12;
/// Returns `Err` if the ciphertext is too short or the AES-GCM authentication tag
/// doesn't verify (wrong key or tampered data).
pub fn unseal(sealed: &[u8], own_key: &SealingSecretKey) -> Result<Self, SealError> {
// kem_ciphertext (1088) + nonce (12) = header, then AES-GCM tag (16) minimum.
const KEM_CT_LEN: usize = 1088;
const HEADER_LEN: usize = KEM_CT_LEN + 12;
const MIN_LEN: usize = HEADER_LEN + 16;
if sealed.len() < MIN_LEN {
return Err(SealError::TooShort);
}
// MIN_LEN (61) > HEADER_LEN (45), so all slicing below is in bounds.
let ephemeral_pubkey = Secp256k1Point(sealed[..33].to_vec());
let nonce = aes_gcm::Nonce::from_slice(&sealed[33..HEADER_LEN]);
let kem_ct = EphemeralPublicKey(sealed[..KEM_CT_LEN].to_vec());
let nonce = aes_gcm::Nonce::from_slice(&sealed[KEM_CT_LEN..HEADER_LEN]);
let ciphertext = &sealed[HEADER_LEN..];
let shared = SharedSecretKey::new(own_key, &ephemeral_pubkey);
let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.z)
.expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: KEM_CT_LEN guarantees exactly 1088 bytes");
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -219,7 +217,7 @@ impl GroupKeyHolder {
Ok(Self::from_gms(gms))
}
/// Derives an AES-256 key from the ECDH shared secret via SHA-256 with a domain prefix.
/// Derives an AES-256 key from the ML-KEM shared secret via SHA-256 with a domain prefix.
fn seal_kdf(shared: &SharedSecretKey) -> [u8; 32] {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeySeal/AES\x00\x00\x00\x00\x00\x00";
let mut hasher = sha2::Sha256::new();
@ -407,8 +405,10 @@ mod tests {
let recipient_vpk = recipient_keys.generate_viewing_public_key();
let recipient_vsk = recipient_keys.viewing_secret_key;
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
let restored = GroupKeyHolder::unseal(&sealed, recipient_vsk).expect("unseal");
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(
recipient_vpk.to_bytes().to_vec(),
));
let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal");
assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms());
@ -433,13 +433,14 @@ mod tests {
.produce_private_key_holder(None)
.generate_viewing_public_key();
let wrong_ssk = SecretSpendingKey([99_u8; 32]);
let wrong_vsk = wrong_ssk
let wrong_vsk = SecretSpendingKey([99_u8; 32])
.produce_private_key_holder(None)
.viewing_secret_key;
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
let result = GroupKeyHolder::unseal(&sealed, wrong_vsk);
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(
recipient_vpk.to_bytes().to_vec(),
));
let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk);
assert!(matches!(result, Err(super::SealError::DecryptionFailed)));
}
@ -453,16 +454,18 @@ mod tests {
let recipient_vpk = recipient_keys.generate_viewing_public_key();
let recipient_vsk = recipient_keys.viewing_secret_key;
let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
// Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce)
let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(
recipient_vpk.to_bytes().to_vec(),
));
// Flip a byte in the AES-GCM ciphertext portion (after KEM ciphertext + nonce).
let last = sealed.len() - 1;
sealed[last] ^= 0xFF;
let result = GroupKeyHolder::unseal(&sealed, recipient_vsk);
let result = GroupKeyHolder::unseal(&sealed, &recipient_vsk);
assert!(matches!(result, Err(super::SealError::DecryptionFailed)));
}
/// Two seals of the same holder produce different ciphertexts (ephemeral randomness).
/// Two seals of the same holder produce different ciphertexts (KEM randomness).
#[test]
fn two_seals_produce_different_ciphertexts() {
let holder = GroupKeyHolder::from_gms([42_u8; 32]);
@ -472,7 +475,7 @@ mod tests {
.produce_private_key_holder(None)
.generate_viewing_public_key();
let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.0);
let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.to_bytes().to_vec());
let sealed_a = holder.seal_for(&sealing_key);
let sealed_b = holder.seal_for(&sealing_key);
assert_ne!(sealed_a, sealed_b);
@ -481,14 +484,15 @@ mod tests {
/// Sealed payload is too short.
#[test]
fn unseal_too_short_fails() {
let vsk: SealingSecretKey = [7_u8; 32];
let result = GroupKeyHolder::unseal(&[0_u8; 10], vsk);
let vsk = SealingSecretKey {
d: [7_u8; 32],
z: [0_u8; 32],
};
let result = GroupKeyHolder::unseal(&[0_u8; 10], &vsk);
assert!(matches!(result, Err(super::SealError::TooShort)));
}
/// Degenerate GMS values (all-zeros, all-ones, single-bit) must still produce valid,
/// non-zero, pairwise-distinct npks. Rules out accidental "if gms == default { return
/// default }" style shortcuts in the derivation.
/// Degenerate GMS values must still produce valid, non-zero, pairwise-distinct npks.
#[test]
fn degenerate_gms_produces_distinct_non_zero_keys() {
let seed = PdaSeed::new([1; 32]);
@ -526,21 +530,19 @@ mod tests {
let pda_seed = PdaSeed::new([42_u8; 32]);
let program_id: lee_core::program::ProgramId = [1; 8];
// Derive Alice's keys
let alice_keys = alice_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed);
let alice_npk = alice_keys.generate_nullifier_public_key();
// Seal GMS for Bob using Bob's viewing key, Bob unseals
let bob_ssk = SecretSpendingKey([77_u8; 32]);
let bob_keys = bob_ssk.produce_private_key_holder(None);
let bob_vpk = bob_keys.generate_viewing_public_key();
let bob_vsk = bob_keys.viewing_secret_key;
let sealed = alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.0));
let sealed =
alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.to_bytes().to_vec()));
let bob_holder =
GroupKeyHolder::unseal(&sealed, bob_vsk).expect("Bob should unseal the GMS");
GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS");
// Key agreement: both derive identical NPK and AccountId
let bob_npk = bob_holder
.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed)
.generate_nullifier_public_key();

View File

@ -1,8 +1,8 @@
use std::collections::BTreeMap;
use k256::{Scalar, elliptic_curve::PrimeField as _};
use lee_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey};
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use crate::key_management::{
KeyChain,
@ -34,10 +34,10 @@ impl ChildKeysPrivate {
.expect("hash_value is 64 bytes, must be safe to get last 32");
let nsk = ssk.generate_nullifier_secret_key(None);
let vsk = ssk.generate_viewing_secret_key(None);
let vsk = ssk.generate_viewing_secret_seed_key(None);
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_scalar(vsk);
let vpk = ViewingPublicKey::from(&vsk);
Self {
value: (
@ -59,16 +59,20 @@ impl ChildKeysPrivate {
#[must_use]
pub fn nth_child(&self, cci: u32) -> Self {
#[expect(clippy::arithmetic_side_effects, reason = "TODO: fix later")]
let parent_pt =
Scalar::from_repr(self.value.0.private_key_holder.nullifier_secret_key.into())
.expect("Key generated as scalar, must be valid representation")
* Scalar::from_repr(self.value.0.private_key_holder.viewing_secret_key.into())
.expect("Key generated as scalar, must be valid representation");
let mut input = vec![];
// `parent_hash`` is used to incorporate entropy based on the parent node's keys
// to generate the `ssk` and `ccc` values.
let mut parent_hash = sha2::Sha256::new();
parent_hash.update(b"LEE/keys");
parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z);
let parent_pt = parent_hash.finalize();
// Each child (of the same parent node) share the same `parent_pt`.
// To ensure that each child generates unique keys, we include the child index.
let mut input = vec![];
input.extend_from_slice(b"LEE_seed_priv");
input.extend_from_slice(&parent_pt.to_bytes());
input.extend_from_slice(&parent_pt);
#[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")]
input.extend_from_slice(&cci.to_be_bytes());
@ -84,10 +88,10 @@ impl ChildKeysPrivate {
.expect("hash_value is 64 bytes, must be safe to get last 32");
let nsk = ssk.generate_nullifier_secret_key(Some(cci));
let vsk = ssk.generate_viewing_secret_key(Some(cci));
let vsk = ssk.generate_viewing_secret_seed_key(Some(cci));
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_scalar(vsk);
let vpk = ViewingPublicKey::from(&vsk);
Self {
value: (
@ -128,12 +132,11 @@ impl KeyTreeNode for ChildKeysPrivate {
#[cfg(test)]
mod tests {
use lee_core::{NullifierPublicKey, NullifierSecretKey};
use lee_core::NullifierSecretKey;
use super::*;
use crate::key_management::{self, secret_holders::ViewingSecretKey};
#[expect(clippy::redundant_type_annotations, reason = "TODO: clippy requires")]
#[test]
fn master_key_generation() {
let seed: [u8; 64] = [
@ -145,7 +148,7 @@ mod tests {
let keys = ChildKeysPrivate::root(seed);
let expected_ssk: SecretSpendingKey = key_management::secret_holders::SecretSpendingKey([
let expected_ssk = key_management::secret_holders::SecretSpendingKey([
246, 79, 26, 124, 135, 95, 52, 51, 201, 27, 48, 194, 2, 144, 51, 219, 245, 128, 139,
222, 42, 195, 105, 33, 115, 97, 186, 0, 97, 14, 218, 191,
]);
@ -160,26 +163,92 @@ mod tests {
34, 234, 19, 222, 2, 22, 12, 163, 252, 88, 11, 0, 163,
];
let expected_npk: NullifierPublicKey = lee_core::NullifierPublicKey([
let expected_npk = lee_core::NullifierPublicKey([
7, 123, 125, 191, 233, 183, 201, 4, 20, 214, 155, 210, 45, 234, 27, 240, 194, 111, 97,
247, 155, 113, 122, 246, 192, 0, 70, 61, 76, 71, 70, 2,
]);
let expected_vsk = [
155, 90, 54, 75, 228, 130, 68, 201, 129, 251, 180, 195, 250, 64, 34, 230, 241, 204,
216, 50, 149, 156, 10, 67, 208, 74, 9, 10, 47, 59, 50, 202,
];
let expected_vpk_as_bytes: [u8; 33] = [
2, 191, 99, 102, 114, 40, 131, 109, 166, 8, 222, 186, 107, 29, 156, 106, 206, 96, 127,
80, 170, 66, 217, 79, 38, 80, 11, 74, 147, 123, 221, 159, 166,
];
let expected_vsk = ViewingSecretKey::new(
[
187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194,
159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36,
],
[
80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19,
223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82,
],
);
let expected_vpk: [u8; 1184] = [
127, 229, 162, 212, 104, 117, 4, 150, 192, 103, 122, 195, 14, 35, 12, 60, 52, 23, 220,
150, 100, 203, 34, 34, 127, 232, 156, 43, 218, 109, 6, 160, 67, 35, 210, 194, 25, 181,
118, 237, 25, 129, 51, 160, 189, 51, 99, 184, 57, 28, 121, 240, 236, 2, 170, 198, 26,
91, 172, 110, 52, 32, 186, 35, 179, 202, 234, 249, 15, 242, 100, 198, 168, 163, 120,
205, 118, 85, 195, 210, 187, 95, 150, 154, 8, 68, 165, 237, 87, 166, 101, 57, 4, 18,
11, 122, 235, 180, 199, 154, 165, 158, 55, 136, 30, 237, 43, 167, 215, 68, 80, 102, 0,
71, 90, 130, 206, 240, 215, 69, 199, 83, 7, 60, 184, 128, 230, 184, 61, 93, 201, 204,
165, 104, 9, 127, 220, 52, 246, 217, 131, 251, 2, 170, 133, 6, 51, 40, 224, 101, 61,
16, 135, 32, 182, 201, 68, 58, 171, 54, 161, 184, 243, 38, 106, 200, 251, 17, 172, 8,
24, 73, 230, 55, 85, 20, 147, 222, 165, 200, 116, 135, 47, 20, 227, 56, 220, 64, 120,
215, 245, 58, 86, 102, 149, 252, 193, 163, 160, 59, 82, 138, 249, 171, 1, 54, 199, 193,
171, 85, 38, 64, 56, 121, 106, 84, 57, 252, 94, 147, 16, 191, 196, 104, 47, 129, 84,
21, 252, 160, 81, 207, 184, 199, 3, 177, 74, 117, 115, 175, 138, 108, 36, 198, 5, 32,
15, 218, 3, 20, 19, 15, 251, 209, 86, 128, 139, 148, 78, 10, 34, 144, 149, 74, 102, 48,
59, 70, 124, 47, 193, 100, 26, 9, 104, 178, 102, 156, 199, 242, 101, 147, 161, 87, 27,
234, 192, 204, 41, 36, 43, 83, 219, 15, 211, 66, 91, 76, 73, 13, 113, 155, 203, 193,
160, 130, 84, 103, 47, 70, 100, 147, 169, 65, 119, 84, 121, 122, 161, 76, 203, 144,
248, 145, 22, 8, 46, 121, 44, 77, 20, 149, 66, 179, 56, 149, 231, 98, 184, 9, 64, 14,
67, 196, 34, 8, 123, 21, 80, 169, 168, 223, 230, 133, 0, 66, 159, 230, 69, 201, 205,
169, 105, 196, 21, 71, 84, 70, 58, 165, 165, 134, 186, 232, 60, 70, 51, 57, 239, 74,
174, 116, 234, 36, 178, 49, 42, 168, 250, 104, 141, 106, 0, 109, 52, 86, 104, 243, 62,
214, 137, 48, 107, 2, 152, 206, 227, 175, 147, 236, 19, 113, 27, 191, 231, 235, 167,
114, 104, 23, 126, 203, 94, 242, 149, 171, 115, 170, 89, 244, 58, 29, 176, 73, 203, 44,
8, 32, 9, 226, 32, 78, 246, 38, 235, 149, 133, 25, 243, 47, 124, 180, 200, 211, 165,
137, 56, 169, 117, 31, 244, 65, 91, 135, 146, 158, 20, 75, 102, 32, 65, 250, 103, 199,
36, 48, 31, 155, 164, 191, 222, 85, 37, 66, 243, 17, 120, 104, 0, 228, 83, 200, 116, 6,
199, 106, 236, 139, 246, 216, 152, 241, 211, 85, 106, 200, 44, 231, 240, 66, 3, 193,
147, 16, 145, 65, 49, 33, 53, 247, 69, 47, 44, 113, 86, 117, 6, 20, 193, 183, 128, 178,
181, 21, 251, 99, 39, 149, 210, 146, 106, 181, 186, 7, 36, 63, 186, 234, 191, 164, 193,
162, 127, 250, 122, 189, 219, 21, 92, 48, 86, 209, 184, 99, 160, 201, 162, 145, 20,
138, 154, 18, 37, 180, 209, 165, 165, 51, 187, 78, 193, 175, 135, 6, 55, 216, 178, 10,
40, 246, 98, 128, 80, 14, 38, 69, 113, 123, 54, 94, 43, 50, 106, 167, 17, 77, 163, 148,
117, 225, 9, 7, 253, 240, 157, 96, 103, 33, 100, 37, 37, 20, 53, 138, 234, 55, 45, 232,
154, 9, 150, 192, 116, 36, 119, 106, 95, 119, 34, 220, 84, 174, 19, 227, 33, 209, 96,
197, 148, 230, 197, 59, 117, 130, 7, 116, 11, 0, 197, 16, 249, 151, 31, 4, 64, 29, 165,
247, 110, 176, 166, 4, 112, 136, 101, 208, 7, 179, 38, 183, 134, 58, 107, 207, 160, 38,
159, 67, 112, 20, 225, 199, 179, 133, 117, 144, 54, 199, 15, 204, 80, 154, 116, 84, 88,
109, 113, 5, 207, 226, 21, 62, 247, 122, 14, 156, 9, 8, 76, 26, 148, 67, 196, 128, 176,
78, 51, 161, 151, 75, 248, 154, 31, 168, 9, 4, 3, 107, 222, 245, 178, 21, 84, 7, 25,
155, 118, 97, 135, 63, 89, 233, 11, 207, 148, 155, 38, 106, 104, 102, 140, 104, 67,
149, 20, 30, 196, 44, 197, 128, 34, 182, 80, 30, 32, 137, 34, 212, 164, 177, 164, 12,
115, 41, 156, 111, 71, 230, 120, 111, 218, 25, 117, 218, 75, 167, 32, 37, 57, 50, 99,
181, 203, 40, 105, 248, 150, 114, 121, 73, 127, 198, 191, 161, 44, 56, 213, 243, 71, 2,
56, 192, 243, 107, 179, 27, 96, 21, 116, 169, 64, 15, 97, 166, 151, 200, 11, 40, 204,
71, 168, 220, 9, 55, 43, 146, 244, 212, 166, 192, 180, 189, 237, 162, 42, 29, 33, 52,
193, 4, 178, 157, 244, 28, 209, 44, 26, 36, 147, 126, 94, 164, 37, 47, 115, 38, 23,
165, 96, 106, 140, 42, 69, 146, 194, 93, 71, 175, 49, 147, 32, 246, 97, 94, 41, 116,
127, 174, 18, 16, 14, 163, 17, 180, 213, 203, 166, 33, 139, 214, 18, 170, 27, 41, 59,
175, 200, 101, 14, 128, 45, 179, 167, 136, 232, 138, 56, 124, 145, 75, 233, 132, 161,
196, 164, 72, 80, 60, 187, 38, 90, 90, 17, 66, 134, 59, 2, 165, 29, 76, 24, 38, 211,
177, 83, 119, 20, 239, 59, 77, 34, 3, 42, 47, 60, 89, 46, 103, 168, 120, 17, 199, 50,
17, 103, 107, 48, 8, 53, 220, 159, 212, 65, 198, 80, 8, 11, 235, 97, 203, 196, 240, 44,
56, 121, 77, 91, 196, 160, 129, 242, 149, 226, 57, 106, 180, 76, 161, 203, 18, 37, 166,
153, 44, 40, 28, 74, 8, 11, 6, 166, 54, 10, 103, 247, 23, 35, 7, 47, 173, 133, 71, 85,
3, 168, 250, 120, 126, 174, 37, 80, 128, 107, 7, 161, 130, 155, 136, 92, 48, 215, 119,
196, 124, 85, 157, 234, 2, 166, 137, 65, 121, 222, 112, 47, 17, 43, 23, 111, 88, 5,
195, 41, 8, 191, 227, 21, 173, 35, 199, 196, 188, 162, 191, 195, 204, 137, 54, 16, 73,
178, 150, 249, 234, 22, 216, 123, 157, 144, 218, 118, 53, 193, 67, 65, 84, 162, 244,
165, 24, 110, 246, 146, 228, 212, 180, 150, 116, 201, 37, 128, 76, 41, 188, 42, 79,
148, 52, 196, 176, 178, 224, 48, 168, 13, 129, 193, 131, 185, 131, 93, 40, 145, 56,
180, 29, 153, 83, 39, 69, 232, 96, 238, 137, 104, 150, 2, 202, 239, 149, 248, 154, 115,
115, 127, 3, 8, 32, 61, 96, 66, 25, 181, 14, 72, 73, 97, 186, 134, 140, 33, 69, 33, 74,
];
assert!(expected_ssk == keys.value.0.secret_spending_key);
assert!(expected_ccc == keys.ccc);
assert!(expected_nsk == keys.value.0.private_key_holder.nullifier_secret_key);
assert!(expected_npk == keys.value.0.nullifier_public_key);
assert!(expected_vsk == keys.value.0.private_key_holder.viewing_secret_key);
assert!(expected_vpk_as_bytes == keys.value.0.viewing_public_key.to_bytes());
assert!(expected_vpk == keys.value.0.viewing_public_key.to_bytes());
}
#[test]
@ -194,33 +263,107 @@ mod tests {
let root_node = ChildKeysPrivate::root(seed);
let child_node = ChildKeysPrivate::nth_child(&root_node, 42_u32);
let expected_ccc: [u8; 32] = [
27, 73, 133, 213, 214, 63, 217, 184, 164, 17, 172, 140, 223, 95, 255, 157, 11, 0, 58,
53, 82, 147, 121, 120, 199, 50, 30, 28, 103, 24, 121, 187,
let expected_ssk = key_management::secret_holders::SecretSpendingKey([
151, 183, 113, 151, 215, 187, 207, 64, 197, 182, 207, 32, 5, 49, 180, 98, 119, 14, 248,
175, 39, 100, 47, 109, 148, 173, 217, 253, 159, 234, 209, 113,
]);
let expected_ccc = [
138, 243, 142, 163, 62, 107, 63, 131, 230, 158, 185, 60, 204, 50, 243, 222, 13, 123,
98, 116, 131, 194, 7, 25, 129, 209, 163, 72, 178, 143, 192, 240,
];
let expected_nsk: NullifierSecretKey = [
124, 61, 40, 92, 33, 135, 3, 41, 200, 234, 3, 69, 102, 184, 57, 191, 106, 151, 194,
192, 103, 132, 141, 112, 249, 108, 192, 117, 24, 48, 70, 216,
196, 33, 11, 39, 220, 84, 119, 182, 187, 194, 135, 20, 124, 33, 244, 205, 96, 58, 102,
52, 74, 67, 110, 213, 24, 16, 160, 64, 247, 3, 107, 235,
];
let expected_npk = lee_core::NullifierPublicKey([
116, 231, 246, 189, 145, 240, 37, 59, 219, 223, 216, 246, 116, 171, 223, 55, 197, 200,
134, 192, 221, 40, 218, 167, 239, 5, 11, 95, 147, 247, 162, 226,
247, 253, 217, 86, 157, 208, 39, 172, 59, 190, 88, 165, 7, 173, 183, 106, 172, 211, 4,
180, 51, 107, 177, 107, 51, 117, 231, 176, 200, 103, 1, 121,
]);
let expected_vsk: ViewingSecretKey = [
33, 155, 68, 60, 102, 70, 47, 105, 194, 129, 44, 26, 143, 198, 44, 244, 185, 31, 236,
252, 205, 89, 138, 107, 39, 38, 154, 73, 109, 166, 41, 114,
];
let expected_vpk_as_bytes: [u8; 33] = [
2, 78, 213, 113, 117, 105, 162, 248, 175, 68, 128, 232, 106, 204, 208, 159, 11, 78, 48,
244, 127, 112, 46, 0, 93, 184, 1, 77, 132, 160, 75, 152, 88,
let expected_vsk = ViewingSecretKey::new(
[
185, 209, 179, 92, 7, 131, 98, 121, 215, 46, 154, 56, 238, 106, 162, 225, 83, 82,
134, 3, 80, 186, 35, 178, 161, 204, 205, 163, 28, 19, 149, 18,
],
[
174, 24, 72, 205, 129, 123, 131, 9, 146, 152, 224, 151, 10, 184, 224, 109, 94, 149,
117, 60, 26, 10, 212, 125, 113, 147, 87, 67, 73, 26, 101, 193,
],
);
let expected_vpk: [u8; 1184] = [
215, 229, 207, 120, 148, 177, 148, 197, 72, 222, 134, 3, 231, 146, 123, 226, 36, 84,
232, 179, 205, 16, 241, 142, 9, 81, 58, 54, 12, 115, 148, 182, 19, 245, 22, 203, 57,
71, 11, 204, 156, 130, 30, 170, 199, 201, 25, 2, 21, 34, 155, 136, 124, 145, 223, 128,
177, 207, 92, 38, 252, 165, 118, 61, 128, 71, 154, 242, 105, 165, 52, 7, 6, 244, 120,
227, 134, 191, 25, 169, 150, 123, 246, 138, 25, 196, 126, 156, 144, 33, 123, 120, 44,
142, 89, 201, 49, 219, 205, 87, 236, 110, 64, 129, 102, 100, 155, 26, 101, 121, 42,
236, 82, 111, 141, 117, 75, 71, 194, 73, 123, 170, 110, 69, 149, 107, 96, 195, 55, 122,
140, 131, 106, 140, 156, 147, 75, 28, 128, 138, 113, 86, 37, 63, 173, 214, 200, 2, 214,
84, 234, 176, 120, 252, 184, 99, 192, 65, 112, 150, 99, 26, 174, 187, 183, 187, 64, 90,
248, 100, 66, 63, 195, 3, 44, 43, 128, 59, 149, 107, 66, 180, 67, 200, 183, 200, 36,
91, 7, 65, 228, 159, 79, 44, 89, 35, 163, 145, 92, 227, 104, 2, 72, 5, 7, 193, 21, 51,
116, 198, 184, 6, 192, 188, 68, 183, 163, 193, 142, 244, 217, 155, 197, 187, 189, 174,
225, 45, 126, 112, 93, 194, 156, 102, 150, 1, 188, 222, 76, 108, 73, 149, 44, 28, 219,
66, 95, 215, 204, 148, 217, 16, 36, 121, 112, 2, 51, 10, 195, 137, 12, 93, 203, 146,
138, 211, 15, 201, 42, 72, 146, 186, 160, 222, 235, 127, 83, 48, 182, 49, 248, 29, 138,
16, 32, 232, 179, 163, 187, 161, 174, 152, 187, 93, 76, 166, 48, 230, 219, 111, 123,
181, 103, 130, 28, 109, 235, 115, 45, 57, 193, 206, 160, 17, 52, 92, 194, 25, 3, 80,
97, 142, 249, 151, 94, 250, 95, 12, 57, 11, 165, 92, 47, 85, 182, 48, 22, 60, 97, 244,
59, 194, 135, 180, 133, 106, 227, 56, 192, 60, 91, 15, 241, 146, 89, 240, 130, 219,
202, 187, 43, 85, 98, 50, 104, 64, 114, 113, 80, 54, 69, 69, 5, 43, 90, 19, 0, 0, 188,
251, 184, 70, 160, 18, 117, 76, 53, 209, 166, 96, 34, 224, 137, 115, 183, 168, 243, 19,
1, 255, 4, 97, 162, 199, 104, 72, 213, 111, 62, 54, 172, 82, 184, 82, 143, 71, 99, 25,
104, 74, 120, 70, 84, 235, 32, 22, 20, 218, 163, 77, 194, 125, 75, 22, 72, 236, 192,
200, 107, 91, 156, 201, 10, 178, 87, 19, 181, 211, 91, 17, 145, 200, 17, 179, 65, 75,
200, 186, 89, 144, 91, 184, 116, 214, 51, 91, 42, 162, 243, 202, 92, 18, 54, 0, 213,
67, 149, 151, 51, 29, 220, 196, 160, 201, 68, 113, 210, 164, 175, 152, 121, 168, 231,
161, 91, 132, 218, 1, 171, 176, 84, 100, 57, 1, 3, 2, 196, 194, 76, 181, 79, 171, 157,
35, 162, 155, 192, 210, 149, 142, 120, 189, 127, 151, 96, 202, 225, 73, 242, 81, 112,
237, 224, 155, 130, 130, 34, 196, 153, 131, 161, 113, 163, 172, 114, 48, 207, 32, 151,
172, 83, 145, 79, 210, 100, 161, 92, 82, 216, 90, 104, 238, 212, 38, 50, 107, 17, 228,
195, 190, 6, 151, 165, 148, 245, 102, 51, 8, 185, 8, 85, 59, 247, 219, 95, 219, 170,
155, 233, 123, 27, 64, 251, 56, 24, 200, 16, 181, 212, 146, 61, 116, 106, 215, 214, 62,
118, 27, 68, 233, 148, 73, 135, 199, 74, 184, 89, 159, 217, 139, 24, 208, 250, 30, 224,
97, 185, 237, 193, 8, 216, 23, 186, 5, 50, 41, 161, 203, 22, 217, 23, 194, 191, 148,
124, 10, 212, 171, 209, 210, 145, 184, 171, 74, 35, 220, 43, 145, 241, 23, 43, 92, 171,
216, 43, 114, 77, 155, 147, 156, 86, 56, 170, 27, 1, 54, 182, 169, 96, 22, 201, 51,
145, 94, 143, 133, 106, 47, 176, 112, 197, 197, 96, 80, 73, 164, 207, 179, 22, 229,
171, 201, 223, 219, 13, 219, 1, 91, 224, 252, 171, 199, 217, 25, 60, 128, 135, 9, 71,
105, 231, 86, 34, 21, 155, 50, 0, 105, 72, 117, 108, 175, 140, 9, 181, 249, 139, 97, 3,
161, 66, 248, 42, 67, 113, 132, 8, 119, 232, 6, 169, 18, 157, 222, 53, 176, 56, 137,
120, 18, 115, 199, 187, 112, 48, 223, 211, 206, 152, 252, 108, 179, 129, 20, 227, 248,
183, 234, 87, 202, 49, 17, 69, 215, 118, 89, 188, 180, 33, 238, 245, 206, 40, 179, 129,
242, 59, 73, 254, 117, 114, 250, 179, 103, 109, 250, 202, 99, 152, 2, 167, 130, 169,
35, 71, 89, 211, 140, 71, 103, 154, 121, 108, 147, 191, 186, 73, 10, 73, 203, 23, 55,
106, 144, 98, 227, 157, 25, 27, 81, 67, 11, 57, 88, 227, 116, 61, 100, 94, 23, 166,
146, 57, 226, 72, 124, 33, 65, 226, 35, 167, 206, 156, 202, 213, 213, 158, 89, 249,
181, 19, 113, 109, 217, 71, 168, 142, 180, 122, 30, 5, 54, 170, 155, 73, 56, 170, 124,
139, 4, 165, 103, 82, 32, 183, 84, 7, 239, 117, 135, 239, 48, 24, 28, 210, 49, 137, 6,
158, 65, 211, 113, 205, 135, 146, 83, 10, 46, 90, 27, 97, 135, 135, 185, 173, 69, 58,
34, 247, 141, 150, 6, 158, 117, 23, 198, 139, 65, 81, 179, 187, 194, 247, 203, 127,
106, 232, 119, 122, 215, 197, 110, 69, 203, 174, 227, 63, 185, 106, 14, 184, 104, 113,
233, 83, 92, 104, 38, 188, 9, 135, 107, 108, 121, 193, 33, 209, 89, 39, 137, 17, 208,
26, 21, 238, 169, 86, 181, 193, 153, 82, 8, 151, 53, 39, 88, 91, 252, 3, 33, 75, 127,
9, 168, 53, 34, 1, 173, 202, 123, 157, 174, 170, 199, 254, 187, 196, 144, 37, 29, 48,
112, 173, 107, 147, 155, 69, 134, 137, 156, 247, 123, 242, 72, 5, 43, 106, 89, 179,
204, 41, 15, 60, 48, 78, 214, 180, 26, 170, 67, 71, 66, 146, 113, 220, 159, 153, 201,
176, 116, 154, 21, 186, 33, 180, 72, 39, 187, 240, 80, 112, 132, 144, 173, 210, 12, 76,
184, 146, 89, 178, 178, 82, 109, 71, 201, 241, 160, 207, 219, 124, 77, 2, 105, 124,
178, 71, 3, 38, 64, 41, 83, 170, 137, 82, 242, 144, 76, 102, 82, 7, 25, 149, 141, 169,
46, 4, 68, 40, 244, 146, 131, 107, 148, 18, 111, 85, 104, 243, 28, 75, 176, 249, 88,
82, 123, 89, 29, 104, 135, 230, 117, 67, 26, 249, 108, 145, 76, 38, 175, 89, 185, 94,
106, 128, 201, 150, 151, 194, 133, 21, 81, 213, 231, 15, 117, 44, 61, 86, 223, 162, 56,
190, 166, 177, 157, 137, 60, 208, 155, 234, 158, 252, 30,
];
assert!(expected_ssk == child_node.value.0.secret_spending_key);
assert!(expected_ccc == child_node.ccc);
assert!(expected_nsk == child_node.value.0.private_key_holder.nullifier_secret_key);
assert!(expected_npk == child_node.value.0.nullifier_public_key);
assert!(expected_vsk == child_node.value.0.private_key_holder.viewing_secret_key);
assert!(expected_vpk_as_bytes == child_node.value.0.viewing_public_key.to_bytes());
assert!(expected_vpk == child_node.value.0.viewing_public_key.to_bytes());
}
}

View File

@ -69,21 +69,15 @@ impl KeyChain {
pub fn calculate_shared_secret_receiver(
&self,
ephemeral_public_key_sender: &EphemeralPublicKey,
index: Option<u32>,
) -> SharedSecretKey {
SharedSecretKey::new(
self.secret_spending_key.generate_viewing_secret_key(index),
ephemeral_public_key_sender,
)
) -> Option<SharedSecretKey> {
let vsk = &self.private_key_holder.viewing_secret_key;
SharedSecretKey::decapsulate(ephemeral_public_key_sender, &vsk.d, &vsk.z)
}
}
#[cfg(test)]
mod tests {
use aes_gcm::aead::OsRng;
use base58::ToBase58 as _;
use k256::{AffinePoint, elliptic_curve::group::GroupEncoding as _};
use rand::RngCore as _;
use super::*;
use crate::key_management::{
@ -106,14 +100,31 @@ mod tests {
fn calculate_shared_secret_receiver() {
let account_id_key_holder = KeyChain::new_os_random();
// Generate a random ephemeral public key sender
let mut scalar = [0; 32];
OsRng.fill_bytes(&mut scalar);
let ephemeral_public_key_sender = EphemeralPublicKey::from_scalar(scalar);
// Create a proper KEM ciphertext by encapsulating toward this key chain's VPK.
let (_, epk) = SharedSecretKey::encapsulate(&account_id_key_holder.viewing_public_key);
// Calculate shared secret
let _shared_secret = account_id_key_holder
.calculate_shared_secret_receiver(&ephemeral_public_key_sender, None);
let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk);
}
#[test]
fn calculate_shared_secret_receiver_returns_none_for_malformed_epk() {
let key_chain = KeyChain::new_os_random();
let short_epk = EphemeralPublicKey(vec![42_u8; 100]);
assert!(
key_chain
.calculate_shared_secret_receiver(&short_epk)
.is_none(),
"short EphemeralPublicKey must return None"
);
let long_epk = EphemeralPublicKey(vec![42_u8; 1089]);
assert!(
key_chain
.calculate_shared_secret_receiver(&long_epk)
.is_none(),
"long EphemeralPublicKey must return None"
);
}
#[test]
@ -135,12 +146,6 @@ mod tests {
println!("======Prerequisites======");
println!();
println!(
"Group generator {:?}",
hex::encode(AffinePoint::GENERATOR.to_bytes())
);
println!();
println!("======Holders======");
println!();
@ -188,14 +193,12 @@ mod tests {
fn non_trivial_chain_index() {
let keys = account_with_chain_index_2_for_tests();
let eph_key_holder = EphemeralKeyHolder::new(&keys.nullifier_public_key);
let eph_key_holder = EphemeralKeyHolder::new(&keys.viewing_public_key);
let key_sender = eph_key_holder.calculate_shared_secret_sender(&keys.viewing_public_key);
let key_receiver = keys.calculate_shared_secret_receiver(
&eph_key_holder.generate_ephemeral_public_key(),
Some(2),
);
let key_sender = eph_key_holder.calculate_shared_secret_sender();
let key_receiver =
keys.calculate_shared_secret_receiver(eph_key_holder.ephemeral_public_key());
assert_eq!(key_sender.0, key_receiver.0);
assert_eq!(key_sender.0, key_receiver.unwrap().0);
}
}

View File

@ -1,9 +1,7 @@
use bip39::Mnemonic;
use common::HashType;
use lee_core::{
NullifierPublicKey, NullifierSecretKey,
encryption::{Scalar, ViewingPublicKey},
};
use lee_core::{NullifierPublicKey, NullifierSecretKey, encryption::ViewingPublicKey};
use ml_kem;
use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, digest::FixedOutput as _};
@ -19,8 +17,20 @@ pub struct SeedHolder {
/// Secret spending key object. Can produce `PrivateKeyHolder` objects.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SecretSpendingKey(pub [u8; 32]);
/// Viewing secret key: the FIPS 203 KEM seed split into its two 32-byte halves `d` and `z`,
/// from which the ML-KEM-768 decapsulation key is derived deterministically.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViewingSecretKey {
pub d: [u8; 32],
pub z: [u8; 32],
}
pub type ViewingSecretKey = Scalar;
impl ViewingSecretKey {
#[must_use]
pub const fn new(d: [u8; 32], z: [u8; 32]) -> Self {
Self { d, z }
}
}
/// Private key holder. Produces public keys. Can produce `account_id`. Can produce shared secret
/// for recepient.
@ -114,7 +124,7 @@ impl SecretSpendingKey {
#[must_use]
#[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")]
pub fn generate_viewing_secret_key(&self, index: Option<u32>) -> ViewingSecretKey {
pub fn generate_viewing_secret_seed_key(&self, index: Option<u32>) -> ViewingSecretKey {
const PREFIX: &[u8; 8] = b"LEE/keys";
const SUFFIX_1: &[u8; 1] = &[2];
const SUFFIX_2: &[u8; 19] = &[0; 19];
@ -124,25 +134,57 @@ impl SecretSpendingKey {
_ => index.expect("Expect a valid u32"),
};
let mut hasher = sha2::Sha256::new();
hasher.update(PREFIX);
hasher.update(self.0);
hasher.update(SUFFIX_1);
hasher.update(index.to_be_bytes());
hasher.update(SUFFIX_2);
let mut bytes: Vec<u8> = Vec::with_capacity(64);
bytes.extend_from_slice(PREFIX);
bytes.extend_from_slice(&self.0);
bytes.extend_from_slice(SUFFIX_1);
bytes.extend_from_slice(&index.to_be_bytes());
bytes.extend_from_slice(SUFFIX_2);
let bytes: [u8; 64] = bytes
.try_into()
.expect("`generate_viewing_secret_seed_key`: bytes must be exactly 64");
hasher.finalize_fixed().into()
let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed");
ViewingSecretKey::new(
*full_seed
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
*full_seed
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32"),
)
}
#[must_use]
pub const fn generate_viewing_secret_key(seed: [u8; 64]) -> ViewingSecretKey {
ViewingSecretKey::new(
*seed.first_chunk::<32>().expect("seed is 64 bytes"),
*seed.last_chunk::<32>().expect("seed is 64 bytes"),
)
}
#[must_use]
pub fn produce_private_key_holder(&self, index: Option<u32>) -> PrivateKeyHolder {
PrivateKeyHolder {
nullifier_secret_key: self.generate_nullifier_secret_key(index),
viewing_secret_key: self.generate_viewing_secret_key(index),
viewing_secret_key: self.generate_viewing_secret_seed_key(index),
}
}
}
impl From<&ViewingSecretKey> for ViewingPublicKey {
fn from(sk: &ViewingSecretKey) -> Self {
use ml_kem::{Kem, KeyExport as _, MlKem768, Seed};
let mut seed_bytes = [0_u8; 64];
seed_bytes[..32].copy_from_slice(&sk.d);
seed_bytes[32..].copy_from_slice(&sk.z);
let dk = <MlKem768 as Kem>::DecapsulationKey::from_seed(Seed::from(seed_bytes));
Self::from_bytes(dk.encapsulation_key().to_bytes().to_vec())
.expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always 1184 bytes")
}
}
impl PrivateKeyHolder {
#[must_use]
pub fn generate_nullifier_public_key(&self) -> NullifierPublicKey {
@ -151,7 +193,7 @@ impl PrivateKeyHolder {
#[must_use]
pub fn generate_viewing_public_key(&self) -> ViewingPublicKey {
ViewingPublicKey::from_scalar(self.viewing_secret_key)
ViewingPublicKey::from(&self.viewing_secret_key)
}
}
@ -183,8 +225,7 @@ mod tests {
assert_eq!(seed_holder.seed.len(), 64);
let top_secret_key_holder = seed_holder.produce_top_secret_key_holder();
let _vsk = top_secret_key_holder.generate_viewing_secret_key(None);
let _vsk = top_secret_key_holder.generate_viewing_secret_seed_key(None);
}
#[test]

View File

@ -31,6 +31,7 @@ risc0-build = "3.0.3"
risc0-binfmt = "3.0.2"
[dev-dependencies]
lee_core = { workspace = true, features = ["test_utils"] }
token_core.workspace = true
authenticated_transfer_core.workspace = true
test_program_methods.workspace = true

View File

@ -16,7 +16,7 @@ thiserror.workspace = true
bytemuck.workspace = true
bytesize.workspace = true
base58.workspace = true
k256 = { workspace = true, optional = true }
ml-kem = { workspace = true, optional = true, features = ["getrandom"] }
chacha20 = { version = "0.10" }
[dev-dependencies]
@ -24,4 +24,5 @@ serde_json.workspace = true
[features]
default = []
host = ["dep:k256"]
host = ["dep:ml-kem"]
test_utils = ["host"]

View File

@ -7,7 +7,7 @@ use std::io::Read as _;
#[cfg(feature = "host")]
use crate::Nullifier;
#[cfg(feature = "host")]
use crate::encryption::shared_key_derivation::Secp256k1Point;
use crate::encryption::EphemeralPublicKey;
#[cfg(feature = "host")]
use crate::error::LeeCoreError;
use crate::{
@ -158,16 +158,17 @@ impl Ciphertext {
}
#[cfg(feature = "host")]
impl Secp256k1Point {
/// Converts the point to bytes.
impl EphemeralPublicKey {
/// Serializes the ML-KEM-768 ciphertext to bytes (always 1088 bytes).
#[must_use]
pub fn to_bytes(&self) -> [u8; 33] {
self.0.clone().try_into().unwrap()
pub fn to_bytes(&self) -> Vec<u8> {
self.0.clone()
}
/// Deserializes a secp256k1 point from a cursor.
/// Deserializes an ML-KEM-768 ciphertext from a cursor.
/// Reads exactly 1088 bytes — the fixed ciphertext size for ML-KEM-768.
pub fn from_cursor(cursor: &mut Cursor<&[u8]>) -> Result<Self, LeeCoreError> {
let mut value = vec![0; 33];
let mut value = vec![0_u8; 1088];
cursor.read_exact(&mut value)?;
Ok(Self(value))
}

View File

@ -6,7 +6,7 @@ use chacha20::{
use risc0_zkvm::sha::{Impl, Sha256 as _};
use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
pub use shared_key_derivation::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey};
pub use shared_key_derivation::{EphemeralPublicKey, MlKem768EncapsulationKey, ViewingPublicKey};
use crate::{Commitment, account::Account, program::PrivateAccountKind};
#[cfg(feature = "host")]
@ -154,4 +154,41 @@ mod tests {
assert_eq!(account_ct.0.len(), pda_ct.0.len());
}
/// Verifies the full account-note pipeline: ML-KEM-768 encapsulation/decapsulation
/// feeds the correct shared secret into the SHA-256 KDF and `ChaCha20` round-trip.
#[cfg(feature = "host")]
#[test]
fn kem_to_chacha20_round_trip() {
let d = [1_u8; 32];
let z = [2_u8; 32];
let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &z);
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
let account = Account {
program_owner: [12_u32; 8],
balance: 999,
..Account::default()
};
let kind = PrivateAccountKind::Regular(0);
let commitment = crate::Commitment::new(&AccountId::new([7_u8; 32]), &account);
let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &commitment, 0);
let (decoded_kind, decoded_account) =
EncryptionScheme::decrypt(&ct, &receiver_ss, &commitment, 0)
.expect("decryption must succeed with correct shared secret");
assert_eq!(decoded_account, account);
assert_eq!(decoded_kind, kind);
// Wrong shared secret must not decrypt correctly.
let wrong_ss = SharedSecretKey([0_u8; 32]);
let bad = EncryptionScheme::decrypt(&ct, &wrong_ss, &commitment, 0);
assert!(
bad.is_none() || bad.is_some_and(|(_, a)| a.balance != 999),
"wrong shared secret must not produce the correct plaintext"
);
}
}

View File

@ -1,78 +1,232 @@
#![expect(
clippy::arithmetic_side_effects,
reason = "Multiplication of finite field elements can't overflow"
)]
use std::fmt::Write as _;
use borsh::{BorshDeserialize, BorshSerialize};
use k256::{
AffinePoint, EncodedPoint, FieldBytes, ProjectivePoint,
elliptic_curve::{
PrimeField as _,
sec1::{FromEncodedPoint as _, ToEncodedPoint as _},
},
};
use ml_kem::{Decapsulate as _, Encapsulate as _, KeyExport as _, Seed};
use serde::{Deserialize, Serialize};
use crate::{SharedSecretKey, encryption::Scalar};
use crate::SharedSecretKey;
/// The ML-KEM-768 ciphertext produced during encapsulation; transmitted on-wire in place of the
/// former ECDH ephemeral public key. Always 1088 bytes for ML-KEM-768.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct EphemeralPublicKey(pub Vec<u8>);
/// ML-KEM-768 encapsulation key bytes (1184 bytes, opaque to this crate).
#[derive(
Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, BorshSerialize, BorshDeserialize,
Serialize,
Deserialize,
Clone,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
BorshSerialize,
BorshDeserialize,
)]
pub struct Secp256k1Point(pub Vec<u8>);
pub struct MlKem768EncapsulationKey(Vec<u8>);
impl std::fmt::Debug for Secp256k1Point {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let hex: String = self.0.iter().fold(String::new(), |mut acc, b| {
write!(acc, "{b:02x}").expect("writing to string should not fail");
acc
});
write!(f, "Secp256k1Point({hex})")
pub type ViewingPublicKey = MlKem768EncapsulationKey;
impl MlKem768EncapsulationKey {
/// Expected byte length of an ML-KEM-768 encapsulation key.
pub const LEN: usize = 1184;
/// Construct from raw bytes, returning an error if the length is not [`Self::LEN`].
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, crate::error::LeeCoreError> {
if bytes.len() != Self::LEN {
return Err(crate::error::LeeCoreError::DeserializationError(format!(
"MlKem768EncapsulationKey must be {} bytes, got {}",
Self::LEN,
bytes.len()
)));
}
Ok(Self(bytes))
}
}
impl Secp256k1Point {
#[must_use]
pub fn from_scalar(value: Scalar) -> Self {
let x_bytes: FieldBytes = value.into();
let x = k256::Scalar::from_repr(x_bytes).unwrap();
let p = ProjectivePoint::GENERATOR * x;
let q = AffinePoint::from(p);
let enc = q.to_encoded_point(true);
Self(enc.as_bytes().to_vec())
pub fn to_bytes(&self) -> &[u8] {
&self.0
}
}
pub type EphemeralSecretKey = Scalar;
pub type EphemeralPublicKey = Secp256k1Point;
pub type ViewingPublicKey = Secp256k1Point;
impl From<&EphemeralSecretKey> for EphemeralPublicKey {
fn from(value: &EphemeralSecretKey) -> Self {
Self::from_scalar(*value)
/// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `z`.
#[must_use]
pub fn from_seed(d: &[u8; 32], z: &[u8; 32]) -> Self {
let mut seed = Seed::default();
seed[..32].copy_from_slice(d);
seed[32..].copy_from_slice(z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
Self(dk.encapsulation_key().to_bytes().to_vec())
}
}
impl SharedSecretKey {
/// Creates a new shared secret key from a scalar and a point.
/// Sender: encapsulate a fresh shared secret toward `ek`.
///
/// Returns `(shared_secret, ciphertext)`. The ciphertext must be included in the transaction
/// as the `EphemeralPublicKey`; the receiver recovers the same shared secret via
/// [`Self::decapsulate`].
#[must_use]
pub fn new(scalar: Scalar, point: &Secp256k1Point) -> Self {
let scalar = k256::Scalar::from_repr(scalar.into()).unwrap();
let point: [u8; 33] = point.0.clone().try_into().unwrap();
pub fn encapsulate(ek: &MlKem768EncapsulationKey) -> (Self, EphemeralPublicKey) {
let ek_bytes: ml_kem::kem::Key<ml_kem::EncapsulationKey768> =
ek.0.as_slice()
.try_into()
.expect("MlKem768EncapsulationKey must be 1184 bytes");
let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect(
"MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key",
);
let (ct, ss) = ek_obj.encapsulate();
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
(Self(ss_bytes), EphemeralPublicKey(ct.to_vec()))
}
let encoded = EncodedPoint::from_bytes(point).unwrap();
let pubkey_affine = AffinePoint::from_encoded_point(&encoded).unwrap();
/// Deterministically encapsulate a shared secret toward `ek` for use in tests.
///
/// The shared secret has no secret entropy — it is fully determined by `ek`,
/// `message_hash`, and `output_index`, all of which are public. This makes it
/// unsuitable for real encryption but useful for producing stable, reproducible
/// shared secrets in unit tests. Use a distinct `output_index` per output to
/// avoid EPK collisions across multiple outputs in the same test.
///
/// For production use [`Self::encapsulate`], which draws randomness from the OS.
#[cfg(any(test, feature = "test_utils"))]
#[must_use]
pub fn encapsulate_deterministic(
ek: &MlKem768EncapsulationKey,
message_hash: &[u8; 32],
output_index: u32,
) -> (Self, EphemeralPublicKey) {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let shared = ProjectivePoint::from(pubkey_affine) * scalar;
let shared_affine = shared.to_affine();
let mut input = Vec::with_capacity(36);
input.extend_from_slice(message_hash);
input.extend_from_slice(&output_index.to_le_bytes());
let hash = Impl::hash_bytes(&input);
let m: ml_kem::B32 =
ml_kem::array::Array::try_from(hash.as_bytes()).expect("SHA-256 output is 32 bytes");
let shared_affine_encoded = shared_affine.to_encoded_point(false);
let x_bytes_slice = shared_affine_encoded.x().unwrap();
let mut x_bytes = [0_u8; 32];
x_bytes.copy_from_slice(x_bytes_slice);
let ek_bytes: ml_kem::kem::Key<ml_kem::EncapsulationKey768> =
ek.0.as_slice()
.try_into()
.expect("MlKem768EncapsulationKey must be 1184 bytes");
let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect(
"MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key",
);
let (ct, ss) = ek_obj.encapsulate_deterministic(&m);
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
(Self(ss_bytes), EphemeralPublicKey(ct.to_vec()))
}
Self(x_bytes)
/// Receiver: decapsulate the shared secret from a KEM ciphertext.
///
/// Returns `None` if the `EphemeralPublicKey` is not exactly 1088 bytes — callers on
/// the wallet scan path should skip the output rather than panic on malformed chain data.
///
/// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed.
#[must_use]
pub fn decapsulate(
ciphertext: &EphemeralPublicKey,
d: &[u8; 32],
z: &[u8; 32],
) -> Option<Self> {
let mut seed = Seed::default();
seed[..32].copy_from_slice(d);
seed[32..].copy_from_slice(z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
let ss = dk.decapsulate_slice(&ciphertext.0).ok()?;
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
Some(Self(ss_bytes))
}
}
#[cfg(test)]
mod tests {
use ml_kem::KeyExport as _;
use super::*;
#[test]
fn encapsulate_decapsulate_round_trip() {
let d = [1_u8; 32];
let z = [2_u8; 32];
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d);
seed[32..].copy_from_slice(&z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
let ek_bytes = dk.encapsulation_key().to_bytes();
let ek = MlKem768EncapsulationKey(ek_bytes.to_vec());
let (sender_ss, epk) = SharedSecretKey::encapsulate(&ek);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match");
assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes");
assert_eq!(
ek.0.len(),
1184,
"ML-KEM-768 encapsulation key is 1184 bytes"
);
}
#[test]
fn decapsulate_returns_none_for_malformed_epk() {
let d = [1_u8; 32];
let z = [2_u8; 32];
// Too short — 100 bytes instead of 1088.
let short_epk = EphemeralPublicKey(vec![42_u8; 100]);
assert!(
SharedSecretKey::decapsulate(&short_epk, &d, &z).is_none(),
"short EphemeralPublicKey must return None"
);
// Too long — 1089 bytes instead of 1088.
let long_epk = EphemeralPublicKey(vec![42_u8; 1089]);
assert!(
SharedSecretKey::decapsulate(&long_epk, &d, &z).is_none(),
"long EphemeralPublicKey must return None"
);
// Empty.
let empty_epk = EphemeralPublicKey(vec![]);
assert!(
SharedSecretKey::decapsulate(&empty_epk, &d, &z).is_none(),
"empty EphemeralPublicKey must return None"
);
}
#[test]
fn different_vpks_produce_different_shared_secrets() {
let (d1, z1) = ([1_u8; 32], [2_u8; 32]);
let (d2, z2) = ([3_u8; 32], [4_u8; 32]);
let ek1 = {
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d1);
seed[32..].copy_from_slice(&z1);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
MlKem768EncapsulationKey(dk.encapsulation_key().to_bytes().to_vec())
};
let ek2 = {
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d2);
seed[32..].copy_from_slice(&z2);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
MlKem768EncapsulationKey(dk.encapsulation_key().to_bytes().to_vec())
};
let (ss1, _) = SharedSecretKey::encapsulate(&ek1);
let (ss2, _) = SharedSecretKey::encapsulate(&ek2);
assert_ne!(ss1.0, ss2.0);
}
}

View File

@ -31,7 +31,9 @@ impl Proof {
}
pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool {
let inner: InnerReceipt = borsh::from_slice(&self.0).unwrap();
let Ok(inner) = borsh::from_slice::<InnerReceipt>(&self.0) else {
return false;
};
let receipt = Receipt::new(inner, circuit_output.to_bytes());
receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID).is_ok()
}
@ -243,8 +245,8 @@ mod tests {
let expected_sender_pre = sender.clone();
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &recipient_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![sender, recipient],
@ -340,11 +342,11 @@ mod tests {
Commitment::new(&recipient_account_id, &expected_private_account_2),
];
let esk_1 = [3; 32];
let shared_secret_1 = SharedSecretKey::new(esk_1, &sender_keys.vpk());
let shared_secret_1 =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0;
let esk_2 = [5; 32];
let shared_secret_2 = SharedSecretKey::new(esk_2, &recipient_keys.vpk());
let shared_secret_2 =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1).0;
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient],
@ -418,8 +420,8 @@ mod tests {
))
.unwrap();
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0).0;
let program_with_deps = ProgramWithDependencies::new(
validity_window_chain_caller,
@ -449,7 +451,8 @@ mod tests {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -487,7 +490,8 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret_pda =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
@ -526,7 +530,8 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret_pda =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
@ -581,7 +586,8 @@ mod tests {
let shared_keys = test_private_account_keys_1();
let shared_npk = shared_keys.npk();
let shared_identifier: u128 = 42;
let shared_secret = SharedSecretKey::new([55; 32], &shared_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&shared_keys.vpk(), &[0_u8; 32], 0).0;
// Sender: public account with balance, owned by auth-transfer
let sender_id = AccountId::new([99; 32]);
@ -632,7 +638,7 @@ mod tests {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let pre = AccountWithMetadata::new(Account::default(), true, account_id);
@ -662,7 +668,7 @@ mod tests {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let sender = AccountWithMetadata::new(
Account {
@ -707,7 +713,7 @@ mod tests {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let account = Account {
program_owner: program.id(),
@ -756,7 +762,7 @@ mod tests {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
@ -811,7 +817,8 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -838,7 +845,7 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);

View File

@ -143,7 +143,7 @@ pub mod tests {
Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind,
SharedSecretKey,
account::{Account, AccountId, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
encryption::ViewingPublicKey,
program::{BlockValidityWindow, TimestampValidityWindow},
};
use sha2::{Digest as _, Sha256};
@ -208,7 +208,7 @@ pub mod tests {
let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
// all remaining vec fields are empty: u32 len=0
let empty_vec_bytes: &[u8] = &[0_u8; 4];
// validity windows: unbounded = {from: None (0u8), to: None (0u8)}
// validity windows: unbounded = {from: None (0_u8), to: None (0_u8)}
let unbounded_window_bytes: &[u8] = &[0_u8; 2];
let expected_borsh_vec: Vec<u8> = [
@ -246,13 +246,11 @@ pub mod tests {
#[test]
fn encrypted_account_data_constructor() {
let npk = NullifierPublicKey::from(&[1; 32]);
let vpk = ViewingPublicKey::from_scalar([2; 32]);
let vpk = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]);
let account = Account::default();
let account_id = lee_core::account::AccountId::for_regular_private_account(&npk, 0);
let commitment = Commitment::new(&account_id, &account);
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &vpk);
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(&vpk, &[0_u8; 32], 0);
let ciphertext = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Regular(0),

View File

@ -421,7 +421,7 @@ pub mod tests {
BlockId, Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey,
NullifierSecretKey, SharedSecretKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{
BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId,
TimestampValidityWindow, WrappedBalanceSum,
@ -536,7 +536,8 @@ pub mod tests {
pub struct TestPrivateKeys {
pub nsk: NullifierSecretKey,
pub vsk: Scalar,
pub d: [u8; 32],
pub z: [u8; 32],
}
impl TestPrivateKeys {
@ -545,7 +546,7 @@ pub mod tests {
}
pub fn vpk(&self) -> ViewingPublicKey {
ViewingPublicKey::from_scalar(self.vsk)
ViewingPublicKey::from_seed(&self.d, &self.z)
}
}
@ -1333,14 +1334,16 @@ pub mod tests {
pub fn test_private_account_keys_1() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [13; 32],
vsk: [31; 32],
d: [31; 32],
z: [32; 32],
}
}
pub fn test_private_account_keys_2() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [38; 32],
vsk: [83; 32],
d: [83; 32],
z: [84; 32],
}
}
@ -1361,9 +1364,8 @@ pub mod tests {
let recipient =
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &recipient_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0);
let (output, proof) = circuit::execute_and_prove(
vec![sender, recipient],
@ -1413,13 +1415,11 @@ pub mod tests {
let recipient_pre =
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
let esk_1 = [3; 32];
let shared_secret_1 = SharedSecretKey::new(esk_1, &sender_keys.vpk());
let epk_1 = EphemeralPublicKey::from_scalar(esk_1);
let (shared_secret_1, epk_1) =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0);
let esk_2 = [3; 32];
let shared_secret_2 = SharedSecretKey::new(esk_2, &recipient_keys.vpk());
let epk_2 = EphemeralPublicKey::from_scalar(esk_2);
let (shared_secret_2, epk_2) =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1);
let (output, proof) = circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
@ -1483,9 +1483,8 @@ pub mod tests {
*recipient_account_id,
);
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0);
let (output, proof) = circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
@ -1993,14 +1992,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: recipient_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2039,14 +2048,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2085,14 +2104,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2131,14 +2160,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2177,14 +2216,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2221,14 +2270,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2247,7 +2306,8 @@ pub mod tests {
let program = Program::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let public_account_1 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
@ -2289,7 +2349,8 @@ pub mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -2326,7 +2387,8 @@ pub mod tests {
let npk_a = keys_a.npk();
let npk_b = keys_b.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys_b.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys_b.vpk(), &[0_u8; 32], 0).0;
// `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state.
// `AccountId::for_private_pda(program, seed, npk_b) != account_id`, so the claim check in
@ -2361,7 +2423,8 @@ pub mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -2400,7 +2463,8 @@ pub mod tests {
let npk = keys.npk();
let claim_seed = PdaSeed::new([77; 32]);
let wrong_delegated_seed = PdaSeed::new([88; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -2438,8 +2502,8 @@ pub mod tests {
let keys_a = test_private_account_keys_1();
let keys_b = test_private_account_keys_2();
let seed = PdaSeed::new([55; 32]);
let shared_a = SharedSecretKey::new([66; 32], &keys_a.vpk());
let shared_b = SharedSecretKey::new([77; 32], &keys_b.vpk());
let shared_a = SharedSecretKey::encapsulate_deterministic(&keys_a.vpk(), &[0_u8; 32], 0).0;
let shared_b = SharedSecretKey::encapsulate_deterministic(&keys_b.vpk(), &[0_u8; 32], 0).0;
let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk(), u128::MAX);
let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk(), u128::MAX);
@ -2480,7 +2544,8 @@ pub mod tests {
let program = Program::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let seed = PdaSeed::new([99; 32]);
// Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized =
@ -2580,7 +2645,8 @@ pub mod tests {
(&sender_keys.npk(), 0),
);
let shared_secret = SharedSecretKey::new([55; 32], &sender_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0;
let result = execute_and_prove(
vec![private_account_1.clone(), private_account_1],
Program::serialize_instruction(100_u128).unwrap(),
@ -2924,9 +2990,8 @@ pub mod tests {
AccountId::from(&PublicKey::new_from_private_key(&recipient_private_key));
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let esk = [5; 32];
let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0);
let balance = 37;
@ -3030,13 +3095,11 @@ pub mod tests {
None,
);
let from_esk = [3; 32];
let from_ss = SharedSecretKey::new(from_esk, &from_keys.vpk());
let from_epk = EphemeralPublicKey::from_scalar(from_esk);
let (from_ss, from_epk) =
SharedSecretKey::encapsulate_deterministic(&from_keys.vpk(), &[0_u8; 32], 0);
let to_esk = [3; 32];
let to_ss = SharedSecretKey::new(to_esk, &to_keys.vpk());
let to_epk = EphemeralPublicKey::from_scalar(to_esk);
let (to_ss, to_epk) =
SharedSecretKey::encapsulate_deterministic(&to_keys.vpk(), &[0_u8; 32], 1);
let mut dependencies = HashMap::new();
@ -3333,9 +3396,8 @@ pub mod tests {
let program = Program::authenticated_transfer_program();
// Set up parameters for the new account
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0);
let instruction = authenticated_transfer_core::Instruction::Initialize;
@ -3385,9 +3447,8 @@ pub mod tests {
AccountWithMetadata::new(Account::default(), false, (&private_keys.npk(), 0));
let program = Program::claimer();
let esk = [5; 32];
let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0);
let (output, proof) = execute_and_prove(
vec![unauthorized_account],
@ -3435,9 +3496,8 @@ pub mod tests {
let claimer_program = Program::claimer();
// Set up parameters for claiming the new account
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0);
let instruction = authenticated_transfer_core::Instruction::Initialize;
@ -3485,8 +3545,8 @@ pub mod tests {
};
let noop_program = Program::noop();
let esk2 = [4; 32];
let shared_secret2 = SharedSecretKey::new(esk2, &private_keys.vpk());
let shared_secret2 =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0).0;
// Step 3: Try to execute noop program with authentication but without initialization
let res = execute_and_prove(
@ -3570,7 +3630,8 @@ pub mod tests {
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([3; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -3596,7 +3657,8 @@ pub mod tests {
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([3; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -3642,8 +3704,8 @@ pub mod tests {
let balance_to_transfer = 10_u128;
let instruction = (balance_to_transfer, auth_transfers.id());
let recipient_esk = [3; 32];
let recipient = SharedSecretKey::new(recipient_esk, &recipient_keys.vpk());
let recipient =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0;
let mut dependencies = HashMap::new();
dependencies.insert(auth_transfers.id(), auth_transfers);
@ -3799,9 +3861,8 @@ pub mod tests {
let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0);
let instruction = (
block_validity_window,
@ -3869,9 +3930,8 @@ pub mod tests {
let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0);
let instruction = (
BlockValidityWindow::new_unbounded(),
@ -4425,8 +4485,10 @@ pub mod tests {
..Account::default()
};
let alice_shared_0 = SharedSecretKey::new([10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new([11; 32], &alice_keys.vpk());
let (alice_shared_0, alice_epk_0) =
SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 0);
let (alice_shared_1, alice_epk_1) =
SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 1);
// Fund alice_pda_0 via authenticated_transfer directly.
{
@ -4454,11 +4516,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_0.clone())],
output,
)
.unwrap();
@ -4498,11 +4556,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_1.clone())],
output,
)
.unwrap();
@ -4549,11 +4603,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![Nonce(0)],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_0)],
output,
)
.unwrap();
@ -4594,11 +4644,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_1)],
output,
)
.unwrap();
@ -4626,7 +4672,7 @@ pub mod tests {
};
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 alice_shared_1_refund = SharedSecretKey([12; 32]);
{
let recipient_account = state.get_account_by_id(recipient_id);
let recipient_nonce = recipient_account.nonce;
@ -4662,7 +4708,7 @@ pub mod tests {
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([12; 32]),
EphemeralPublicKey(vec![12_u8; 1088]),
)],
output,
)

View File

@ -544,7 +544,6 @@ mod tests {
use lee_core::{
Commitment, InputAccountIdentity, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::EphemeralPublicKey,
};
use crate::{
@ -571,9 +570,7 @@ mod tests {
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
let attacker_esk = [12_u8; 32];
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
let (attacker_ssk, attacker_epk) = SharedSecretKey::encapsulate(&attacker_keys.vpk());
let victim_id = AccountId::new([20_u8; 32]);
let recipient_id = AccountId::new([42_u8; 32]);
@ -695,7 +692,6 @@ mod tests {
use lee_core::{
Commitment, InputAccountIdentity, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::EphemeralPublicKey,
};
use crate::{
@ -725,9 +721,7 @@ mod tests {
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
let attacker_esk = [12_u8; 32];
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
let (attacker_ssk, attacker_epk) = SharedSecretKey::encapsulate(&attacker_keys.vpk());
// Victim is a private account — not registered in public chain state.
let victim_keys = test_private_account_keys_2();
@ -936,4 +930,56 @@ mod tests {
"recipient should receive nothing"
);
}
/// Regression test: a `PrivacyPreservingTransaction` carrying a structurally invalid
/// proof must be rejected with a clean `Err`.
#[test]
fn privacy_garbage_proof_is_rejected() {
use lee_core::{
Commitment,
account::Account,
program::{BlockValidityWindow, TimestampValidityWindow},
};
use crate::{
PrivacyPreservingTransaction,
privacy_preserving_transaction::{
circuit::Proof, message::Message, witness_set::WitnessSet,
},
};
let state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// Minimal message that passes every check up to proof verification: a single
// commitment satisfies the non-empty requirement, no signers makes the
// nonce/signature checks vacuously true, and unbounded validity windows are valid
// for any block/timestamp.
let account_id = AccountId::from(&PublicKey::new_from_private_key(
&PrivateKey::try_new([1_u8; 32]).unwrap(),
));
let commitment = Commitment::new(&account_id, &Account::default());
let message = Message {
public_account_ids: vec![],
nonces: vec![],
public_post_states: vec![],
encrypted_private_post_states: vec![],
new_commitments: vec![commitment],
new_nullifiers: vec![],
block_validity_window: BlockValidityWindow::new_unbounded(),
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
};
// Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`.
let garbage_proof = Proof::from_inner(vec![0xff_u8; 64]);
let witness_set = WitnessSet::for_message(&message, garbage_proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
match result {
Err(LeeError::InvalidPrivacyPreservingProof) => {}
Err(other) => panic!("expected InvalidPrivacyPreservingProof, got {other:?}"),
Ok(_) => panic!("garbage proof was accepted instead of rejected"),
}
}
}

View File

@ -5,14 +5,12 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256, digest::FixedOutput as _};
use crate::{HashType, transaction::LeeTransaction};
pub type MantleMsgId = [u8; 32];
pub type BlockHash = HashType;
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct BlockMeta {
pub id: BlockId,
pub hash: BlockHash,
pub msg_id: MantleMsgId,
}
#[derive(Debug, Clone)]
@ -55,7 +53,6 @@ pub struct Block {
pub header: BlockHeader,
pub body: BlockBody,
pub bedrock_status: BedrockStatus,
pub bedrock_parent_id: MantleMsgId,
}
impl Serialize for Block {
@ -80,11 +77,7 @@ pub struct HashableBlockData {
impl HashableBlockData {
#[must_use]
pub fn into_pending_block(
self,
signing_key: &lee::PrivateKey,
bedrock_parent_id: MantleMsgId,
) -> Block {
pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Block/\x00\x00\x00\x00\x00\x00\x00\x00";
let data_bytes = borsh::to_vec(&self).unwrap();
@ -111,7 +104,6 @@ impl HashableBlockData {
transactions: self.transactions,
},
bedrock_status: BedrockStatus::Pending,
bedrock_parent_id,
}
}
}

View File

@ -39,7 +39,7 @@ pub fn produce_dummy_block(
transactions,
};
block_data.into_pending_block(&sequencer_sign_key_for_testing(), [0; 32])
block_data.into_pending_block(&sequencer_sign_key_for_testing())
}
#[must_use]

View File

@ -27,7 +27,6 @@ pub fn BlockPreview(block: Block) -> impl IntoView {
},
body: BlockBody { transactions },
bedrock_status,
bedrock_parent_id: _,
} = block;
let tx_count = transactions.len();

View File

@ -64,7 +64,6 @@ pub fn BlockPage() -> impl IntoView {
transactions,
},
bedrock_status,
bedrock_parent_id: _,
} = blk;
let hash_str = hash.to_string();

View File

@ -238,10 +238,8 @@ mod tests {
timestamp: 0,
transactions: vec![clock_tx],
};
let genesis_block = genesis_block_data.into_pending_block(
&common::test_utils::sequencer_sign_key_for_testing(),
[0; 32],
);
let genesis_block = genesis_block_data
.into_pending_block(&common::test_utils::sequencer_sign_key_for_testing());
let mut prev_hash = Some(genesis_block.header.hash);
storage
.put_block(genesis_block, HeaderId::from([0_u8; 32]))

View File

@ -320,13 +320,10 @@ typedef struct FfiVec_FfiTransaction {
typedef struct FfiVec_FfiTransaction FfiBlockBody;
typedef struct FfiBytes32 FfiMsgId;
typedef struct FfiBlock {
struct FfiBlockHeader header;
FfiBlockBody body;
enum FfiBedrockStatus bedrock_status;
FfiMsgId bedrock_parent_id;
} FfiBlock;
typedef struct FfiOption_FfiBlock {

View File

@ -1,9 +1,7 @@
use indexer_service_protocol::{
BedrockStatus, Block, BlockHeader, HashType, MantleMsgId, Signature,
};
use indexer_service_protocol::{BedrockStatus, Block, BlockHeader, HashType, Signature};
use crate::api::types::{
FfiBlockId, FfiHashType, FfiMsgId, FfiOption, FfiSignature, FfiTimestamp, FfiVec,
FfiBlockId, FfiHashType, FfiOption, FfiSignature, FfiTimestamp, FfiVec,
transaction::free_ffi_transaction_vec, vectors::FfiBlockBody,
};
@ -12,7 +10,6 @@ pub struct FfiBlock {
pub header: FfiBlockHeader,
pub body: FfiBlockBody,
pub bedrock_status: FfiBedrockStatus,
pub bedrock_parent_id: FfiMsgId,
}
impl From<Block> for FfiBlock {
@ -21,7 +18,6 @@ impl From<Block> for FfiBlock {
header,
body,
bedrock_status,
bedrock_parent_id,
} = value;
Self {
@ -33,7 +29,6 @@ impl From<Block> for FfiBlock {
.collect::<Vec<_>>()
.into(),
bedrock_status: bedrock_status.into(),
bedrock_parent_id: bedrock_parent_id.into(),
}
}
}
@ -126,8 +121,6 @@ pub unsafe extern "C" fn free_ffi_block(val: FfiBlock) {
#[expect(clippy::let_underscore_must_use, reason = "No use for this Copy type")]
let _: BedrockStatus = val.bedrock_status.into();
let _ = MantleMsgId(val.bedrock_parent_id.data);
unsafe {
free_ffi_transaction_vec(ffi_tx_ffi_vec);
};
@ -166,8 +159,6 @@ pub unsafe extern "C" fn free_ffi_block_opt(val: FfiBlockOpt) {
#[expect(clippy::let_underscore_must_use, reason = "No use for this Copy type")]
let _: BedrockStatus = value.bedrock_status.into();
let _ = MantleMsgId(value.bedrock_parent_id.data);
unsafe {
free_ffi_transaction_vec(ffi_tx_ffi_vec);
};

View File

@ -1,4 +1,4 @@
use indexer_service_protocol::{AccountId, HashType, MantleMsgId, ProgramId, PublicKey, Signature};
use indexer_service_protocol::{AccountId, HashType, ProgramId, PublicKey, Signature};
pub mod account;
pub mod block;
@ -68,7 +68,6 @@ impl From<FfiU128> for u128 {
}
pub type FfiHashType = FfiBytes32;
pub type FfiMsgId = FfiBytes32;
pub type FfiBlockId = u64;
pub type FfiTimestamp = u64;
pub type FfiSignature = FfiBytes64;
@ -82,12 +81,6 @@ impl From<HashType> for FfiHashType {
}
}
impl From<MantleMsgId> for FfiMsgId {
fn from(value: MantleMsgId) -> Self {
Self { data: value.0 }
}
}
impl From<Signature> for FfiSignature {
fn from(value: Signature) -> Self {
Self { data: value.0 }

View File

@ -4,8 +4,8 @@ use lee_core::account::Nonce;
use crate::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, Ciphertext, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, MantleMsgId,
Nullifier, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, Nullifier,
PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction,
Signature, Transaction, ValidityWindow, WitnessSet,
};
@ -630,14 +630,12 @@ impl From<common::block::Block> for Block {
header,
body,
bedrock_status,
bedrock_parent_id,
} = value;
Self {
header: header.into(),
body: body.into(),
bedrock_status: bedrock_status.into(),
bedrock_parent_id: MantleMsgId(bedrock_parent_id),
}
}
}
@ -650,14 +648,12 @@ impl TryFrom<Block> for common::block::Block {
header,
body,
bedrock_status,
bedrock_parent_id,
} = value;
Ok(Self {
header: header.try_into()?,
body: body.try_into()?,
bedrock_status: bedrock_status.into(),
bedrock_parent_id: bedrock_parent_id.0,
})
}
}

View File

@ -145,7 +145,6 @@ pub struct Block {
pub header: BlockHeader,
pub body: BlockBody,
pub bedrock_status: BedrockStatus,
pub bedrock_parent_id: MantleMsgId,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
@ -358,13 +357,6 @@ impl FromStr for HashType {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct MantleMsgId(
#[serde(with = "base64::arr")]
#[schemars(with = "String", description = "base64-encoded Bedrock message id")]
pub [u8; 32],
);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum BedrockStatus {
Pending,

View File

@ -10,10 +10,10 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
use indexer_service_protocol::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, HashType, MantleMsgId,
PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, ProgramId, PublicMessage, PublicTransaction, Signature,
Transaction, ValidityWindow, WitnessSet,
CommitmentSetDigest, Data, EncryptedAccountData, HashType, PrivacyPreservingMessage,
PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction,
ProgramId, PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow,
WitnessSet,
};
use jsonrpsee::{
core::{SubscriptionResult, async_trait},
@ -432,7 +432,6 @@ fn build_mock_block(
transactions: block_transactions,
},
bedrock_status,
bedrock_parent_id: MantleMsgId([0; 32]),
}
}

View File

@ -13,3 +13,4 @@ pyo3.workspace = true
log.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
zeroize.workspace = true

View File

@ -3,15 +3,18 @@ from keycard.exceptions import APDUError, TransportError
from ecdsa import VerifyingKey, SECP256k1
from keycard.keycard import KeyCard
from keycard.commands.export_lee_key import export_lee_key
from mnemonic import Mnemonic
from keycard import constants
import keycard
import os
import secrets
DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing"
def _pairing_password() -> str:
return os.environ.get("KEYCARD_PAIRING_PASSWORD", DEFAULT_PAIRING_PASSWORD)
class KeycardWallet:
def __init__(self):
self.card = KeyCard()
@ -37,7 +40,7 @@ class KeycardWallet:
return False
return True
def initialize(self, pin: str) -> bool:
def initialize(self, pin: str, pairing_password: str | None = None) -> bool:
try:
self.card.select()
@ -45,14 +48,18 @@ class KeycardWallet:
raise RuntimeError("Card is already initialized")
puk = ''.join(secrets.choice('0123456789') for _ in range(12))
self.card.init(pin, puk, DEFAULT_PAIRING_PASSWORD)
self.card.init(pin, puk, pairing_password or _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:
def _reconnect(self) -> None:
self.card = KeyCard()
self.card.select()
def _pair(self, pin: str, password: str) -> tuple[int, bytes]:
self.card.select()
if not self.card.is_initialized:
@ -70,14 +77,28 @@ class KeycardWallet:
self.card.unpair(pairing_index)
except Exception:
pass
raise RuntimeError(f"Error setting up communication: {e}") from e
raise RuntimeError(f"Error opening secure channel after fresh pair: {e}") from e
return True
return pairing_index, pairing_key
def get_pairing_data(self) -> tuple[int, bytes]:
return (self.pairing_index, self.pairing_key)
def pair(self, pin: str, password: str | None = None) -> tuple[int, bytes]:
password = password or _pairing_password()
try:
return self._pair(pin, password)
except TransportError as e:
print(f"Transport error during fresh pair ({e}), attempting card reset and retry...")
try:
self._reconnect()
result = self._pair(pin, password)
print("Retry succeeded after card reset.")
return result
except TransportError as e2:
raise RuntimeError(
"Card lost power and did not recover after reset. "
"Try reseating the card in the reader."
) from e2
def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool:
def _setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool:
self.card.select()
if not self.card.is_initialized:
@ -94,6 +115,22 @@ class KeycardWallet:
return True
def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool:
try:
return self._setup_communication_with_pairing(pin, pairing_index, pairing_key)
except TransportError as e:
print(f"Transport error during stored pairing ({e}), attempting card reset and retry...")
try:
self._reconnect()
result = self._setup_communication_with_pairing(pin, pairing_index, pairing_key)
print("Retry succeeded after card reset.")
return result
except TransportError as e2:
raise RuntimeError(
"Card lost power and did not recover after reset. "
"Try reseating the card in the reader."
) from e2
def close_session(self) -> bool:
return True
@ -161,4 +198,24 @@ class KeycardWallet:
return signature.signature
except Exception as e:
raise RuntimeError(f"Error signing message: {e}") from e
raise RuntimeError(f"Error signing message: {e}") from e
def get_private_keys_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
private_keys = export_lee_key(
self.card,
constants.DerivationOption.DERIVE,
path
)
nsk = private_keys.lee_nsk
vsk = private_keys.lee_vsk
return (nsk, vsk)
except Exception as e:
raise RuntimeError(f"Error getting private keys: {e}") from e

View File

@ -3,9 +3,13 @@ use std::path::PathBuf;
use lee::{AccountId, PublicKey, Signature};
use pyo3::{prelude::*, types::PyAny};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
pub mod python_path;
/// NSK and VSK as fixed-length zeroizing byte arrays.
type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>);
// TODO: encrypt at rest alongside broader wallet storage encryption work.
#[derive(Serialize, Deserialize)]
pub struct KeycardPairingData {
@ -51,10 +55,10 @@ impl KeycardWallet {
.extract()
}
pub fn get_pairing_data(&self, py: Python<'_>) -> PyResult<(u8, Vec<u8>)> {
pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec<u8>)> {
self.instance
.bind(py)
.call_method0("get_pairing_data")?
.call_method1("pair", (pin,))?
.extract()
}
@ -91,20 +95,11 @@ impl KeycardWallet {
{
return Ok(());
}
self.setup_communication(py, pin)?;
if let Ok((index, key)) = self.get_pairing_data(py) {
save_pairing(&KeycardPairingData { index, key });
}
let (index, key) = self.pair(py, pin)?;
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()
}
@ -138,6 +133,10 @@ impl KeycardWallet {
})
}
#[expect(
clippy::arithmetic_side_effects,
reason = "64 - s_stripped.len() is safe: s_stripped.len() ≤ 31 because py_signature.len() is in [32, 63]"
)]
pub fn sign_message_for_path(
&self,
py: Python,
@ -150,6 +149,24 @@ impl KeycardWallet {
.call_method1("sign_message_for_path", (message, path))?
.extract()?;
// The keycard Python library strips leading zeros from S when S < 2^(8k) for some k.
// Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S).
let py_signature = if py_signature.len() < 64 {
if py_signature.len() < 32 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"signature from keycard too short: {} bytes",
py_signature.len()
)));
}
let s_stripped = &py_signature[32..];
let mut padded = [0_u8; 64];
padded[..32].copy_from_slice(&py_signature[..32]);
padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped);
padded.to_vec()
} else {
py_signature
};
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?})",
@ -190,11 +207,65 @@ impl KeycardWallet {
Ok(())
}
pub fn get_account_id_for_path_with_connect(pin: &str, key_path: &str) -> PyResult<String> {
pub fn get_public_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)))
}
pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult<PrivateKeyPair> {
let (raw_nsk, raw_vsk): (Vec<u8>, Vec<u8>) = self
.instance
.bind(py)
.call_method1("get_private_keys_for_path", (path,))?
.extract()?;
let raw_nsk = Zeroizing::new(raw_nsk);
let raw_vsk = Zeroizing::new(raw_vsk);
let nsk = {
if raw_nsk.len() != 32 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"expected 32-byte NSK from keycard, got {} bytes",
raw_nsk.len()
)));
}
let mut arr = Zeroizing::new([0_u8; 32]);
arr.copy_from_slice(&raw_nsk);
arr
};
let vsk = {
if raw_vsk.len() != 32 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"expected 32-byte VSK from keycard, got {} bytes",
raw_vsk.len()
)));
}
let mut arr = Zeroizing::new([0_u8; 32]);
arr.copy_from_slice(&raw_vsk);
arr
};
Ok((nsk, vsk))
}
pub fn get_private_keys_for_path_with_connect(
pin: &str,
path: &str,
) -> PyResult<PrivateKeyPair> {
Python::with_gil(|py| {
python_path::add_python_path(py)?;
let wallet = Self::new(py)?;
wallet.connect(py, pin)?;
let result = wallet.get_private_keys_for_path(py, path);
drop(wallet.disconnect(py));
result
})
}
}
fn pairing_file_path() -> Option<PathBuf> {

View File

@ -12,8 +12,12 @@ pub fn add_python_path(py: Python<'_>) -> PyResult<()> {
.unwrap_or_else(|| current_dir.clone());
let mut paths_to_add: Vec<PathBuf> = vec![
python_base.join("keycard_wallet").join("python"),
python_base
.join("lez")
.join("keycard_wallet")
.join("python"),
python_base
.join("lez")
.join("keycard_wallet")
.join("python")
.join("keycard-py"),

View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
Forces the card in the first available reader into the unpowered state via
PC/SC SCARD_UNPOWER_CARD. Run immediately before a wallet command to simulate
the power-loss condition reported on some USB reader/driver combinations.
Either:
- pcscd re-powers the card on the next SCardConnect, so wallet
commands will succeed without triggering the retry path.
- the card stays unpowered, triggering TransportError
and exercising the retry wrapper in pair() / setup_communication_with_pairing().
"""
import sys
from smartcard.scard import (
SCardEstablishContext, SCardListReaders, SCardConnect, SCardDisconnect,
SCARD_SCOPE_USER, SCARD_SHARE_SHARED,
SCARD_PROTOCOL_T0, SCARD_PROTOCOL_T1,
SCARD_UNPOWER_CARD,
)
hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER)
hresult, reader_list = SCardListReaders(hcontext, [])
if not reader_list:
print("force_unpower: no readers found, skipping.")
sys.exit(0)
hresult, hcard, _ = SCardConnect(
hcontext,
reader_list[0],
SCARD_SHARE_SHARED,
SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1,
)
if hresult != 0:
print(f"force_unpower: SCardConnect failed (hresult={hresult:#010x}), skipping.")
sys.exit(0)
SCardDisconnect(hcard, SCARD_UNPOWER_CARD)
print("force_unpower: card powered down.")

View File

@ -0,0 +1,117 @@
#!/bin/bash
# Power-recovery variant of keycard_tests.sh.
#
# Forces a card power cycle before each keycard-backed wallet command to verify
# commands survive mid-session power loss.
source venv/bin/activate
export KEYCARD_PIN=111111
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
unpower() {
python "$SCRIPT_DIR/force_unpower.py"
}
echo "Test: wallet keycard available"
wallet keycard available
echo ""
echo "Test: wallet keycard load (after power cycle)"
export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then"
unpower
wallet keycard load
unset KEYCARD_MNEMONIC
echo ""
echo "Test: wallet auth-transfer init --account-id \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet auth-transfer init --account-id "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet pinata claim --to \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet pinata claim --to "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet auth-transfer init and send between two keycard accounts (after power cycle)"
unpower
wallet auth-transfer init --account-id "m/44'/60'/0'/0/1"
unpower
wallet auth-transfer send --amount 40 --from "m/44'/60'/0'/0/0" --to "m/44'/60'/0'/0/1"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/1"
echo ""
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 ""
echo "Test: wallet auth-transfer init local account"
wallet auth-transfer init --account-id "Public/${LOCAL_ACCOUNT_ID}"
echo ""
echo "Test: wallet auth-transfer send from keycard to local account (after power cycle)"
unpower
wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/${LOCAL_ACCOUNT_ID}"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\" (after power cycle)"
unpower
wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}"
echo ""
echo "Test: wallet auth-transfer send from local account to keycard account (after power cycle)"
unpower
wallet auth-transfer send --amount 10 --from "Public/${LOCAL_ACCOUNT_ID}" --to "m/44'/60'/0'/0/1"
echo ""
echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\" (after power cycle)"
unpower
wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/1"
echo ""
echo "Test: wallet auth-transfer send from keycard to foreign account (after power cycle)"
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
unpower
wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
echo ""
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)"
unpower
wallet account get --account-id "m/44'/60'/0'/0/0"
echo ""
echo "Test: wallet account get foreign account (after power cycle)"
unpower
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# keycard_test_3.sh — tests for `wallet keycard get-private-keys`.
#
# Prerequisites:
# 1. Run wallet_with_keycard.sh once to install dependencies.
# 2. Keycard reader inserted with card loaded (wallet keycard load has been run).
source venv/bin/activate
cargo install --path lez/wallet --force --features keycard-debug
export KEYCARD_PIN=111111
echo "=== Test: wallet keycard get-private-keys path 10 ==="
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal
echo "=== Test: wallet keycard get-private-keys path 11 ==="
wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/11" --reveal
echo ""
echo "=== All get-private-keys tests finished ==="

View File

@ -28,7 +28,8 @@ 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"
echo ""
echo "=== Test: Keycard account to Keycard account ==="
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"
@ -38,7 +39,8 @@ 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 ""
echo "=== Test: Keycard account to public local 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}"
@ -56,7 +58,8 @@ 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 ""
echo "=== Test: public local account to Keycard account ==="
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"
@ -67,7 +70,8 @@ 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 ""
echo "=== Test: Keycard account to foreign recipient (no signature required) ==="
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
@ -79,3 +83,50 @@ 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"
echo ""
echo "=== Test: Shielded auth-transfer to owned private account ==="
SHIELDED_RECV=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1)
echo "Private recipient: $SHIELDED_RECV"
SHIELDED_KEYS=$(wallet account show-keys --account-id "$SHIELDED_RECV")
SHIELDED_NPK=$(echo "$SHIELDED_KEYS" | head -1)
SHIELDED_VPK=$(echo "$SHIELDED_KEYS" | tail -1)
wallet auth-transfer send --amount 2 \
--from "m/44'/60'/0'/0/0" \
--to-npk "$SHIELDED_NPK" \
--to-vpk "$SHIELDED_VPK"
echo "Shielded auth-transfer sent"
sleep 15
wallet account get --account-id "m/44'/60'/0'/0/0"
echo ""
echo "=== Test: Deshielded auth-transfer: private account → keycard path 1 ==="
PRIV_SENDER=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1)
echo "Fresh private sender account: $PRIV_SENDER"
wallet auth-transfer init --account-id "$PRIV_SENDER"
echo "Test: wallet pinata claim to private sender"
wallet pinata claim --to "$PRIV_SENDER"
sleep 15
echo "priv-sender state after claim:"
wallet account get --account-id "$PRIV_SENDER"
wallet auth-transfer send \
--from "$PRIV_SENDER" \
--to "m/44'/60'/0'/0/1" \
--amount 5
echo "Deshielded transfer of 5: $PRIV_SENDER → keycard path 1"
sleep 15
echo "priv-sender state (balance should have decreased by 5):"
wallet account get --account-id "$PRIV_SENDER"
echo "Keycard path 1 state (balance should have increased by 5):"
wallet account get --account-id "m/44'/60'/0'/0/1"

View File

@ -0,0 +1,457 @@
#!/usr/bin/env bash
# keycard_tests_2.sh — comprehensive token + AMM keycard integration tests.
#
# Prerequisites:
# 1. Run wallet_with_keycard.sh once to install dependencies.
# 2. Reset the local chain so all accounts are uninitialized.
# 3. Keycard reader inserted with card loaded.
#
# Keycard path layout:
# path 2 → LEZ token definition (keycard)
# path 3 → LEZ token supply (keycard)
# path 4 → LEE token definition (keycard)
# path 5 → LEE token supply (keycard)
# path 6 → LEZ holding (keycard — transfers, mint, burn, swap, liquidity)
# path 7 → LEE holding (keycard — swap, add/remove liquidity)
# path 8 → LP holding (keycard — add/remove liquidity)
# path 9 → ATA owner (keycard — ATA create, send, burn)
#
# Non-keycard accounts:
# pub-receiver → public account (target for keycard → public token transfer)
# priv-receiver → private account (target for keycard → private token transfer)
# amm-lez-fund → public LEZ holding used to seed the AMM pool
# amm-lee-fund → public LEE holding used to seed the AMM pool
# (LP holding for amm new is created fresh each run — no persistent label)
source venv/bin/activate
export KEYCARD_PIN=111111
# =============================================================================
# Keycard setup
# =============================================================================
echo ""
echo "=== Keycard setup ==="
wallet keycard available
export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then"
wallet keycard load
unset KEYCARD_MNEMONIC
# =============================================================================
# Create non-keycard wallet accounts
# =============================================================================
echo ""
echo "=== Create non-keycard accounts ==="
wallet account new public --label pub-receiver 2>/dev/null || true
wallet account new public --label amm-lez-fund 2>/dev/null || true
wallet account new public --label amm-lee-fund 2>/dev/null || true
wallet account new public --label amm-lp-fund 2>/dev/null || true
# =============================================================================
# (1) Create LEZ token — definition AND supply via keycard paths
# =============================================================================
echo ""
echo "=== (1) Create LEZ token (keycard def=path2, supply=path3) ==="
wallet token new \
--definition-account-id "m/44'/60'/0'/0/2" \
--supply-account-id "m/44'/60'/0'/0/3" \
--name LEZ \
--total-supply 100000
echo "LEZ token created"
# =============================================================================
# (2) Create LEE token — definition AND supply via keycard paths
# =============================================================================
echo ""
echo "=== (2) Create LEE token (keycard def=path4, supply=path5) ==="
wallet token new \
--definition-account-id "m/44'/60'/0'/0/4" \
--supply-account-id "m/44'/60'/0'/0/5" \
--name LEE \
--total-supply 100000
echo "LEE token created"
sleep 15
LEZ_DEF_ID=$(wallet account id --account-id "m/44'/60'/0'/0/2")
LEE_DEF_ID=$(wallet account id --account-id "m/44'/60'/0'/0/4")
echo "LEZ definition ID: $LEZ_DEF_ID"
echo "LEE definition ID: $LEE_DEF_ID"
echo "Keycard path 2 (LEZ definition) state:"
wallet account get --account-id "m/44'/60'/0'/0/2"
echo "Keycard path 3 (LEZ supply) state:"
wallet account get --account-id "m/44'/60'/0'/0/3"
echo "Keycard path 4 (LEE definition) state:"
wallet account get --account-id "m/44'/60'/0'/0/4"
echo "Keycard path 5 (LEE supply) state:"
wallet account get --account-id "m/44'/60'/0'/0/5"
# =============================================================================
# Initialize token holding accounts
# =============================================================================
echo ""
echo "=== Initialize token holding accounts ==="
# Keycard path 6: LEZ holding (mint 0 to initialize)
wallet token mint \
--definition "m/44'/60'/0'/0/2" \
--holder "m/44'/60'/0'/0/6" \
--amount 0
echo "LEZ holding initialized for keycard path 6"
# Keycard path 7: LEE holding (different definition — safe to submit immediately)
wallet token mint \
--definition "m/44'/60'/0'/0/4" \
--holder "m/44'/60'/0'/0/7" \
--amount 0
echo "LEE holding initialized for keycard path 7"
# Wait for path2 (LEZ def) and path4 (LEE def) nonces to be confirmed before reusing them
sleep 15
# pub-receiver: public LEZ holding
wallet token mint \
--definition "m/44'/60'/0'/0/2" \
--holder pub-receiver \
--amount 0
echo "LEZ holding initialized for pub-receiver"
# amm-lee-fund: LEE holding (different definition — safe to submit with pub-receiver)
wallet token mint \
--definition "m/44'/60'/0'/0/4" \
--holder amm-lee-fund \
--amount 0
echo "LEE holding initialized for amm-lee-fund"
# Wait for path2 nonce to be confirmed before the third LEZ mint
sleep 15
# amm-lez-fund: LEZ holding
wallet token mint \
--definition "m/44'/60'/0'/0/2" \
--holder amm-lez-fund \
--amount 0
echo "AMM seed holdings initialized"
# =============================================================================
# Fund keycard holdings and AMM seed accounts from supply
# =============================================================================
echo ""
echo "=== Fund keycard holdings and AMM seed accounts ==="
wallet token send \
--from "m/44'/60'/0'/0/3" \
--to "m/44'/60'/0'/0/6" \
--amount 20000
echo "Transferred 20000 LEZ → keycard path 6"
wallet token send \
--from "m/44'/60'/0'/0/5" \
--to "m/44'/60'/0'/0/7" \
--amount 20000
echo "Transferred 20000 LEE → keycard path 7"
# Wait for path3 and path5 nonces to be confirmed before reusing them
sleep 15
wallet token send \
--from "m/44'/60'/0'/0/3" \
--to amm-lez-fund \
--amount 10000
echo "Transferred 10000 LEZ → amm-lez-fund"
wallet token send \
--from "m/44'/60'/0'/0/5" \
--to amm-lee-fund \
--amount 10000
echo "Transferred 10000 LEE → amm-lee-fund"
sleep 15
echo "Keycard path 6 (LEZ holding) state (balance should be 20000):"
wallet account get --account-id "m/44'/60'/0'/0/6"
echo "Keycard path 7 (LEE holding) state (balance should be 20000):"
wallet account get --account-id "m/44'/60'/0'/0/7"
echo "amm-lez-fund state (balance should be 10000):"
wallet account get --account-id amm-lez-fund
echo "amm-lee-fund state (balance should be 10000):"
wallet account get --account-id amm-lee-fund
# =============================================================================
# (3) Token transfer: keycard path 6 (LEZ) → public account
# =============================================================================
echo ""
echo "=== (3) Token transfer: keycard path 6 → pub-receiver (public) ==="
wallet token send \
--from "m/44'/60'/0'/0/6" \
--to pub-receiver \
--amount 1000
echo "Transferred 1000 LEZ: keycard path 6 → pub-receiver"
sleep 15
echo "Keycard path 6 (LEZ) state (balance should be 19000):"
wallet account get --account-id "m/44'/60'/0'/0/6"
echo "pub-receiver state (balance should be 1000):"
wallet account get --account-id pub-receiver
# =============================================================================
# (4) Token transfer: keycard path 6 (LEZ) → private account (shielded)
# =============================================================================
echo ""
echo "=== (4) Token transfer: keycard path 6 → priv-receiver (private, shielded) ==="
PRIV_RECEIVER=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1)
echo "Fresh private receiver account: $PRIV_RECEIVER"
wallet token send \
--from "m/44'/60'/0'/0/6" \
--to "$PRIV_RECEIVER" \
--amount 500
echo "Shielded transfer of 500 LEZ: keycard path 6 → $PRIV_RECEIVER"
wallet account sync-private
sleep 15
echo "Keycard path 6 (LEZ) state (balance should be 18500):"
wallet account get --account-id "m/44'/60'/0'/0/6"
echo "priv-receiver state (balance should be 500):"
wallet account get --account-id "$PRIV_RECEIVER"
# =============================================================================
# (5) Token transfer: private account → keycard path 6 (deshielded)
# Uses priv-receiver from test (4) which holds 500 LEZ.
# The private sender is handled by the ZK circuit; the keycard recipient
# does not sign — resolve() derives its account ID from the card only.
# =============================================================================
echo ""
echo "=== (5) Token transfer: priv-receiver (private) → keycard path 6 (deshielded) ==="
wallet token send \
--from "$PRIV_RECEIVER" \
--to "m/44'/60'/0'/0/6" \
--amount 300
echo "Deshielded transfer of 300 LEZ: $PRIV_RECEIVER → keycard path 6"
wallet account sync-private
sleep 15
echo "priv-receiver state (balance should be 200):"
wallet account get --account-id "$PRIV_RECEIVER"
echo "Keycard path 6 (LEZ) state (balance should be 18800):"
wallet account get --account-id "m/44'/60'/0'/0/6"
# =============================================================================
# (6) Token mint with keycard — definition signed by keycard path 2
# =============================================================================
echo ""
echo "=== (6) Token mint: keycard def path 2 mints 2000 LEZ to keycard path 6 ==="
wallet token mint \
--definition "m/44'/60'/0'/0/2" \
--holder "m/44'/60'/0'/0/6" \
--amount 2000
echo "Minted 2000 LEZ to keycard path 6"
sleep 15
echo "Keycard path 2 (LEZ definition) state (total supply should have increased):"
wallet account get --account-id "m/44'/60'/0'/0/2"
echo "Keycard path 6 (LEZ holding) state (balance should be 20800):"
wallet account get --account-id "m/44'/60'/0'/0/6"
# =============================================================================
# (7) Token burn with keycard — holder is keycard path 6
# =============================================================================
echo ""
echo "=== (7) Token burn: keycard path 6 burns 500 LEZ ==="
wallet token burn \
--definition "Public/$LEZ_DEF_ID" \
--holder "m/44'/60'/0'/0/6" \
--amount 500
echo "Burned 500 LEZ from keycard path 6"
sleep 15
echo "Keycard path 2 (LEZ definition) state (total supply should reflect burn):"
wallet account get --account-id "m/44'/60'/0'/0/2"
echo "Keycard path 6 (LEZ holding) state (balance should be 20300):"
wallet account get --account-id "m/44'/60'/0'/0/6"
# =============================================================================
# (8) Create AMM pool for LEZ/LEE — without keycard
# =============================================================================
echo ""
echo "=== (8) Create AMM pool for LEZ/LEE (without keycard) ==="
wallet amm new \
--user-holding-a amm-lez-fund \
--user-holding-b amm-lee-fund \
--user-holding-lp amm-lp-fund \
--balance-a 10000 \
--balance-b 10000
echo "AMM pool created for LEZ/LEE"
sleep 15
echo "amm-lez-fund state (balance should be 0 — contributed to pool):"
wallet account get --account-id amm-lez-fund
echo "amm-lee-fund state (balance should be 0 — contributed to pool):"
wallet account get --account-id amm-lee-fund
echo "Initial LP holding state (should hold initial LP tokens):"
wallet account get --account-id amm-lp-fund
LP_DEF_ID=$(wallet account get --account-id amm-lp-fund | grep -o '"definition_id":"[^"]*"' | awk -F'"' '{print $4}')
echo "LP token definition ID: $LP_DEF_ID"
# =============================================================================
# (9) Swap tokens owned by keycard accounts
# keycard path 7 (LEE) sells 500 LEE; keycard path 6 (LEZ) receives LEZ
# =============================================================================
echo ""
echo "=== (9) Swap: keycard path 7 sells 500 LEE, keycard path 6 receives LEZ ==="
wallet amm swap-exact-input \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--amount-in 500 \
--min-amount-out 1 \
--token-definition "$LEE_DEF_ID"
echo "Swap LEE → LEZ complete via keycard"
sleep 15
echo "Keycard path 6 (LEZ holding) state (balance should have increased):"
wallet account get --account-id "m/44'/60'/0'/0/6"
echo "Keycard path 7 (LEE holding) state (balance should have decreased by 500):"
wallet account get --account-id "m/44'/60'/0'/0/7"
# =============================================================================
# (10) Add liquidity — keycard accounts for holding A (path 6), B (path 7), LP (path 8)
# =============================================================================
echo ""
echo "=== (10) Add liquidity (keycard path 6=LEZ, path 7=LEE, path 8=LP) ==="
wallet amm add-liquidity \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--user-holding-lp "m/44'/60'/0'/0/8" \
--max-amount-a 1000 \
--max-amount-b 1000 \
--min-amount-lp 1
echo "Add liquidity complete via keycard"
sleep 15
echo "Keycard path 6 (LEZ holding) state (balance should have decreased):"
wallet account get --account-id "m/44'/60'/0'/0/6"
echo "Keycard path 7 (LEE holding) state (balance should have decreased):"
wallet account get --account-id "m/44'/60'/0'/0/7"
echo "Keycard path 8 (LP holding) state (should have received LP tokens):"
wallet account get --account-id "m/44'/60'/0'/0/8"
# =============================================================================
# (11) Remove liquidity — keycard accounts for holding A (path 6), B (path 7), LP (path 8)
# =============================================================================
echo ""
echo "=== (11) Remove liquidity (keycard path 6=LEZ, path 7=LEE, path 8=LP) ==="
wallet amm remove-liquidity \
--user-holding-a "m/44'/60'/0'/0/6" \
--user-holding-b "m/44'/60'/0'/0/7" \
--user-holding-lp "m/44'/60'/0'/0/8" \
--balance-lp 500 \
--min-amount-a 1 \
--min-amount-b 1
echo "Remove liquidity complete via keycard"
sleep 15
echo "Keycard path 6 (LEZ holding) state (balance should have increased):"
wallet account get --account-id "m/44'/60'/0'/0/6"
echo "Keycard path 7 (LEE holding) state (balance should have increased):"
wallet account get --account-id "m/44'/60'/0'/0/7"
echo "Keycard path 8 (LP holding) state (balance should have decreased):"
wallet account get --account-id "m/44'/60'/0'/0/8"
# =============================================================================
# (12) ATA create — keycard path 9 as owner for LEZ
# =============================================================================
echo ""
echo "=== (12) ATA create: keycard path 9 as owner, LEZ token ==="
ATA_OWNER_ID=$(wallet account id --account-id "m/44'/60'/0'/0/9")
echo "ATA owner (keycard path 9): $ATA_OWNER_ID"
wallet ata create \
--owner "m/44'/60'/0'/0/9" \
--token-definition "$LEZ_DEF_ID"
echo "ATA created for keycard path 9 / LEZ"
sleep 15
LEZ_ATA_ID=$(wallet ata address --owner "$ATA_OWNER_ID" --token-definition "$LEZ_DEF_ID")
echo "Keycard path 9 LEZ ATA ID: $LEZ_ATA_ID"
echo "ATA state (should be initialized with zero balance):"
wallet account get --account-id "Public/$LEZ_ATA_ID"
# Fund the ATA from LEZ supply (path 3) — setup for tests 12 and 13
wallet token send \
--from "m/44'/60'/0'/0/3" \
--to "Public/$LEZ_ATA_ID" \
--amount 3000
echo "Funded keycard path 9 ATA with 3000 LEZ"
sleep 15
echo "ATA state after funding (balance should be 3000):"
wallet account get --account-id "Public/$LEZ_ATA_ID"
# =============================================================================
# (13) ATA send — keycard path 9's ATA → pub-receiver's ATA
# =============================================================================
echo ""
echo "=== (13) ATA send: keycard path 9's ATA → pub-receiver's ATA ==="
PUB_RECEIVER_ID=$(wallet account id --account-id pub-receiver)
wallet ata create \
--owner "Public/$PUB_RECEIVER_ID" \
--token-definition "$LEZ_DEF_ID"
echo "ATA created for pub-receiver / LEZ"
sleep 15
PUB_RECEIVER_ATA_ID=$(wallet ata address --owner "$PUB_RECEIVER_ID" --token-definition "$LEZ_DEF_ID")
echo "pub-receiver LEZ ATA ID: $PUB_RECEIVER_ATA_ID"
echo "pub-receiver ATA state (should be initialized with zero balance):"
wallet account get --account-id "Public/$PUB_RECEIVER_ATA_ID"
wallet ata send \
--from "m/44'/60'/0'/0/9" \
--token-definition "$LEZ_DEF_ID" \
--to "$PUB_RECEIVER_ATA_ID" \
--amount 500
echo "Sent 500 LEZ: keycard path 9 ATA → pub-receiver ATA"
sleep 15
echo "Keycard path 9 ATA state (balance should be 2500):"
wallet account get --account-id "Public/$LEZ_ATA_ID"
echo "pub-receiver ATA state (balance should be 500):"
wallet account get --account-id "Public/$PUB_RECEIVER_ATA_ID"
# =============================================================================
# (14) ATA burn — keycard path 9's ATA burns 200 LEZ
# =============================================================================
echo ""
echo "=== (14) ATA burn: keycard path 9's ATA burns 200 LEZ ==="
wallet ata burn \
--holder "m/44'/60'/0'/0/9" \
--token-definition "$LEZ_DEF_ID" \
--amount 200
echo "Burned 200 LEZ from keycard path 9 ATA"
sleep 15
echo "Keycard path 9 ATA state (balance should be 2300):"
wallet account get --account-id "Public/$LEZ_ATA_ID"
echo "LEZ definition state (total supply should reflect burn):"
wallet account get --account-id "m/44'/60'/0'/0/2"
echo ""
echo "=== All keycard token + AMM + ATA tests finished ==="

Some files were not shown because too many files have changed in this diff Show More