From f5ea652b86f28997c646dcbb8815a00fe7dc3575 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 16 Jun 2026 11:51:42 -0700 Subject: [PATCH 1/2] feat(examples): add keyless Zcash transparent signer example A keyless Zcash transparent (t1) wallet bound to a Lit Action's CID. The action derives a secp256k1 t1 address from its own identity key, builds and ZIP-243 signs every transaction it sends (BLAKE2b sighash personalized by the consensus branch ID), and only signs single-recipient P2PKH transfers under a code-bound cap with change forced back to itself. Includes the action, a Blockchair-backed client (setup/address/balance/transfer), and a README. Co-Authored-By: Claude Opus 4.8 --- examples/README.md | 1 + examples/zcash-signer/.env.example | 39 ++ examples/zcash-signer/.gitignore | 2 + examples/zcash-signer/README.md | 194 ++++++++++ examples/zcash-signer/action/zcashSigner.js | 392 ++++++++++++++++++++ examples/zcash-signer/package.json | 15 + examples/zcash-signer/scripts/_env.js | 40 ++ examples/zcash-signer/scripts/_lit.js | 66 ++++ examples/zcash-signer/scripts/_zcash.js | 85 +++++ examples/zcash-signer/scripts/address.js | 17 + examples/zcash-signer/scripts/balance.js | 39 ++ examples/zcash-signer/scripts/setup.js | 182 +++++++++ examples/zcash-signer/scripts/transfer.js | 95 +++++ 13 files changed, 1167 insertions(+) create mode 100644 examples/zcash-signer/.env.example create mode 100644 examples/zcash-signer/.gitignore create mode 100644 examples/zcash-signer/README.md create mode 100644 examples/zcash-signer/action/zcashSigner.js create mode 100644 examples/zcash-signer/package.json create mode 100644 examples/zcash-signer/scripts/_env.js create mode 100644 examples/zcash-signer/scripts/_lit.js create mode 100644 examples/zcash-signer/scripts/_zcash.js create mode 100644 examples/zcash-signer/scripts/address.js create mode 100644 examples/zcash-signer/scripts/balance.js create mode 100644 examples/zcash-signer/scripts/setup.js create mode 100644 examples/zcash-signer/scripts/transfer.js diff --git a/examples/README.md b/examples/README.md index c149fc7c..0f7ad238 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,7 @@ gets back a signed result. | [`lit-solver-vault`](./lit-solver-vault) | Policy-gated key custody for intent-system solvers/fillers. Inventory lives in a vault; the only key that releases a fill is a Lit Action that screens recipient binding, notional cap, allowlist, and a kill switch. The bot can request fills but can't drain the vault, and `exit` always recovers inventory without Lit. Ships a zero-dependency mock demo plus a live **Across** testnet relayer that fills a real Sepolia→Base-Sepolia intent. Keyless. | | [`action-bound-wallet`](./action-bound-wallet) | A unique, immutable wallet per user — bound to a Lit Action's code with no PKP to mint or contract to deploy. Stamp the user's address into the action template and the CID-derived key gives each user their own wallet; the action gates withdrawals on the owner's signature. Demo: deposit an ERC-20, then withdraw by authing in; a wrong-user attack is refused. Keyless. | | [`solana-signer`](./solana-signer) | A keyless **Solana** wallet bound to a Lit Action. The action derives an ed25519 Solana keypair from its own CID-bound identity key (the bridge from Lit's secp256k1 identity to Solana's ed25519), inspects the transaction it's asked to sign, and signs only capped `SystemProgram` transfers — fee payer must be its own address. Demo: airdrop devnet SOL, send a transfer, watch an over-cap transfer get refused. Keyless. | +| [`zcash-signer`](./zcash-signer) | A keyless **Zcash** transparent (`t1`) wallet bound to a Lit Action. Zcash transparent addresses are secp256k1/P2PKH — the same curve as Lit's identity key, so no bridge — but signing uses Zcash's **BLAKE2b sighash personalized by the consensus branch ID** (ZIP-243), unlike Bitcoin or EVM. The action builds every output itself (recipient capped, change only ever back to itself), signs, and returns the raw tx. Runs on mainnet (testnet REST infra is dead) via Blockchair; watch an over-cap transfer get refused. Keyless. | | [`dark-pool`](./dark-pool) | Confidential sealed-bid batch auction. Trader-signed orders are submitted encrypted, stored as ciphertext in Postgres, matched blind inside the TEE at a single uniform clearing price, and settled on-chain — order contents never exist in plaintext outside the enclave. The first example to use encryption + confidential state, not just compute + signing. | | [`mpc-signing-ecdsa`](./mpc-signing-ecdsa) | A threshold-ECDSA key split between a Lit Action and the user (DKLs23 MPC in WASM): **2-of-3 by default** — Lit + your hot share + a cold recovery share. Lit literally cannot sign without you co-signing, the full key never exists anywhere, and because you hold 2 of 3 you can always recover without Lit. Output is a standard ECDSA signature any EVM contract verifies with `ecrecover`. | diff --git a/examples/zcash-signer/.env.example b/examples/zcash-signer/.env.example new file mode 100644 index 00000000..138e1a77 --- /dev/null +++ b/examples/zcash-signer/.env.example @@ -0,0 +1,39 @@ +# =========================================================================== +# REQUIRED — fill this in before running `npm run setup` +# =========================================================================== + +# Your ACCOUNT-LEVEL (master) Lit API key — the one issued when you first +# created the account, not a scoped usage key. Setup calls management +# endpoints like /add_group and /add_action that reject scoped keys. +# Get it from https://dashboard.chipotle.litprotocol.com. +LIT_API_KEY= + + +# =========================================================================== +# Defaults — change only if you know why +# =========================================================================== + +LIT_API_BASE=https://api.chipotle.litprotocol.com + +# Optional Blockchair API key. The free tier is rate-limited; set this if the +# demo scripts start getting throttled. Get one at https://blockchair.com/api. +BLOCKCHAIR_API_KEY= + + +# =========================================================================== +# Auto-filled by `npm run setup` — leave blank, the script will populate them +# =========================================================================== + +# The action's Zcash transparent address (t1…, Base58Check), derived from its +# IPFS CID. This is the MAINNET wallet you fund and send from. Real ZEC. +ZCASH_ADDRESS= + +# Scoped usage key with execute permission for this example's group. +# The demo scripts use this for /lit_action. +# Heads up: shown ONCE by the server. Re-running setup mints a fresh key +# and overwrites this line. +LIT_USAGE_API_KEY= + +# Group + action CID — bookkeeping filled in by setup.js. +GROUP_ID= +ACTION_IPFS_CID= diff --git a/examples/zcash-signer/.gitignore b/examples/zcash-signer/.gitignore new file mode 100644 index 00000000..713d5006 --- /dev/null +++ b/examples/zcash-signer/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/examples/zcash-signer/README.md b/examples/zcash-signer/README.md new file mode 100644 index 00000000..add5edfd --- /dev/null +++ b/examples/zcash-signer/README.md @@ -0,0 +1,194 @@ +# Keyless Zcash Wallet + +**A Zcash transparent (`t1…`) wallet that can only ever be operated by one +exact Lit Action — no private key to hold, no PKP to mint, no program to +deploy. The action derives a secp256k1 address from its own CID-bound identity, +builds every transaction it sends, and signs only capped P2PKH transfers.** + +The [`solana-signer`](../solana-signer) example needed a curve bridge because +Solana uses ed25519. Zcash transparent addresses are **secp256k1 / P2PKH** — +the same curve `Lit.Actions.getLitActionPrivateKey()` already returns — so the +identity key *is* the Zcash key, no bridge. What's Zcash-specific, and what this +example teaches, is the **signature hash**: Zcash signs transparent inputs with +a **BLAKE2b-256 digest personalized by the consensus branch ID** (ZIP-243), not +Bitcoin's double-SHA256 and not an EVM keccak hash. + +## The idea + +`Lit.Actions.getLitActionPrivateKey()` returns a 32-byte secp256k1 private key +derived from the action's IPFS CID. A Zcash transparent address is just the +Bitcoin P2PKH construction with Zcash's version bytes: + +```javascript +const priv = hexToBytes((await Lit.Actions.getLitActionPrivateKey()).replace(/^0x/, "")); +const pubkey = secp.getPublicKey(priv, true); // 33-byte compressed +const h160 = ripemd160(sha256(pubkey)); // hash160 +const address = base58CheckEncode(concat([0x1c, 0xb8], h160)); // t1… (mainnet P2PKH) +``` + +The key is derived from the CID and never leaves the Lit TEE, so the wallet is +**bound to the code**: change a byte, the CID changes, the key changes, the +address changes. This exact action is the only thing that can ever sign for +that address. + +The action does **not** trust the caller to tell it what the transaction is. The +client supplies UTXOs and a recipient + amount; the action **builds every +output itself** — the recipient output (capped) plus a change output that can +only return to its own address — then signs. It will only produce a transaction +when: + +- the recipient is a valid mainnet **`t1` P2PKH** address (decoded and + checksum-verified inside the action), +- the amount to the recipient is **at most `MAX_AMOUNT_ZAT`** (0.01 ZEC), +- the fee is **at most `MAX_FEE_ZAT`** (0.0005 ZEC), and +- **change can only go back to the action's own address** — there is no caller + path to add an output. + +Because the action emits the exact bytes the client broadcasts, a caller can't +redirect the change, exceed the cap, or burn an arbitrary fee. Lying about a +UTXO's value or outpoint can't steal funds either: both are committed in the +ZIP-243 sighash, so a lie just makes the broadcast fail. + +``` + client (Blockchair REST) Lit Action (zcashSigner) Zcash mainnet + │ │ │ + │ action:"address" │ derive secp256k1 key from CID │ + ├─────────────────────────────────►│ hash160 + Base58Check │ + │◄─────────────────────────────────┤ t1 address │ + │ fund the t1 address with ZEC ────────────────────────────────────────►│ + │ GET /dashboards/address (UTXOs) ◄──────────────────────────────────────┤ + │ │ │ + │ action:"sign" + inputs + recip │ validate policy; build v4 tx; │ + ├─────────────────────────────────►│ ZIP-243 BLAKE2b sighash per input; │ + │◄─────────────────────────────────┤ secp256k1-sign; return raw tx hex │ + │ POST /push/transaction ──────────────────────────────────────────────►│ +``` + +## ⚠️ This example uses mainnet (real ZEC) + +Zcash **testnet** REST infrastructure is effectively dead — the old +`explorer.testnet.z.cash` Insight API is gone, there is no public testnet +Blockbook, and Blockchair has no Zcash testnet. So unlike the Solana example +(devnet, free funds), this one runs on **mainnet with real ZEC** via +[Blockchair's](https://blockchair.com/api) public REST API. + +The spend cap is baked into the action's (CID-bound) source at **0.01 ZEC**, so +fund the wallet with only a small amount. The cap, the fee cap, and the network +are all part of the hashed code — changing any of them changes the address. + +## Files + +| Path | Purpose | +| --- | --- | +| `action/zcashSigner.js` | The Lit Action. Derives the secp256k1 t1 address from the action's identity key, validates the requested spend, builds the v4 transaction, computes the ZIP-243 BLAKE2b sighash for each input, secp256k1-signs, and returns the raw tx hex. Imports `@noble/secp256k1`, `@noble/hashes` (blake2b/sha256/ripemd160/hmac), `@scure/base` (pinned ESM from jsDelivr). | +| `scripts/_lit.js` | Runs the action against `/lit_action` with the scoped usage key and unwraps the response envelope. | +| `scripts/_zcash.js` | Blockchair REST client: list UTXOs, read the chain tip, broadcast a raw tx. | +| `scripts/_env.js` | Minimal `.env` reader / upserter, inlined so the folder is self-contained. | +| `scripts/setup.js` | One-shot: create the group, mint a scoped usage key, derive + record the t1 address, register the action. No contract to deploy. | +| `scripts/address.js` | Print the action's t1 address (re-derived live). | +| `scripts/balance.js` | Show the wallet's confirmed balance and UTXOs. | +| `scripts/transfer.js` | Select UTXOs, have the action build + sign the transfer, broadcast to mainnet. | + +## Walkthrough + +### 1. Install + configure + +```bash +cp .env.example .env +npm install +``` + +Set in `.env`: +- `LIT_API_KEY` — your **account-level (master) API key** from the + [Chipotle dashboard](https://dashboard.chipotle.litprotocol.com), *not* a + scoped usage key (setup calls `/add_group`, which rejects scoped keys). + +`BLOCKCHAIR_API_KEY` is optional; set it only if the free tier rate-limits you. + +> The scripts use Node's built-in `fetch`, so Node 18+ is required. `npm +> install` has nothing to fetch (the client has no runtime deps) — it just sets +> the folder up. + +### 2. Run setup + +```bash +npm run setup +``` + +Six steps: compute the action CID, create a wildcard permission group, mint a +scoped usage key, **derive the t1 address by running the action's `"address"` +branch**, register the action, and add its CID to the group. The address is +written to `.env` as `ZCASH_ADDRESS`. + +A freshly-minted usage key's group grant is eventually consistent, so the +first action call (step 4) polls with retries until the grant propagates rather +than aborting on a transient miss — you may see a few `...action not ready yet` +lines before it succeeds. + +### 3. Fund the wallet + +```bash +npm run address # print the t1 address +npm run balance # show balance + UTXOs once funds land +``` + +Send a small amount of ZEC (the cap is 0.01 ZEC) to the address from any wallet +or exchange, wait for a confirmation, then `npm run balance` should show it. + +### 4. Send ZEC + +```bash +# Sign + broadcast a 0.001 ZEC transfer to any t1 address: +npm run transfer -- 0.001 +``` + +The client picks UTXOs, the action builds + signs the transaction, and the +client broadcasts it. Try `npm run transfer -- 0.05` to watch the +action **refuse** anything over the 0.01 ZEC cap. + +## Why the binding holds + +- **Code-bound key.** The secp256k1 key is derived from the action's CID and + never leaves the Lit TEE. There is no key file to steal; the only way to + produce a signature for this address is to run this exact code in the network. +- **The signer builds what it signs.** The action constructs every output + itself — recipient (capped) and change (only ever back to itself). A caller + can't smuggle in an extra output, redirect the change, or exceed the caps. +- **Tamper-evident policy.** `MAX_AMOUNT_ZAT`, `MAX_FEE_ZAT`, the network + prefix, and the consensus branch ID are all part of the hashed source, so + changing any of them changes the CID and the wallet address. +- **Lying about inputs only fails the broadcast.** A UTXO's value is committed + in its ZIP-243 sighash and its outpoint must reference a real unspent output, + so a caller that misreports either produces a transaction the network simply + rejects — it can't redirect funds. + +## Production notes + +- **Per-user wallets.** Like the [`action-bound-wallet`](../action-bound-wallet) + example, you can give each user their own Zcash wallet by stamping a value + (e.g. their id) into the action source — a different CID yields a different + key and address. Gate spending on the user's own signature recovered inside + the action. +- **Consensus branch ID is a maintenance constant.** The sighash is + personalized with the active network upgrade's branch ID (NU6, + `0xc8e71055`, here). It's the same on mainnet and testnet and only changes at + a network upgrade — bump `BRANCH_ID_LE` in the action when the next upgrade + activates. (The CID/address won't change from a branch-ID bump alone only if + you don't edit the file; editing it *does* change the address, so plan the + rotation.) +- **v4 transactions.** This builds version-4 (Sapling, ZIP-243) transparent + transactions, which are simpler to get byte-exact and are still valid on the + current network. [ZIP-2003](https://zips.z.cash/zip-2003) proposes disallowing + v4 at a future NU7 (no activation date as of writing); migrating to v5 + (ZIP-244) means swapping the flat sighash preimage for ZIP-244's BLAKE2b hash + tree. +- **Transparent only.** Shielded (Sapling/Orchard) addresses need zk-SNARK + proving and are out of scope for a single Lit Action; this example handles + transparent `t1` P2PKH only. +- **Widen the policy deliberately.** This action signs single-recipient + transfers with change back to self. Multi-recipient sends, P2SH, or other + scripts mean extending the builder and policy — and each change mints a new + CID and a new wallet. +- **Verify before mainnet volume.** Cross-check the sighash against the + official [zcash-test-vectors](https://github.com/zcash-hackworks/zcash-test-vectors) + if you adapt this for anything beyond small demo amounts. diff --git a/examples/zcash-signer/action/zcashSigner.js b/examples/zcash-signer/action/zcashSigner.js new file mode 100644 index 00000000..210790c3 --- /dev/null +++ b/examples/zcash-signer/action/zcashSigner.js @@ -0,0 +1,392 @@ +// Lit Action: a keyless Zcash (transparent) wallet bound to this exact code. +// +// The wallet's secp256k1 keypair is the action's own identity key +// (Lit.Actions.getLitActionPrivateKey(), itself derived from the action's +// IPFS CID). Zcash transparent ("t1…") addresses are secp256k1/P2PKH — the +// SAME curve Lit already gives you — so unlike the Solana example there is no +// ed25519 bridge: the identity key IS the Zcash key. What's Zcash-specific is +// the ADDRESS ENCODING (Base58Check with a two-byte version prefix) and, above +// all, the SIGNATURE HASH: Zcash signs transparent inputs with a BLAKE2b-256 +// digest personalized by the consensus branch ID (ZIP-243), not Bitcoin's +// double-SHA256 and not an EVM keccak hash. That sighash is what this example +// teaches. +// +// The key is derived from the CID and never leaves the Lit TEE, so the wallet +// is bound to the code: edit a byte and the CID changes, the identity key +// changes, and the t1 address changes. This exact action is the only thing +// that can ever sign for that address — no key file to steal, no PKP to mint. +// +// Two operations, selected by `action`: +// action: "address" -> return the action's Zcash t1 address (Base58Check) +// action: "sign" -> validate a requested transparent spend against +// policy, build the v4 transaction itself, ZIP-243 +// sign every input, and return the raw tx hex +// +// The signer does NOT trust the client to tell it what the transaction is. The +// client supplies UTXOs (txid/vout/value) and a recipient+amount; the action +// builds every output itself — the recipient output (capped) plus a change +// output that can ONLY go back to its own address — computes the sighash for +// each input from those bytes, signs, and emits the exact hex to broadcast. A +// caller cannot redirect the change, exceed the spend cap, or burn an +// arbitrary fee. (Lying about an input's value or outpoint can't steal funds +// either: the value is committed in the ZIP-243 sighash and the outpoint must +// reference a real UTXO, so a lie just makes the broadcast fail.) +// +// js_params: +// action "address" | "sign" +// inputs (sign) [{ txid: hex(big-endian, as shown in explorers), +// vout: number, value: string(zatoshi) }] +// recipient (sign) destination t1 address (Base58Check) +// amountZat (sign) string, zatoshi to send to the recipient +// feeZat (sign) string, zatoshi miner fee +// expiryHeight (sign) number, nExpiryHeight (block height the tx expires at) +// +// Imports are pinned ESM from jsDelivr (see docs/lit-actions/imports.mdx). +// @noble/* and @scure/* are pure ESM with no Node built-ins, so they run as-is +// inside the action runtime. +import * as secp from "@noble/secp256k1@2.1.0"; +import { blake2b } from "@noble/hashes@1.4.0/blake2b/+esm"; +import { sha256 } from "@noble/hashes@1.4.0/sha256/+esm"; +import { ripemd160 } from "@noble/hashes@1.4.0/ripemd160/+esm"; +import { hmac } from "@noble/hashes@1.4.0/hmac/+esm"; +import { base58 } from "@scure/base@1.1.6"; + +// @noble/secp256k1 v2 needs an hmac-sha256 wired in for its sync signing API +// (RFC 6979 deterministic nonces). +secp.etc.hmacSha256Sync = (key, ...msgs) => + hmac(sha256, key, secp.etc.concatBytes(...msgs)); + +const { hexToBytes, bytesToHex, concatBytes } = secp.etc; + +// --------------------------------------------------------------------------- +// Policy + network constants. All of this is part of the hashed source, so +// changing any of it changes the CID and therefore the wallet address — the +// caps are bound to the address exactly like the key is. +// --------------------------------------------------------------------------- + +// Mainnet P2PKH ("t1…") Base58Check version prefix. (Testnet "tm…" would be +// 0x1D,0x25 — but Zcash testnet REST infrastructure is effectively dead, so +// this example targets mainnet. See the README.) +const P2PKH_PREFIX = new Uint8Array([0x1c, 0xb8]); + +// Consensus branch ID for the active network upgrade, little-endian. This is +// NU6 (0xc8e71055). It is the same on mainnet and testnet and only changes at +// a network upgrade — bump it (and re-derive: the CID won't change, only the +// signatures) when the next upgrade activates. +const BRANCH_ID_LE = new Uint8Array([0x55, 0x10, 0xe7, 0xc8]); + +// The most this wallet will ever pay a recipient in one transaction: 0.01 ZEC. +const MAX_AMOUNT_ZAT = 1_000_000n; +// The most it will ever burn as a miner fee: 0.0005 ZEC. Caps the only value +// a caller could otherwise leak by over-funding the fee. +const MAX_FEE_ZAT = 50_000n; +// Below this, a change output isn't worth creating; fold it into the fee. +const DUST_ZAT = 1_000n; + +// v4 (Sapling) transaction header: fOverwintered bit (31) set | version 4. +const HEADER = u32le(0x80000004); +// SAPLING_VERSION_GROUP_ID. +const VERSION_GROUP_ID = u32le(0x892f2085); +// SIGHASH_ALL. +const SIGHASH_ALL = 0x01; +const N_SEQUENCE = 0xffffffff; +const SEQUENCE_BYTES = u32le(N_SEQUENCE); +const ZERO32 = new Uint8Array(32); + +async function main({ action, inputs, recipient, amountZat, feeZat, expiryHeight }) { + // The identity key is a 32-byte secp256k1 private key — already exactly a + // Zcash transparent private key. No curve bridge needed. + const priv = hexToBytes( + (await Lit.Actions.getLitActionPrivateKey()).replace(/^0x/, "") + ); + const pubkey = secp.getPublicKey(priv, true); // 33-byte compressed + const selfHash160 = hash160(pubkey); + const address = base58CheckEncode(concatBytes(P2PKH_PREFIX, selfHash160)); + + if (action === "address") { + return { address }; + } + if (action !== "sign") { + return { authorized: false, reason: `unknown action "${action}"` }; + } + + // ---- Validate the requested spend against policy ------------------------ + if (!Array.isArray(inputs) || inputs.length === 0) { + return { authorized: false, reason: "no inputs provided" }; + } + if (!Number.isInteger(expiryHeight) || expiryHeight <= 0) { + return { authorized: false, reason: "expiryHeight must be a positive integer" }; + } + + let amount, fee, totalIn; + try { + amount = BigInt(amountZat); + fee = BigInt(feeZat); + totalIn = inputs.reduce((sum, i) => sum + BigInt(i.value), 0n); + } catch { + return { authorized: false, reason: "amount/fee/value must be integer zatoshi strings" }; + } + + if (amount <= 0n) { + return { authorized: false, reason: "amount must be positive" }; + } + if (amount > MAX_AMOUNT_ZAT) { + return { + authorized: false, + reason: `amount ${amount} zat exceeds cap ${MAX_AMOUNT_ZAT}`, + }; + } + if (fee < 0n || fee > MAX_FEE_ZAT) { + return { + authorized: false, + reason: `fee ${fee} zat outside [0, ${MAX_FEE_ZAT}]`, + }; + } + + // The recipient must be a mainnet t1 P2PKH address. Decode it ourselves so a + // typo or wrong-network address is refused rather than signed. + let recipientHash160; + try { + const payload = base58CheckDecode(recipient); + if (payload[0] !== P2PKH_PREFIX[0] || payload[1] !== P2PKH_PREFIX[1]) { + return { authorized: false, reason: "recipient is not a mainnet t1 P2PKH address" }; + } + recipientHash160 = payload.slice(2); + if (recipientHash160.length !== 20) { + return { authorized: false, reason: "recipient hash160 is not 20 bytes" }; + } + } catch (e) { + return { authorized: false, reason: `recipient is not valid Base58Check: ${e.message}` }; + } + + // change = totalIn - amount - fee, and it can ONLY ever go back to us. + let change = totalIn - amount - fee; + if (change < 0n) { + return { + authorized: false, + reason: `inputs (${totalIn} zat) do not cover amount + fee (${amount + fee} zat)`, + }; + } + // Sub-dust change isn't worth a 34-byte output; fold it into the fee — but + // only if that keeps the fee under the cap, so this can't become a leak. + if (change > 0n && change < DUST_ZAT) { + fee += change; + change = 0n; + if (fee > MAX_FEE_ZAT) { + return { authorized: false, reason: "dust change would push the fee over the cap; adjust inputs" }; + } + } + + // The action builds every output. There is no path for the caller to add an + // output of its own. + const outputs = [{ script: p2pkhScript(recipientHash160), value: amount }]; + if (change > 0n) { + outputs.push({ script: p2pkhScript(selfHash160), value: change }); + } + + // ---- Build the canonical pieces shared by every input's sighash --------- + const selfScript = p2pkhScript(selfHash160); // scriptCode — all inputs are ours + + const hashPrevouts = blake2b16( + concatBytes(...inputs.map((i) => outpoint(i.txid, i.vout))), + pers("ZcashPrevoutHash") + ); + const hashSequence = blake2b16( + concatBytes(...inputs.map(() => SEQUENCE_BYTES)), + pers("ZcashSequencHash") + ); + const hashOutputs = blake2b16( + concatBytes(...outputs.map((o) => concatBytes(u64le(o.value), withLen(o.script)))), + pers("ZcashOutputsHash") + ); + + const expiryBytes = u32le(expiryHeight); + const preamble = concatBytes( + HEADER, + VERSION_GROUP_ID, + hashPrevouts, + hashSequence, + hashOutputs, + ZERO32, // hashJoinSplits — none + ZERO32, // hashShieldedSpends — none + ZERO32, // hashShieldedOutputs — none + u32le(0), // nLockTime + expiryBytes, // nExpiryHeight + u64le(0n), // valueBalance (Sapling net value) — 0 + u32le(SIGHASH_ALL) // nHashType + ); + + // ---- Sign each input over its ZIP-243 sighash and build its scriptSig ---- + const scriptSigs = inputs.map((i) => { + const preimage = concatBytes( + preamble, + outpoint(i.txid, i.vout), + withLen(selfScript), // scriptCode of the output being spent (ours) + u64le(BigInt(i.value)), // amount of that output — committed in the sighash + SEQUENCE_BYTES + ); + const sighash = blake2b16(preimage, pers("ZcashSigHash", BRANCH_ID_LE)); + // ECDSA over the 32-byte digest directly. @noble/secp256k1 v2 signs low-S + // (canonical) by default but only emits a 64-byte compact r||s — Zcash + // scriptSigs need DER, so we encode it ourselves. + const compact = secp.sign(sighash, priv).toCompactRawBytes(); + const sig = concatBytes(derEncode(compact), new Uint8Array([SIGHASH_ALL])); + return concatBytes(pushData(sig), pushData(pubkey)); + }); + + // ---- Assemble the final v4 transaction ---------------------------------- + const tx = concatBytes( + HEADER, + VERSION_GROUP_ID, + compactSize(inputs.length), + ...inputs.map((i, n) => + concatBytes(outpoint(i.txid, i.vout), withLen(scriptSigs[n]), SEQUENCE_BYTES) + ), + compactSize(outputs.length), + ...outputs.map((o) => concatBytes(u64le(o.value), withLen(o.script))), + u32le(0), // nLockTime + expiryBytes, // nExpiryHeight + u64le(0n), // valueBalance + compactSize(0), // nShieldedSpend + compactSize(0), // nShieldedOutput + compactSize(0) // nJoinSplit + ); + + // v4 txid is the double-SHA256 of the whole serialization, shown reversed. + const txid = bytesToHex(sha256(sha256(tx)).reverse()); + + return { + authorized: true, + address, + recipient, + amountZat: amount.toString(), + feeZat: fee.toString(), + changeZat: change.toString(), + txid, + txHex: bytesToHex(tx), + }; +} + +// --------------------------------------------------------------------------- +// Zcash / Bitcoin-style serialization helpers. +// --------------------------------------------------------------------------- + +// An outpoint is the spent txid in INTERNAL (little-endian) byte order — the +// reverse of how explorers display it — followed by the 4-byte output index. +function outpoint(txidHexBigEndian, vout) { + return concatBytes(hexToBytes(txidHexBigEndian).reverse(), u32le(vout)); +} + +// scriptPubKey / scriptCode for a P2PKH output: +// OP_DUP OP_HASH160 <20-byte hash160> OP_EQUALVERIFY OP_CHECKSIG +function p2pkhScript(hash160Bytes) { + return concatBytes( + new Uint8Array([0x76, 0xa9, 0x14]), + hash160Bytes, + new Uint8Array([0x88, 0xac]) + ); +} + +function hash160(bytes) { + return ripemd160(sha256(bytes)); +} + +// DER-encode a 64-byte compact (r || s) ECDSA signature: +// 0x30 0x02 0x02 +// r and s are minimal big-endian: leading zero bytes stripped, with a single +// 0x00 prepended if the high bit is set (so they stay positive). s is already +// low-S because @noble signs canonically — we only re-encode it. +function derEncode(compact) { + const r = derInt(compact.slice(0, 32)); + const s = derInt(compact.slice(32, 64)); + const seqLen = 2 + r.length + 2 + s.length; + return concatBytes( + new Uint8Array([0x30, seqLen, 0x02, r.length]), + r, + new Uint8Array([0x02, s.length]), + s + ); +} + +function derInt(bytes) { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + let v = bytes.slice(i); + if (v[0] & 0x80) v = concatBytes(new Uint8Array([0x00]), v); + return v; +} + +// BLAKE2b-256 with a 16-byte personalization (Zcash's domain separation). +function blake2b16(data, personalization) { + return blake2b(data, { dkLen: 32, personalization }); +} + +// Build a 16-byte BLAKE2b personalization from an ASCII tag (+ optional suffix +// bytes, e.g. the consensus branch ID for the signature hash). +function pers(tag, suffix) { + const base = new Uint8Array(tag.length); + for (let i = 0; i < tag.length; i++) base[i] = tag.charCodeAt(i); + return suffix ? concatBytes(base, suffix) : base; +} + +// Prefix a byte string with its CompactSize ("varint") length. +function withLen(bytes) { + return concatBytes(compactSize(bytes.length), bytes); +} + +// Bitcoin script push of <= 520 bytes of data (we only ever push <= 256). +function pushData(bytes) { + if (bytes.length <= 75) { + return concatBytes(new Uint8Array([bytes.length]), bytes); + } + if (bytes.length <= 255) { + return concatBytes(new Uint8Array([0x4c, bytes.length]), bytes); + } + throw new Error("pushData operand too large for this example"); +} + +function compactSize(n) { + if (n < 0xfd) return new Uint8Array([n]); + if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]); + if (n <= 0xffffffff) return concatBytes(new Uint8Array([0xfe]), u32le(n)); + return concatBytes(new Uint8Array([0xff]), u64le(BigInt(n))); +} + +function u32le(n) { + const b = new Uint8Array(4); + b[0] = n & 0xff; + b[1] = (n >>> 8) & 0xff; + b[2] = (n >>> 16) & 0xff; + b[3] = (n >>> 24) & 0xff; + return b; +} + +function u64le(big) { + const b = new Uint8Array(8); + let v = BigInt(big); + for (let i = 0; i < 8; i++) { + b[i] = Number(v & 0xffn); + v >>= 8n; + } + return b; +} + +// --------------------------------------------------------------------------- +// Base58Check (version-prefixed payload + 4-byte double-SHA256 checksum). +// --------------------------------------------------------------------------- +function base58CheckEncode(payload) { + const checksum = sha256(sha256(payload)).slice(0, 4); + return base58.encode(concatBytes(payload, checksum)); +} + +function base58CheckDecode(str) { + const raw = base58.decode(str); + if (raw.length < 5) throw new Error("too short"); + const payload = raw.slice(0, raw.length - 4); + const checksum = raw.slice(raw.length - 4); + const expected = sha256(sha256(payload)).slice(0, 4); + for (let i = 0; i < 4; i++) { + if (checksum[i] !== expected[i]) throw new Error("checksum mismatch"); + } + return payload; +} diff --git a/examples/zcash-signer/package.json b/examples/zcash-signer/package.json new file mode 100644 index 00000000..14693cdd --- /dev/null +++ b/examples/zcash-signer/package.json @@ -0,0 +1,15 @@ +{ + "name": "zcash-signer", + "version": "0.0.1", + "private": true, + "description": "Lit Action example: a keyless Zcash transparent wallet bound to the action's CID. The action derives a secp256k1 t1 address from its own identity key, builds + ZIP-243-signs the transaction it's asked to send, and signs only capped P2PKH transfers on mainnet.", + "scripts": { + "setup": "node scripts/setup.js", + "address": "node scripts/address.js", + "balance": "node scripts/balance.js", + "transfer": "node scripts/transfer.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/zcash-signer/scripts/_env.js b/examples/zcash-signer/scripts/_env.js new file mode 100644 index 00000000..10d2d456 --- /dev/null +++ b/examples/zcash-signer/scripts/_env.js @@ -0,0 +1,40 @@ +// Minimal .env reader / upserter shared across the zcash-signer scripts. +// +// Kept inline so this example folder is self-contained and you can clone it +// without copying siblings. + +const fs = require("fs"); +const path = require("path"); + +const ENV_PATH = path.join(__dirname, "..", ".env"); + +function load() { + if (!fs.existsSync(ENV_PATH)) return; + for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m) continue; + // Command-line / pre-existing env wins so callers can override. + if (!process.env[m[1]]) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); + } + } +} + +function upsert(key, value) { + const text = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8") : ""; + const line = `${key}=${value}`; + const re = new RegExp(`^${key}\\s*=.*$`, "m"); + let next; + if (re.test(text)) { + next = text.replace(re, line); + } else { + next = + text.endsWith("\n") || text.length === 0 + ? text + line + "\n" + : text + "\n" + line + "\n"; + } + fs.writeFileSync(ENV_PATH, next); + process.env[key] = value; +} + +module.exports = { load, upsert }; diff --git a/examples/zcash-signer/scripts/_lit.js b/examples/zcash-signer/scripts/_lit.js new file mode 100644 index 00000000..7c17bf37 --- /dev/null +++ b/examples/zcash-signer/scripts/_lit.js @@ -0,0 +1,66 @@ +// Run the zcashSigner action against the Lit API with the scoped usage key, +// and unwrap the /lit_action envelope into the action's own return value. +// +// /lit_action wraps the action's return as +// { response: , logs: "...", has_error: bool } + +const fs = require("fs"); +const path = require("path"); + +const ACTION_FILE = path.join(__dirname, "..", "action", "zcashSigner.js"); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function runActionOnce(jsParams) { + const { + LIT_API_BASE = "https://api.chipotle.litprotocol.com", + LIT_USAGE_API_KEY, + } = process.env; + if (!LIT_USAGE_API_KEY) { + throw new Error("LIT_USAGE_API_KEY is required (run `npm run setup` first)"); + } + + const code = fs.readFileSync(ACTION_FILE, "utf8"); + const res = await fetch(`${LIT_API_BASE}/core/v1/lit_action`, { + method: "POST", + headers: { + "X-Api-Key": LIT_USAGE_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ code, js_params: jsParams }), + }); + const envelope = await res.json(); + if (!res.ok) { + throw new Error(`lit_action -> ${res.status}: ${JSON.stringify(envelope)}`); + } + if (envelope.has_error) { + throw new Error(`action errored: ${envelope.logs || JSON.stringify(envelope)}`); + } + return envelope.response; +} + +// Run the action, retrying on failure. A freshly-minted usage key's +// execute-in-group grant is eventually consistent, so the *first* call right +// after `add_usage_api_key` can fail for a beat while the grant propagates. +// The docs say not to sleep a fixed amount but to poll the real execution path +// until it succeeds — so `setup.js` calls this with retries on its first run. +// Steady-state callers (address/balance/transfer) leave retries at 0. +async function runAction(jsParams, { retries = 0, delayMs = 2500 } = {}) { + let lastErr; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await runActionOnce(jsParams); + } catch (err) { + lastErr = err; + if (attempt < retries) { + console.log( + ` ...action not ready yet (attempt ${attempt + 1}/${retries + 1}), retrying in ${delayMs}ms` + ); + await sleep(delayMs); + } + } + } + throw lastErr; +} + +module.exports = { runAction, ACTION_FILE }; diff --git a/examples/zcash-signer/scripts/_zcash.js b/examples/zcash-signer/scripts/_zcash.js new file mode 100644 index 00000000..4667ac62 --- /dev/null +++ b/examples/zcash-signer/scripts/_zcash.js @@ -0,0 +1,85 @@ +// Thin client for the Blockchair public REST API (Zcash mainnet): list a +// transparent address's UTXOs, read the chain tip, and broadcast a raw tx. +// +// Why Blockchair: Zcash *testnet* REST infrastructure is effectively dead +// (the old explorer.testnet.z.cash Insight API is gone, there is no public +// testnet Blockbook, and Blockchair has no testnet), so this example targets +// MAINNET, where Blockchair's REST API is the dependable public option. +// +// The free tier is rate-limited; if you hit limits, set BLOCKCHAIR_API_KEY in +// .env and it'll be appended to every request. + +const BASE = "https://api.blockchair.com/zcash"; +const ZAT_PER_ZEC = 100_000_000; + +function withKey(url) { + const key = process.env.BLOCKCHAIR_API_KEY; + if (!key) return url; + return url + (url.includes("?") ? "&" : "?") + `key=${key}`; +} + +async function getJson(url) { + const res = await fetch(withKey(url)); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = body && body.context && body.context.error; + throw new Error(`${url} -> ${res.status}${msg ? `: ${msg}` : ""}`); + } + return body; +} + +// Unspent outputs for a transparent address. Each entry: { txid, vout, value } +// where value is an integer string in zatoshi (matching the action's interface). +async function getUtxos(address) { + const body = await getJson(`${BASE}/dashboards/address/${address}?limit=100`); + const entry = body.data && body.data[address]; + if (!entry) throw new Error(`no dashboard data for ${address}`); + return (entry.utxo || []).map((u) => ({ + txid: u.transaction_hash, + vout: u.index, + value: String(u.value), // Blockchair returns Zcash values in zatoshi + })); +} + +// Confirmed balance (zatoshi, as a number) for an address. +async function getBalance(address) { + const body = await getJson(`${BASE}/dashboards/address/${address}`); + const entry = body.data && body.data[address]; + return entry && entry.address ? Number(entry.address.balance || 0) : 0; +} + +// Current best block height — used to set a sane nExpiryHeight. +async function getTipHeight() { + const body = await getJson(`${BASE}/stats`); + const h = body.data && (body.data.best_block_height ?? body.data.blocks); + if (!h) throw new Error("could not read tip height from /stats"); + return Number(h); +} + +// Broadcast a raw (hex) transaction. Returns the txid on success. +async function broadcast(txHex) { + const res = await fetch(withKey(`${BASE}/push/transaction`), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ data: txHex }).toString(), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || !body.data || !body.data.transaction_hash) { + const msg = (body.context && body.context.error) || JSON.stringify(body); + throw new Error(`broadcast failed (${res.status}): ${msg}`); + } + return body.data.transaction_hash; +} + +const zatToZec = (zat) => Number(zat) / ZAT_PER_ZEC; +const zecToZat = (zec) => Math.round(Number(zec) * ZAT_PER_ZEC); + +module.exports = { + getUtxos, + getBalance, + getTipHeight, + broadcast, + zatToZec, + zecToZat, + ZAT_PER_ZEC, +}; diff --git a/examples/zcash-signer/scripts/address.js b/examples/zcash-signer/scripts/address.js new file mode 100644 index 00000000..33e9497f --- /dev/null +++ b/examples/zcash-signer/scripts/address.js @@ -0,0 +1,17 @@ +// Print the action's Zcash t1 address (re-derived live by the action). +// +// Usage: npm run address + +const env = require("./_env"); +const { runAction } = require("./_lit"); +env.load(); + +async function main() { + const { address } = await runAction({ action: "address" }); + console.log(address); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/examples/zcash-signer/scripts/balance.js b/examples/zcash-signer/scripts/balance.js new file mode 100644 index 00000000..297915a1 --- /dev/null +++ b/examples/zcash-signer/scripts/balance.js @@ -0,0 +1,39 @@ +// Show the action wallet's confirmed balance and unspent outputs on Zcash +// mainnet (via Blockchair). +// +// There is no testnet faucet path here (Zcash testnet REST infra is dead, so +// this example runs on mainnet) — fund the wallet by sending a little ZEC to +// the address from any wallet or exchange, then run this to confirm it landed. +// +// Usage: npm run balance + +const env = require("./_env"); +const { runAction } = require("./_lit"); +const { getUtxos, getBalance, zatToZec } = require("./_zcash"); +env.load(); + +async function main() { + const address = + process.env.ZCASH_ADDRESS || (await runAction({ action: "address" })).address; + + const [balance, utxos] = await Promise.all([ + getBalance(address), + getUtxos(address), + ]); + + console.log(`address: ${address}`); + console.log(`balance: ${zatToZec(balance)} ZEC (${balance} zat)`); + console.log(`utxos: ${utxos.length}`); + for (const u of utxos) { + console.log(` - ${u.txid}:${u.vout} ${zatToZec(u.value)} ZEC`); + } + if (utxos.length === 0) { + console.log("\nNo funds yet. Send a little ZEC to the address above, wait for"); + console.log("a confirmation, then re-run `npm run balance`."); + } +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/examples/zcash-signer/scripts/setup.js b/examples/zcash-signer/scripts/setup.js new file mode 100644 index 00000000..4ce86e4c --- /dev/null +++ b/examples/zcash-signer/scripts/setup.js @@ -0,0 +1,182 @@ +// One-shot setup for the zcash-signer example. +// +// What you provide (in .env before running): +// LIT_API_KEY Account-level (master) Lit API key +// +// What this script does, in order: +// 1. Compute the action's IPFS CID +// 2. Create a permission group (wildcard action allowlist) +// 3. Create a scoped usage API key with execute_in_groups: [groupId] +// 4. Derive the action's Zcash t1 address (runs the action's "address" branch) +// 5. Register the action with the account (metadata) +// 6. Add the specific action CID to the group (audit trail) +// +// There is no contract to deploy: the Zcash t1 address IS the wallet, derived +// from the action's CID. Fund it with a little ZEC (`npm run address` to get +// the address, `npm run balance` to check it), then send (`npm run transfer`). +// +// Re-running this script does a fresh setup top-to-bottom: every step creates +// new state and overwrites the corresponding key in .env. The previously +// minted group / usage key become orphaned. That's fine for a docs example. + +const fs = require("fs"); +const env = require("./_env"); +const { runAction, ACTION_FILE } = require("./_lit"); + +async function main() { + env.load(); + + const { + LIT_API_BASE = "https://api.chipotle.litprotocol.com", + LIT_API_KEY, + } = process.env; + + if (!LIT_API_KEY) { + throw new Error( + "LIT_API_KEY is required in .env. Copy .env.example to .env and fill it in." + ); + } + + const actionCode = fs.readFileSync(ACTION_FILE, "utf8"); + + // ------------------------------------------------------------------------- + console.log("Step 1/6: Computing action CID..."); + const actionCid = await getActionCid(LIT_API_BASE, LIT_API_KEY, actionCode); + env.upsert("ACTION_IPFS_CID", actionCid); + console.log(` ACTION_IPFS_CID=${actionCid}`); + + // ------------------------------------------------------------------------- + console.log("Step 2/6: Creating group (wildcard action allowlist)..."); + const groupId = await addGroup(LIT_API_BASE, LIT_API_KEY); + env.upsert("GROUP_ID", String(groupId)); + console.log(` GROUP_ID=${groupId}`); + + // ------------------------------------------------------------------------- + console.log("Step 3/6: Creating scoped usage API key..."); + const usageKey = await createUsageApiKey(LIT_API_BASE, LIT_API_KEY, groupId); + env.upsert("LIT_USAGE_API_KEY", usageKey); + console.log( + ` LIT_USAGE_API_KEY=${usageKey.slice(0, 12)}... (full key written to .env)` + ); + + // ------------------------------------------------------------------------- + console.log("Step 4/6: Deriving the action's Zcash t1 address..."); + // Use the scoped key we just minted to run the action's "address" branch. + // This is the first use of a brand-new usage key, whose execute-in-group + // grant is eventually consistent — so poll with retries until it propagates + // rather than aborting setup (and half-populating .env) on a transient miss. + const { address } = await runAction( + { action: "address" }, + { retries: 10, delayMs: 3000 } + ); + if (!address) throw new Error("action did not return an address"); + env.upsert("ZCASH_ADDRESS", address); + console.log(` ZCASH_ADDRESS=${address}`); + + // ------------------------------------------------------------------------- + console.log("Step 5/6: Registering action with account..."); + await addAction(LIT_API_BASE, LIT_API_KEY, actionCid); + + // ------------------------------------------------------------------------- + console.log("Step 6/6: Adding action to group..."); + await addActionToGroup(LIT_API_BASE, LIT_API_KEY, groupId, actionCid); + + // ------------------------------------------------------------------------- + console.log("\n✓ Setup complete.\n"); + console.log(" Action CID: ", process.env.ACTION_IPFS_CID); + console.log(" Zcash address: ", process.env.ZCASH_ADDRESS); + console.log(" Group ID: ", process.env.GROUP_ID); + console.log("\nNext: fund the wallet with a little ZEC, then send."); + console.log(" npm run address # re-derive the address"); + console.log(" npm run balance # check its balance"); + console.log(" npm run transfer -- 0.001 # sign + send 0.001 ZEC"); +} + +// --------------------------------------------------------------------------- +// Lit Chipotle REST helpers. +// --------------------------------------------------------------------------- + +async function call(base, apiKey, path, init = {}) { + const res = await fetch(`${base}/core/v1/${path}`, { + ...init, + headers: { + "X-Api-Key": apiKey, + "Content-Type": "application/json", + ...(init.headers || {}), + }, + }); + const body = await res.json(); + if (!res.ok) { + const msg = body.message || body.error || JSON.stringify(body); + const err = new Error(`${path} -> ${res.status}: ${msg}`); + err.status = res.status; + err.body = body; + throw err; + } + return body; +} + +async function getActionCid(base, apiKey, code) { + return call(base, apiKey, "get_lit_action_ipfs_id", { + method: "POST", + body: JSON.stringify(code), + }); +} + +async function addGroup(base, apiKey) { + const body = await call(base, apiKey, "add_group", { + method: "POST", + body: JSON.stringify({ + group_name: "zcash-signer", + group_description: "Action-derived keyless Zcash transparent wallet (secp256k1)", + pkp_ids_permitted: [], + cid_hashes_permitted: ["0"], + }), + }); + return body.group_id; +} + +async function addAction(base, apiKey, cid) { + return call(base, apiKey, "add_action", { + method: "POST", + body: JSON.stringify({ + action_ipfs_cid: cid, + name: "zcashSigner", + description: "Keyless Zcash transparent wallet bound to the action CID; signs capped P2PKH transfers (ZIP-243)", + }), + }); +} + +async function addActionToGroup(base, apiKey, groupId, cid) { + return call(base, apiKey, "add_action_to_group", { + method: "POST", + body: JSON.stringify({ group_id: Number(groupId), action_ipfs_cid: cid }), + }); +} + +async function createUsageApiKey(base, apiKey, groupId) { + const body = await call(base, apiKey, "add_usage_api_key", { + method: "POST", + body: JSON.stringify({ + name: "zcash-signer-executor", + description: "Scoped key used by the demo scripts to execute the zcash signer action", + can_create_groups: false, + can_delete_groups: false, + can_create_pkps: false, + manage_ipfs_ids_in_groups: [], + add_pkp_to_groups: [], + remove_pkp_from_groups: [], + execute_in_groups: [Number(groupId)], + }), + }); + if (!body.usage_api_key) { + throw new Error(`add_usage_api_key returned no key: ${JSON.stringify(body)}`); + } + return body.usage_api_key; +} + +main().catch((err) => { + console.error("\nSetup failed:", err.message); + if (err.body) console.error("Server said:", err.body); + process.exit(1); +}); diff --git a/examples/zcash-signer/scripts/transfer.js b/examples/zcash-signer/scripts/transfer.js new file mode 100644 index 00000000..d1d78e3e --- /dev/null +++ b/examples/zcash-signer/scripts/transfer.js @@ -0,0 +1,95 @@ +// Send ZEC from the action's keyless transparent wallet: +// 1. Fetch the wallet's UTXOs and select enough to cover amount + fee. +// 2. Ask the action to build + sign the transaction. The action validates +// the spend (recipient is a real t1 address, amount under cap, change can +// only return to itself), constructs every output, computes the ZIP-243 +// sighash for each input, secp256k1-signs them, and returns the raw hex. +// 3. Broadcast that hex to Zcash mainnet via Blockchair. +// +// The action never sees a private key leave the TEE and never trusts us to set +// the outputs — it builds them itself. We only choose which UTXOs to spend and +// what fee to pay; lying about either can only make the broadcast fail, never +// redirect funds (input values and outpoints are committed in the sighash). +// +// Usage: +// npm run transfer -- [feeZec] +// npm run transfer -- t1abc...recipient 0.001 +// +// Try `npm run transfer -- 0.05` to watch the action REFUSE +// anything over the 0.01 ZEC cap baked into its (CID-bound) source. + +const env = require("./_env"); +const { runAction } = require("./_lit"); +const { getUtxos, getTipHeight, broadcast, zecToZat, zatToZec } = require("./_zcash"); +env.load(); + +// Default miner fee: 0.0001 ZEC. Comfortably above the ZIP-317 minimum for a +// small transparent transaction; the action caps the fee at 0.0005 ZEC. +const DEFAULT_FEE_ZAT = 10_000; + +async function main() { + const recipient = process.argv[2]; + const amountZec = Number(process.argv[3]); + const feeZat = process.argv[4] ? zecToZat(process.argv[4]) : DEFAULT_FEE_ZAT; + if (!recipient || !Number.isFinite(amountZec) || amountZec <= 0) { + throw new Error("Usage: npm run transfer -- [feeZec]"); + } + const amountZat = zecToZat(amountZec); + + const from = + process.env.ZCASH_ADDRESS || (await runAction({ action: "address" })).address; + + // ---- 1. Select inputs --------------------------------------------------- + const [utxos, tipHeight] = await Promise.all([getUtxos(from), getTipHeight()]); + if (utxos.length === 0) { + throw new Error(`wallet ${from} has no UTXOs — fund it first (npm run balance)`); + } + + // Greedy largest-first selection until we cover amount + fee. + const target = amountZat + feeZat; + const sorted = [...utxos].sort((a, b) => Number(b.value) - Number(a.value)); + const inputs = []; + let total = 0; + for (const u of sorted) { + inputs.push(u); + total += Number(u.value); + if (total >= target) break; + } + if (total < target) { + throw new Error( + `insufficient funds: have ${zatToZec(total)} ZEC, need ${zatToZec(target)} ZEC (amount + fee)` + ); + } + + // nExpiryHeight: a window past the current tip so the tx has time to confirm. + const expiryHeight = tipHeight + 40; + + // ---- 2. Have the action build + sign it --------------------------------- + console.log(`Asking the action to sign: ${amountZec} ZEC ${from} -> ${recipient}`); + console.log(` inputs: ${inputs.length}, fee: ${zatToZec(feeZat)} ZEC, expiry height: ${expiryHeight}`); + const result = await runAction({ + action: "sign", + inputs, + recipient, + amountZat: String(amountZat), + feeZat: String(feeZat), + expiryHeight, + }); + if (!result || !result.authorized) { + console.error("Action declined to sign:", result && result.reason); + process.exit(2); + } + console.log( + ` signed: amount ${zatToZec(result.amountZat)} ZEC, change ${zatToZec(result.changeZat)} ZEC, fee ${zatToZec(result.feeZat)} ZEC` + ); + + // ---- 3. Broadcast ------------------------------------------------------- + const txid = await broadcast(result.txHex); + console.log(`broadcast tx: ${txid}`); + console.log(`explorer: https://blockchair.com/zcash/transaction/${txid}`); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); From d80cf08d69cadb585c84aadafb587e7a4cbd4d8c Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 16 Jun 2026 12:01:00 -0700 Subject: [PATCH 2/2] docs(examples): add shielded-Zcash resume/handoff notes to zcash-signer Captures the full state for picking this up later: what the transparent example is and how it was verified, the shielded (Orchard) target design, the WASM proving spike with real native/local/TEE numbers (and the spike crate source), the wasm-delivery 413 tension, and the two infra blockers (ingress timeout PR #442; on-chain LIT_ACTION_DEFAULT_MEMORY_LIMIT_MB -> 256) with a step-by-step resume checklist. Co-Authored-By: Claude Opus 4.8 --- examples/zcash-signer/SHIELDED-HANDOFF.md | 299 ++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 examples/zcash-signer/SHIELDED-HANDOFF.md diff --git a/examples/zcash-signer/SHIELDED-HANDOFF.md b/examples/zcash-signer/SHIELDED-HANDOFF.md new file mode 100644 index 00000000..c2e0014e --- /dev/null +++ b/examples/zcash-signer/SHIELDED-HANDOFF.md @@ -0,0 +1,299 @@ +# Zcash signer — handoff / resume notes + +This PR (draft) ships the **transparent** Zcash signer example. The original +intent was to pivot it to a **shielded (Orchard)** signer; that work was scoped, +proven feasible with a real spike, and then parked behind two infra limit +bumps. This doc captures everything needed to pick it back up. + +--- + +## 1. What's in this PR right now (transparent — done & verified) + +`examples/zcash-signer/` — a keyless Zcash **transparent** (`t1`) wallet bound +to a Lit Action's CID, modeled on `examples/solana-signer`. + +- The action derives a **secp256k1** `t1` address from its own identity key + (`getLitActionPrivateKey()`), builds a **v4 (Sapling) transaction**, computes + the **ZIP-243 BLAKE2b sighash** (personalized with the NU6 consensus branch + ID `0xc8e71055`), signs each input, and returns raw tx hex. Policy: single + recipient `t1`, amount under a code-bound cap, change forced back to itself, + fee capped. +- Client (`scripts/`) uses **Blockchair mainnet REST** for UTXOs / tip height / + broadcast. It targets **mainnet** because Zcash *testnet* REST infra is dead + (no public testnet Blockbook, Blockchair has no testnet, `explorer.testnet.z.cash` + is gone). +- **Verification done:** the ZIP-243 sighash machinery was checked against all + 10 official `zcash-test-vectors` (`zip_0243.json`) — 10/10 pass, including the + transparent-input cases — using the action's exact helper code. Address + derivation, low-S DER signing, and tx assembly were checked against the + canonical secp256k1 `privkey=1` vector and an end-to-end run of the real + action file (npm-shimmed imports, mocked `Lit.Actions`). +- **Gotcha already fixed:** `@noble/secp256k1` v2 **dropped DER** (`toDERRawBytes` + / `fromDER` don't exist). The action DER-encodes `(r,s)` itself from the + compact signature. Don't "simplify" that back to `.toDERRawBytes()`. + +If the shielded version supersedes this, the transparent example can be deleted +(the user indicated a preference for shielded). Until then it stands alone. + +--- + +## 2. The shielded pivot — target design + +Goal: a keyless **Orchard** (shielded) wallet where the **full Orchard spend +key never leaves the TEE**. Because Orchard separates proving from spend +authorization, the cleanest design (assuming proving fits the runtime — it does, +see §4) is **the action does everything**: + +``` +Action (Rust→WASM): derive Orchard spend key from CID identity → build bundle → + Halo2 create_proof → RedPallas sign → serialize v5 tx → hex + (also exposes its unified address + FVK) +Client (Node): scan with the FVK via lightwalletd (testnet) to find the + spendable note(s) + Merkle witness + anchor, hand them to + the action, then broadcast the returned tx +``` + +Network: **testnet** via lightwalletd `lwd.testnet.zec.pro` (gRPC) — unlike the +transparent REST situation, shielded testnet works via lightwalletd +(`GetAddressUtxos`-style scan + `SendTransaction`) with free TAZ. + +### Curve / crypto facts (why secp256k1 doesn't apply) +- Orchard: **Pallas/Vesta** curves, **RedPallas** spend-auth sigs, **Halo 2** + proofs (no trusted setup, no params blob). Circuit `k = 11` (2048 rows). +- Sapling (the other shielded pool): Jubjub / RedJubjub / Groth16 (needs ~48 MB + params) — **not** chosen; Orchard is lighter to ship and the modern pool. +- `@noble/curves/pasta` exports `pallas`/`vesta`, so RedPallas could be done in + JS if ever needed, but proving must be Rust→WASM regardless. + +### Orchard crate API (v0.13) — the two-phase auth is built in +`Builder::new(BundleType::DEFAULT, anchor)` → `add_spend(fvk, note, merkle_path)` +/ `add_output(ovk, addr, NoteValue, memo)` → `build()` → +`create_proof(&pk, rng)` (**takes no `ask`** — proving needs only the FVK) → +`prepare(rng, sighash)` → `sign(rng, &ask)` *or* `append_signatures(&[redpallas::Signature])` +(external signer) → `finalize()`. So if you ever want the spend key fully +separate from the prover, the crate already supports it. + +--- + +## 3. v5 transaction + sighash references +- **ZIP-225** — v5 transaction format (header `0x80000005`, versionGroupId + `0x26A7270A`, `nConsensusBranchId` serialized in the body, Sapling+Orchard + bundles). +- **ZIP-244** — v5 sighash (a tree of BLAKE2b hashes; `ZcashTxHash_` root + personalization + branch ID LE; transparent S.2 sub-hashes). Needed if the tx + has any transparent ins/outs; a pure-Orchard tx still needs the v5 txid/sighash + tree. +- Consensus branch ID (current, NU6): **`0xc8e71055`**, appended **little-endian** + in personalizations. Bump at each network upgrade. +- The `orchard` crate handles the Orchard bundle's own sighash internally; the + outer v5 tx assembly (wrapping the bundle + any transparent parts + computing + the tx sighash the bundle is `prepare()`d with) is what you build around it — + `zcash_primitives` / `zcash_protocol` have the v5 tx builder if you don't want + to hand-roll ZIP-225/244. + +--- + +## 4. Spike: proven feasible, with real numbers + +A spike compiled the `orchard` crate (with `circuit`) to `wasm32-unknown-unknown` +and generated a real Halo2 proof (derive key → shielding bundle → `create_proof` +→ `prepare` → `finalize`). It ran **natively, in local Node WASM, and in a real +Lit Action on Chipotle.** + +| Step | Native | Local WASM (1-thread) | **Real TEE (Chipotle)** | +| --- | --- | --- | --- | +| `ProvingKey::build()` | 1.3 s | 9.4 s | **23.7 s** | +| full proof (incl. PK build) | ~1.8 s | ~22 s | **~56–58 s** (scaled) | +| peak WASM linear memory | (131 MB RSS) | **~100 MB** (32 MB after PK build → 100 MB after proof) | matched local byte-for-byte at the 32 MB checkpoint | +| wasm binary | — | **2.3 MB** | — | + +Conclusions: +- **A Lit Action can generate the Orchard proof.** Memory peak ~**100 MB**, time + ~**56 s** in the TEE (CPU ~2.5× slower than a laptop). Not the 4 GB worst case + — that only applies to large multi-action bundles. +- ~**24 s of every call is just rebuilding the proving key** (stateless action + rebuilds it each time). Optimization later: ship a serialized PK, or use WASM + threads (rayon) — but threads need nightly + shared-memory/atomics support in + the TEE. + +The spike lives in **`.context/orchard-spike/`** (gitignored — **NOT in this PR**, +will be lost if the workspace is cleaned). Its source is embedded below so it's +recoverable. The spike only builds a *shielding-output* bundle (dummy spends +against `Anchor::empty_tree()`); the real example needs **real spends** (note + +witness + anchor) and **v5 tx assembly** around the bundle. + +
+orchard-spike/Cargo.toml + +```toml +[package] +name = "orchard-spike" +version = "0.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +orchard = { version = "0.13", default-features = false, features = ["circuit"] } +rand = "0.8" +wasm-bindgen = "0.2" +getrandom = { version = "0.2", features = ["js"] } + +[profile.release] +opt-level = 3 +``` +Build: `wasm-pack build --release --target web` (or `--target nodejs` for local +timing). `default-features = false` drops `multicore`/rayon → single-threaded, +builds on stable. `getrandom` `js` feature is required for `wasm32`. +
+ +
+orchard-spike/src/lib.rs (the working prover spike) + +```rust +use orchard::{ + builder::{Builder, BundleType}, + bundle::{Authorized, Bundle}, + circuit::ProvingKey, + keys::{FullViewingKey, Scope, SpendingKey}, + value::NoteValue, + Anchor, +}; +use rand::rngs::OsRng; +use wasm_bindgen::prelude::*; + +// Current wasm linear memory high-water mark in bytes (wasm memory only grows). +#[wasm_bindgen] +pub fn wasm_mem_bytes() -> u32 { + #[cfg(target_arch = "wasm32")] + { (core::arch::wasm32::memory_size(0) as u32).saturating_mul(65536) } + #[cfg(not(target_arch = "wasm32"))] + { 0 } +} + +#[wasm_bindgen] +pub fn prove_shielding(seed_hex: &str, value: u64) -> String { + let mut rng = OsRng; + let mut seed = [0u8; 32]; + let bytes = hex_to_bytes(seed_hex); + seed.copy_from_slice(&bytes[..32]); + + let sk = SpendingKey::from_bytes(seed).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let pk = ProvingKey::build(); + + let anchor: Anchor = Anchor::empty_tree(); + let mut builder = Builder::new(BundleType::DEFAULT, anchor); + builder.add_output(None, recipient, NoteValue::from_raw(value), [0u8; 512]).unwrap(); + + let unproven = builder.build::(&mut rng).unwrap().unwrap().0; + let bundle: Bundle = unproven + .create_proof(&pk, &mut rng).unwrap() + .prepare(rng, [0u8; 32]) + .finalize().unwrap(); + + format!("ok value_balance={}", bundle.value_balance()) +} + +fn hex_to_bytes(s: &str) -> Vec { + let s = s.trim_start_matches("0x"); + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i+2], 16).unwrap()).collect() +} +``` +
+ +--- + +## 5. wasm delivery tension (resolve before building the real action) + +- Inlining the 2.3 MB wasm as base64 makes a ~3 MB action, which the + `/lit_action` **HTTP gateway rejects with 413** (request-body cap is well + below the 16 MB *code* limit). So inline is out for a wasm this size. +- **Fetching the wasm at runtime works** (verified in the TEE: jsDelivr 141 ms; + an arbitrary host `tmpfiles.org` served the full 2.4 MB in 1.6 s — TEE `fetch` + is not host-restricted). This is the pattern `examples/mpc-signing-ecdsa` uses. +- **Binding caveat:** fetching means the wasm is **not** committed to the action + CID, weakening the "key bound to exact code" story. Mitigation: host the wasm + immutably (npm→jsDelivr, pinned version) **and have the action verify a + hardcoded SHA-256 of the fetched bytes before instantiating** — then the hash + (and thus the prover) *is* part of the CID. Do this. + +--- + +## 6. Infra blockers + status (both needed before a full proof returns) + +The PK-build step alone (23.7 s, 32 MB) ran fine in the TEE. The **full** proof +hits two limits, both ours, both confirmed: + +1. **Ingress timeout (was a 504 at 60 s).** `dstack-ingress` runs nginx + internally (TLS termination → `lit-api-server:8000`) with the stock 60 s + `proxy_read_timeout`. **NOT** a Phala/dstack platform limit (dstack-gateway is + a Rust TCP proxy, idle 10 m / total 5 h, never emits an nginx 504). + → **Fixed in PR #442** (separate PR against `main`): sets + `PROXY_READ_TIMEOUT`/`PROXY_SEND_TIMEOUT` to `900s` on the `dstack-ingress` + service in `docker-compose.phala.yml` (matches the runtime's 15-min + `DEFAULT_TIMEOUT_MS`). The pinned image already supports those env vars. + **Needs:** merge #442 + redeploy ingress on **dev**. + Caveat: dstack-gateway idle = 10 m, so a >10-min *idle* response would still + be cut — irrelevant at ~56 s. + +2. **Action memory limit: 64 MB default < ~100 MB needed.** This is **on-chain** + config (`LIT_ACTION_DEFAULT_MEMORY_LIMIT_MB`, read from the account-config + contract `nodeConfigurationValues()` every 30 s; code fallback default 64, + hard max **640** = `DEFAULT_MEMORY_LIMIT_MB × 10`). 256 is already within the + allowed max, so **no code change / PR is needed** — set it on-chain via the + monitor dapp (`lit-static/dapps/monitor/`, "Set Configuration" = + `setNodeConfiguration`): + > key `LIT_ACTION_DEFAULT_MEMORY_LIMIT_MB` → value `256` + **Needs:** set this on **dev**. (A code PR would only be needed to raise the + fallback default or the 640 ceiling — neither required for 256.) + +Other runtime limits are already fine: execution timeout **15 min**, code size +16 MB, response 1 MB (the v5 tx hex should be well under 1 MB — verify). + +**Verify limits live:** `GET /core/v1/get_lit_action_client_config` with an API +key returns the effective `memory_limit_mb`, `timeout_ms`, etc. (At time of +writing on prod: `memory_limit_mb: 64`, `timeout_ms: 900000`.) + +--- + +## 7. Resume checklist + +1. Land the limits on **dev**: merge/deploy **PR #442** (ingress 900 s) and set + on-chain `LIT_ACTION_DEFAULT_MEMORY_LIMIT_MB = 256`. Confirm via + `GET /get_lit_action_client_config`. +2. Re-run the spike's full proof in a real action to confirm it now **returns** + (was ~56 s / ~100 MB; previously 504'd at the 60 s ingress cap). Harness + pattern: fetch wasm from a URL, `initSync(bytes)`, call `prove_shielding`, + return timing + `wasm_mem_bytes()`. +3. Extend the spike crate into the real prover: **real spends** (note + Merkle + witness + anchor via `add_spend`), recipient + change outputs, and **v5 tx + assembly** (ZIP-225/244) around the Orchard bundle. Derive address + FVK from + the CID seed; expose an `address`/`viewing-key` op. +4. Decide + implement **wasm delivery** (§5): host immutably + verify a pinned + SHA-256 in the action so the prover is bound to the CID. +5. Build the **JS action wrapper** (load wasm, route ops, return tx hex), mirror + `examples/mpc-signing-ecdsa/action/` for the loading pattern. +6. Build the **client**: lightwalletd gRPC (`lwd.testnet.zec.pro`) — scan with + FVK for spendable notes + witnesses + a recent anchor, call the action, + `SendTransaction` to broadcast. This is the fiddliest part; WebZjs + (`@chainsafe/webzjs-*`) is the reference for client-side scanning. +7. Remove the transparent example (or keep both, TBD) and update + `examples/README.md`. + +## Key references +- ZIPs: [225 (v5 format)](https://zips.z.cash/zip-0225), + [244 (v5 sighash)](https://zips.z.cash/zip-0244), + [243 (v4 sighash, used by the transparent example)](https://zips.z.cash/zip-0243). +- `orchard` crate builder (two-phase auth): `src/builder.rs` — `create_proof` + (no `ask`), `prepare`, `append_signatures`, `finalize`. +- WASM-in-action loading + limits: `docs/lit-actions/wasm.mdx`, + `docs/lit-actions/limits.mdx`, `examples/mpc-signing-ecdsa/`. +- Limits in code: `lit-api-server/src/actions/client/mod.rs` + (`DEFAULT_MEMORY_LIMIT_MB`, `MAX_MEMORY_LIMIT_MB`, timeouts), + `lit-api-server/src/accounts/chain_config.rs` (`ConfigKeys`, on-chain source). +- Test vectors: `zcash-test-vectors` repo (`zip_0243.json`, `zip_0244` for v5).