@frontiercompute/zcash-ika 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,31 @@
1
1
  # zcash-ika
2
2
 
3
- Split-key custody for Zcash, Bitcoin, and EVM chains. The private key never exists whole.
3
+ Split-key custody for Zcash, Bitcoin, and EVM chains. The private key never exists whole. Spend policy enforced on-chain. Every action attested to Zcash.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@frontiercompute/zcash-ika)](https://www.npmjs.com/package/@frontiercompute/zcash-ika)
6
6
 
7
- ## What this is
7
+ ## What this does
8
8
 
9
- One secp256k1 dWallet on [Ika's 2PC-MPC network](https://ika.xyz) signs for Zcash transparent, Bitcoin, and Ethereum. Your device holds half the key. Ika's nodes hold the other half. Spending policy enforced by Sui Move contract. Every action attested on Zcash via [ZAP1](https://pay.frontiercompute.io).
9
+ One secp256k1 dWallet on [Ika's 2PC-MPC network](https://ika.xyz) signs for three chain families. Your device holds half the key. Ika's nodes hold the other half. Neither can spend alone.
10
10
 
11
- ## What works today
11
+ - **Spend policy** enforced by Sui Move contract (per-tx limits, daily caps, recipient whitelist, emergency freeze)
12
+ - **Transparent TX builder** for Zcash v5 transactions (ZIP 244 sighash, P2PKH, UTXO selection)
13
+ - **Attestation** of every operation to Zcash mainnet via [ZAP1](https://pay.frontiercompute.io)
14
+ - **5-chain verification** of attestation proofs (Arbitrum, Base, Hyperliquid, Solana, NEAR)
15
+
16
+ ## Chain support
12
17
 
13
18
  | Chain | Curve | Algorithm | Hash | Status |
14
19
  |-------|-------|-----------|------|--------|
15
- | Zcash transparent | secp256k1 | ECDSA | DoubleSHA256 | dWallet on testnet, signing wired |
16
- | Bitcoin | secp256k1 | ECDSA | DoubleSHA256 | Same dWallet, same key |
17
- | Ethereum/EVM | secp256k1 | ECDSA | KECCAK256 | Same dWallet, different hash |
20
+ | Zcash transparent | secp256k1 | ECDSA | DoubleSHA256 | TX builder + signing live |
21
+ | Bitcoin | secp256k1 | ECDSA | DoubleSHA256 | Same dWallet, signing live |
22
+ | Ethereum/EVM | secp256k1 | ECDSA | KECCAK256 | Same dWallet, signing live |
18
23
 
19
24
  One dWallet. Three chain families. Split custody on all of them.
20
25
 
21
26
  ## What does NOT work
22
27
 
23
- **Zcash shielded (Orchard)** requires RedPallas signatures on the Pallas curve. Ika's MPC supports secp256k1 and Ed25519, but not Pallas. There is no path from Ika to Orchard signing today. Same for Sapling (RedJubjub on Jubjub).
24
-
25
- For shielded operations, use the [embedded Orchard wallet](https://github.com/Frontier-Compute/zap1) which holds keys directly. The hybrid architecture: MPC custody for transparent + cross-chain, local wallet for shielded.
28
+ **Zcash shielded (Orchard)** requires RedPallas on the Pallas curve. Ika supports secp256k1 and Ed25519, not Pallas. No path from Ika to Orchard signing today. For shielded, use the [embedded wallet](https://github.com/Frontier-Compute/zap1) which holds Orchard keys directly.
26
29
 
27
30
  ## Install
28
31
 
@@ -30,16 +33,16 @@ For shielded operations, use the [embedded Orchard wallet](https://github.com/Fr
30
33
  npm install @frontiercompute/zcash-ika
31
34
  ```
32
35
 
33
- ## Usage
36
+ ## Quick start
34
37
 
35
38
  ```typescript
36
39
  import {
37
40
  createWallet,
38
41
  sign,
39
- createDualCustody,
40
- getHistory,
41
- checkCompliance,
42
- CHAIN_PARAMS,
42
+ setPolicy,
43
+ spendTransparent,
44
+ checkPolicy,
45
+ deriveZcashAddress,
43
46
  } from "@frontiercompute/zcash-ika";
44
47
 
45
48
  const config = {
@@ -48,81 +51,129 @@ const config = {
48
51
  zap1ApiUrl: "https://pay.frontiercompute.io",
49
52
  };
50
53
 
51
- // Create split-key wallet (secp256k1 - signs for ZEC + BTC + ETH)
52
- const custody = await createDualCustody(config);
53
- console.log("dWallet:", custody.primary.id);
54
- console.log("Save this seed:", custody.primary.encryptionSeed);
55
-
56
- // Sign a Zcash transparent sighash through MPC
57
- const result = await sign(config, {
58
- messageHash: sighashBytes, // DoubleSHA256 of the tx
59
- walletId: custody.primary.id,
60
- chain: "zcash-transparent",
61
- encryptionSeed: custody.primary.encryptionSeed,
62
- });
63
- console.log("Signature:", Buffer.from(result.signature).toString("hex"));
64
-
65
- // Sign Bitcoin (same dWallet, same MPC, same seed)
66
- const btcSig = await sign(config, {
67
- messageHash: btcSighash,
68
- walletId: custody.primary.id,
69
- chain: "bitcoin",
70
- encryptionSeed: custody.primary.encryptionSeed,
54
+ // 1. Create split-key wallet
55
+ const wallet = await createWallet(config);
56
+ console.log("dWallet:", wallet.id);
57
+ console.log("t-addr:", wallet.address);
58
+ console.log("Save this seed:", wallet.encryptionSeed);
59
+
60
+ // 2. Set spend policy (Sui Move contract)
61
+ const policy = await setPolicy(config, wallet.id, {
62
+ maxPerTx: 100_000_000, // 1 ZEC max per transaction
63
+ maxDaily: 500_000_000, // 5 ZEC daily cap
64
+ allowedRecipients: [], // empty = any recipient
65
+ approvalThreshold: 50_000_000, // flag above 0.5 ZEC
71
66
  });
67
+ console.log("Policy:", policy.policyId);
72
68
 
73
- // Compliance check (works now against live ZAP1 API)
74
- const compliance = await checkCompliance(config, custody.primary.id);
69
+ // 3. Check if a spend is allowed
70
+ const check = await checkPolicy(config, policy.policyId, {
71
+ amount: 10_000_000,
72
+ recipient: "t1SomeAddress...",
73
+ });
74
+ console.log("Allowed:", check.allowed);
75
+
76
+ // 4. Spend from the transparent address
77
+ const spend = await spendTransparent(config, {
78
+ walletId: wallet.id,
79
+ encryptionSeed: wallet.encryptionSeed,
80
+ recipient: "t1RecipientAddr...",
81
+ amount: 10_000_000, // 0.1 ZEC
82
+ zebraRpcUrl: "http://localhost:8232",
83
+ });
84
+ console.log("Txid:", spend.txid);
75
85
  ```
76
86
 
77
- ## How it works
87
+ ## Architecture
78
88
 
79
89
  ```
80
- Operator (phone / hardware wallet)
90
+ Operator (device / HSM)
81
91
  |
82
- | user key share (encryption seed)
92
+ | user key share (encryption seed from DKG)
83
93
  |
84
94
  Ika MPC Network (2PC-MPC on Sui)
85
95
  |
86
96
  | network key share (distributed across nodes)
87
97
  |
88
- +-- Spending Policy (Sui Move contract)
89
- | max per tx, daily cap, approved recipients
98
+ +-- Spend Policy (Sui Move contract)
99
+ | max per tx, daily cap, recipient whitelist, freeze
90
100
  |
91
- +-- Sign ZEC transparent tx (secp256k1/ECDSA/DoubleSHA256)
92
- +-- Sign BTC tx (secp256k1/ECDSA/DoubleSHA256)
93
- +-- Sign ETH tx (secp256k1/ECDSA/KECCAK256)
101
+ +-- Sign ZEC transparent (secp256k1 / ECDSA / DoubleSHA256)
102
+ +-- Sign BTC (secp256k1 / ECDSA / DoubleSHA256)
103
+ +-- Sign ETH/EVM (secp256k1 / ECDSA / KECCAK256)
104
+ |
105
+ TX Builder (Zcash v5, ZIP 244)
106
+ +-- UTXO fetch from Zebra
107
+ +-- Sighash computation
108
+ +-- Signature attachment
109
+ +-- Broadcast + attestation
94
110
  |
95
111
  ZAP1 Attestation (Zcash mainnet)
96
- +-- every spend recorded
97
- +-- policy violations on-chain
98
- +-- full audit trail
112
+ +-- every spend recorded in Merkle tree
113
+ +-- anchored to Zcash blockchain
114
+ +-- verified on 5 EVM/L1 chains
99
115
  ```
100
116
 
101
- ## Sign flow (two transactions)
117
+ ## Sign flow
118
+
119
+ 1. **Presign** - pre-compute MPC ephemeral key share (Sui TX 1, poll for completion)
120
+ 2. **Sign** - approve message + request signature (Sui TX 2, poll for completion)
121
+
122
+ Both transactions on Sui. The user partial is computed locally via WASM. Neither party sees the full private key.
123
+
124
+ ## Spend flow (transparent)
102
125
 
103
- 1. **Presign** - pre-compute MPC ephemeral key share (TX 1, poll for completion)
104
- 2. **Sign** - approve message + request signature (TX 2, poll for completion)
126
+ 1. Fetch UTXOs from Zebra RPC (`getaddressutxos`)
127
+ 2. Build unsigned v5 TX with ZIP 244 sighash per input
128
+ 3. Sign each sighash through Ika MPC (presign + sign)
129
+ 4. Attach DER signatures to scriptSig fields
130
+ 5. Broadcast via `sendrawtransaction`
131
+ 6. Attest spend to ZAP1
105
132
 
106
- Both transactions on Sui. The user partial signature is computed locally via WASM. Neither party ever sees the full private key.
133
+ ## Policy enforcement
134
+
135
+ The Sui Move contract (`zap1_policy::policy`) stores:
136
+ - Per-transaction limit (zatoshis)
137
+ - 24-hour rolling daily cap
138
+ - Allowed recipient whitelist (empty = any)
139
+ - Emergency freeze toggle
140
+
141
+ Policy is checked before every MPC sign request. The contract owns the approval gate - you can't bypass it from the client.
142
+
143
+ Deploy: `sui client publish --path move/` then set `POLICY_PACKAGE_ID`.
107
144
 
108
145
  ## On-chain proof
109
146
 
110
- secp256k1 dWallet created on Ika testnet:
147
+ secp256k1 dWallet on Ika testnet:
111
148
 
112
149
  - dWalletId: `0xd9055400c88aeae675413b78143aa54e25eca7061ab659f54a42167cbfdd7aec`
113
150
  - TX: [`CYrS5X1S3itHUtux4qS35AJz5AAyUaJYeWZuqm1CcX2L`](https://testnet.suivision.xyz/txblock/CYrS5X1S3itHUtux4qS35AJz5AAyUaJYeWZuqm1CcX2L)
114
- - Public key: `03ba9e85a85674df494520c2e80b804656fac54fe68668266f33fee9b03ad4b069`
115
- - Derived BTC: `moV3JAzgNa6NkxVfdaNqUjLoDxKEwNAnkX`
151
+ - Compressed pubkey: `03ba9e85a85674df494520c2e80b804656fac54fe68668266f33fee9b03ad4b069`
116
152
  - Derived ZEC t-addr: `t1Rqh1TKqXsSiaV4wrSDandEPccucpHEudn`
117
153
 
154
+ Attestation anchors verified on Arbitrum, Base, Hyperliquid, Solana, NEAR testnet.
155
+
156
+ ## Environment variables
157
+
158
+ ```
159
+ SUI_PRIVATE_KEY - Sui keypair for signing transactions
160
+ POLICY_PACKAGE_ID - Published Move package address (after sui client publish)
161
+ ZAP1_API_URL - ZAP1 attestation API (default: https://pay.frontiercompute.io)
162
+ ZAP1_API_KEY - API key for attestation
163
+ ZEBRA_RPC_URL - Zebra JSON-RPC endpoint for UTXO queries and broadcast
164
+ ```
165
+
118
166
  ## Test scripts
119
167
 
120
168
  ```bash
121
- # Create a new dWallet (saves encryption seed)
169
+ # Create a new dWallet
122
170
  SUI_PRIVATE_KEY=suiprivkey1... node dist/test-dkg.js
123
171
 
124
172
  # Sign a test message through MPC
125
173
  SUI_PRIVATE_KEY=... DWALLET_ID=0x... ENC_SEED=... node dist/test-sign.js
174
+
175
+ # End-to-end: DKG + presign + sign
176
+ SUI_PRIVATE_KEY=... node dist/test-e2e.js
126
177
  ```
127
178
 
128
179
  ## Stack
@@ -130,6 +181,7 @@ SUI_PRIVATE_KEY=... DWALLET_ID=0x... ENC_SEED=... node dist/test-sign.js
130
181
  - [Ika](https://ika.xyz) - 2PC-MPC threshold signing on Sui
131
182
  - [ZAP1](https://pay.frontiercompute.io) - on-chain attestation protocol
132
183
  - [Zebra](https://github.com/ZcashFoundation/zebra) - Zcash node
184
+ - [Sui Move](https://docs.sui.io/concepts/sui-move-concepts) - policy enforcement
133
185
 
134
186
  ## License
135
187
 
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  * is viable through this package today.
15
15
  */
16
16
  export { Curve, Hash, SignatureAlgorithm, IkaClient, IkaTransaction, UserShareEncryptionKeys, getNetworkConfig, createClassGroupsKeypair, createRandomSessionIdentifier, prepareDKG, prepareDKGAsync, prepareDKGSecondRound, prepareDKGSecondRoundAsync, createDKGUserOutput, publicKeyFromDWalletOutput, parseSignatureFromSignOutput, } from "@ika.xyz/sdk";
17
+ export { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
18
+ export type { UTXO } from "./tx-builder.js";
17
19
  export type Chain = "zcash-transparent" | "bitcoin" | "ethereum";
18
20
  export interface ZcashIkaConfig {
19
21
  /** Ika network: mainnet or testnet */
@@ -69,6 +71,19 @@ export declare const CHAIN_PARAMS: {
69
71
  * 4. Base58 encode (version + hash + checksum)
70
72
  */
71
73
  export declare function deriveZcashAddress(publicKey: Uint8Array, network?: "mainnet" | "testnet"): string;
74
+ /**
75
+ * Derive a Bitcoin P2PKH address from a compressed secp256k1 public key.
76
+ *
77
+ * Same as Zcash transparent but with a 1-byte version prefix:
78
+ * mainnet 0x00 (1...), testnet 0x6f (m.../n...)
79
+ *
80
+ * Steps:
81
+ * 1. SHA256(pubkey) then RIPEMD160 = 20-byte hash
82
+ * 2. Prepend 1-byte version
83
+ * 3. Double-SHA256 checksum (first 4 bytes)
84
+ * 4. Base58 encode (version + hash + checksum)
85
+ */
86
+ export declare function deriveBitcoinAddress(publicKey: Uint8Array, network?: "mainnet" | "testnet"): string;
72
87
  export interface DWalletHandle {
73
88
  /** dWallet object ID on Sui */
74
89
  id: string;
@@ -167,21 +182,48 @@ export declare function createWallet(config: ZcashIkaConfig, chain: Chain, _oper
167
182
  * Neither party ever sees the full private key.
168
183
  */
169
184
  export declare function sign(config: ZcashIkaConfig, request: SignRequest): Promise<SignResult>;
185
+ export interface PolicyResult {
186
+ /** SpendPolicy shared object ID on Sui */
187
+ policyId: string;
188
+ /** PolicyCap object ID (owner holds this to manage policy) */
189
+ capId: string;
190
+ /** Sui transaction digest */
191
+ txDigest: string;
192
+ }
193
+ export interface PolicyState {
194
+ policyId: string;
195
+ dwalletId: string;
196
+ owner: string;
197
+ maxPerTx: number;
198
+ maxDaily: number;
199
+ dailySpent: number;
200
+ windowStart: number;
201
+ allowedRecipients: string[];
202
+ frozen: boolean;
203
+ }
204
+ /**
205
+ * Set spending policy on a dWallet.
206
+ * Creates a SpendPolicy shared object and PolicyCap on Sui.
207
+ * The PolicyCap is transferred to the caller.
208
+ */
209
+ export declare function setPolicy(config: ZcashIkaConfig, walletId: string, policy: SpendPolicy): Promise<PolicyResult>;
170
210
  /**
171
- * Set spending policy on the dWallet.
172
- * Policy enforced at Sui Move contract level.
173
- * The agent cannot bypass it - the contract holds the DWalletCap.
211
+ * Query a SpendPolicy object and check if a spend would be allowed.
212
+ * Returns the full policy state plus a boolean for the specific check.
174
213
  */
175
- export declare function setPolicy(_config: ZcashIkaConfig, _walletId: string, _policy: SpendPolicy): Promise<string>;
214
+ export declare function checkPolicy(config: ZcashIkaConfig, policyId: string, amount?: number, recipient?: string): Promise<PolicyState & {
215
+ allowed: boolean;
216
+ }>;
176
217
  /**
177
218
  * Spend from a Zcash transparent wallet.
178
219
  *
179
- * 1. Build Zcash transparent transaction (requires Zebra)
180
- * 2. Compute sighash (DoubleSHA256)
181
- * 3. Sign via Ika 2PC-MPC (secp256k1/ECDSA)
182
- * 4. Attach signature to transaction
220
+ * Full pipeline:
221
+ * 1. Fetch UTXOs from Zebra
222
+ * 2. Build unsigned TX, compute ZIP 244 sighashes
223
+ * 3. Sign each sighash via Ika 2PC-MPC
224
+ * 4. Attach signatures, serialize signed TX
183
225
  * 5. Broadcast via Zebra sendrawtransaction
184
- * 6. Attest via ZAP1 as AGENT_ACTION
226
+ * 6. Attest to ZAP1 as AGENT_ACTION
185
227
  */
186
228
  export declare function spendTransparent(config: ZcashIkaConfig, walletId: string, encryptionSeed: string, request: SpendRequest): Promise<SpendResult>;
187
229
  /**
package/dist/index.js CHANGED
@@ -19,6 +19,8 @@ import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
19
19
  import { Transaction } from "@mysten/sui/transactions";
20
20
  import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
21
21
  import { createHash } from "node:crypto";
22
+ import { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
23
+ export { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
22
24
  const IKA_COIN_TYPE = "0x1f26bb2f711ff82dcda4d02c77d5123089cb7f8418751474b9fb744ce031526a::ika::IKA";
23
25
  /** Parameters for dWallet creation per chain.
24
26
  *
@@ -116,6 +118,45 @@ export function deriveZcashAddress(publicKey, network = "mainnet") {
116
118
  full.set(checksum, 22);
117
119
  return base58Encode(full);
118
120
  }
121
+ // Bitcoin P2PKH version bytes (1 byte each)
122
+ const BITCOIN_VERSION_BYTE = {
123
+ mainnet: 0x00, // 1...
124
+ testnet: 0x6f, // m... or n...
125
+ };
126
+ /**
127
+ * Derive a Bitcoin P2PKH address from a compressed secp256k1 public key.
128
+ *
129
+ * Same as Zcash transparent but with a 1-byte version prefix:
130
+ * mainnet 0x00 (1...), testnet 0x6f (m.../n...)
131
+ *
132
+ * Steps:
133
+ * 1. SHA256(pubkey) then RIPEMD160 = 20-byte hash
134
+ * 2. Prepend 1-byte version
135
+ * 3. Double-SHA256 checksum (first 4 bytes)
136
+ * 4. Base58 encode (version + hash + checksum)
137
+ */
138
+ export function deriveBitcoinAddress(publicKey, network = "mainnet") {
139
+ if (publicKey.length !== 33) {
140
+ throw new Error(`Expected 33-byte compressed secp256k1 pubkey, got ${publicKey.length} bytes`);
141
+ }
142
+ const prefix = publicKey[0];
143
+ if (prefix !== 0x02 && prefix !== 0x03) {
144
+ throw new Error(`Invalid compressed pubkey prefix 0x${prefix.toString(16)}, expected 0x02 or 0x03`);
145
+ }
146
+ const pubkeyHash = hash160(publicKey); // 20 bytes
147
+ const version = BITCOIN_VERSION_BYTE[network];
148
+ // version (1) + hash160 (20) = 21 bytes
149
+ const payload = new Uint8Array(21);
150
+ payload[0] = version;
151
+ payload.set(pubkeyHash, 1);
152
+ // checksum: first 4 bytes of SHA256(SHA256(payload))
153
+ const checksum = sha256(sha256(payload)).subarray(0, 4);
154
+ // final: payload (21) + checksum (4) = 25 bytes
155
+ const full = new Uint8Array(25);
156
+ full.set(payload, 0);
157
+ full.set(checksum, 21);
158
+ return base58Encode(full);
159
+ }
119
160
  // Default poll settings for testnet (epochs can be slow)
120
161
  const POLL_OPTS = {
121
162
  timeout: 300_000,
@@ -274,6 +315,9 @@ export async function createWallet(config, chain, _operatorSeed) {
274
315
  if (pubkey && pubkey.length === 33 && chain === "zcash-transparent") {
275
316
  derivedAddress = deriveZcashAddress(pubkey, config.network);
276
317
  }
318
+ else if (pubkey && pubkey.length === 33 && chain === "bitcoin") {
319
+ derivedAddress = deriveBitcoinAddress(pubkey, config.network);
320
+ }
277
321
  return {
278
322
  id: dwalletId,
279
323
  publicKey: pubkey || new Uint8Array(0),
@@ -471,33 +515,239 @@ export async function sign(config, request) {
471
515
  signTxDigest: signResult.digest,
472
516
  };
473
517
  }
518
+ // Published package ID - set after sui client publish
519
+ // Override via POLICY_PACKAGE_ID env var or pass directly
520
+ const DEFAULT_POLICY_PACKAGE_ID = "0x0";
521
+ function getPolicyPackageId() {
522
+ return process.env.POLICY_PACKAGE_ID || DEFAULT_POLICY_PACKAGE_ID;
523
+ }
474
524
  /**
475
- * Set spending policy on the dWallet.
476
- * Policy enforced at Sui Move contract level.
477
- * The agent cannot bypass it - the contract holds the DWalletCap.
525
+ * Set spending policy on a dWallet.
526
+ * Creates a SpendPolicy shared object and PolicyCap on Sui.
527
+ * The PolicyCap is transferred to the caller.
478
528
  */
479
- export async function setPolicy(_config, _walletId, _policy) {
480
- throw new Error("setPolicy requires a deployed Move module on Sui. " +
481
- "The module gates approve_message() with spending constraints. " +
482
- "See docs/move-policy-template.move for the template.");
529
+ export async function setPolicy(config, walletId, policy) {
530
+ const packageId = getPolicyPackageId();
531
+ if (packageId === "0x0") {
532
+ throw new Error("Policy Move module not deployed. Set POLICY_PACKAGE_ID env var " +
533
+ "after running: sui client publish --path move/");
534
+ }
535
+ const { suiClient, keypair } = await initClients(config);
536
+ const tx = new Transaction();
537
+ // 0x6 is the shared Clock object on Sui
538
+ const cap = tx.moveCall({
539
+ target: `${packageId}::policy::create_policy`,
540
+ arguments: [
541
+ tx.pure.address(walletId),
542
+ tx.pure.u64(policy.maxPerTx),
543
+ tx.pure.u64(policy.maxDaily),
544
+ tx.object("0x6"),
545
+ ],
546
+ });
547
+ // Transfer the returned PolicyCap to sender
548
+ const sender = keypair.getPublicKey().toSuiAddress();
549
+ tx.transferObjects([cap], sender);
550
+ // Add allowed recipients if any
551
+ // Done in separate calls after creation since create_policy starts with empty list
552
+ const result = await suiClient.signAndExecuteTransaction({
553
+ transaction: tx,
554
+ signer: keypair,
555
+ options: { showEffects: true, showObjectChanges: true },
556
+ });
557
+ if (result.effects?.status?.status !== "success") {
558
+ throw new Error(`setPolicy TX failed: ${result.effects?.status?.error}`);
559
+ }
560
+ // Extract created object IDs
561
+ let policyId = "";
562
+ let capId = "";
563
+ const changes = result.objectChanges || [];
564
+ for (const change of changes) {
565
+ if (change.type !== "created")
566
+ continue;
567
+ const objType = change.objectType || "";
568
+ if (objType.includes("::policy::SpendPolicy")) {
569
+ policyId = change.objectId;
570
+ }
571
+ else if (objType.includes("::policy::PolicyCap")) {
572
+ capId = change.objectId;
573
+ }
574
+ }
575
+ if (!policyId || !capId) {
576
+ // Fallback: scan created effects
577
+ const created = result.effects?.created || [];
578
+ for (const obj of created) {
579
+ const id = obj.reference?.objectId || obj.objectId;
580
+ if (id && !policyId)
581
+ policyId = id;
582
+ else if (id && !capId)
583
+ capId = id;
584
+ }
585
+ }
586
+ // Add recipients in a second tx if needed
587
+ if (policy.allowedRecipients.length > 0 && policyId && capId) {
588
+ const tx2 = new Transaction();
589
+ for (const addr of policy.allowedRecipients) {
590
+ const addrBytes = new TextEncoder().encode(addr);
591
+ tx2.moveCall({
592
+ target: `${packageId}::policy::add_recipient_entry`,
593
+ arguments: [
594
+ tx2.object(policyId),
595
+ tx2.object(capId),
596
+ tx2.pure.vector("u8", Array.from(addrBytes)),
597
+ ],
598
+ });
599
+ }
600
+ await suiClient.signAndExecuteTransaction({
601
+ transaction: tx2,
602
+ signer: keypair,
603
+ options: { showEffects: true },
604
+ });
605
+ }
606
+ return { policyId, capId, txDigest: result.digest };
607
+ }
608
+ /**
609
+ * Query a SpendPolicy object and check if a spend would be allowed.
610
+ * Returns the full policy state plus a boolean for the specific check.
611
+ */
612
+ export async function checkPolicy(config, policyId, amount, recipient) {
613
+ const { suiClient } = await initClients(config);
614
+ const obj = await suiClient.getObject({
615
+ id: policyId,
616
+ options: { showContent: true },
617
+ });
618
+ const content = obj.data?.content;
619
+ if (!content || content.dataType !== "moveObject") {
620
+ throw new Error(`Policy object ${policyId} not found or not a Move object`);
621
+ }
622
+ const fields = content.fields;
623
+ const state = {
624
+ policyId,
625
+ dwalletId: fields.dwallet_id,
626
+ owner: fields.owner,
627
+ maxPerTx: Number(fields.max_per_tx),
628
+ maxDaily: Number(fields.max_daily),
629
+ dailySpent: Number(fields.daily_spent),
630
+ windowStart: Number(fields.window_start),
631
+ allowedRecipients: (fields.allowed_recipients || []).map((r) => new TextDecoder().decode(new Uint8Array(r))),
632
+ frozen: fields.frozen,
633
+ };
634
+ // Client-side policy check (mirrors Move logic)
635
+ let allowed = true;
636
+ if (state.frozen) {
637
+ allowed = false;
638
+ }
639
+ else if (amount !== undefined) {
640
+ if (amount > state.maxPerTx) {
641
+ allowed = false;
642
+ }
643
+ else {
644
+ const now = Date.now();
645
+ const daily = (now >= state.windowStart + 86_400_000) ? 0 : state.dailySpent;
646
+ if (daily + amount > state.maxDaily) {
647
+ allowed = false;
648
+ }
649
+ }
650
+ if (allowed && recipient && state.allowedRecipients.length > 0) {
651
+ allowed = state.allowedRecipients.includes(recipient);
652
+ }
653
+ }
654
+ return { ...state, allowed };
483
655
  }
484
656
  /**
485
657
  * Spend from a Zcash transparent wallet.
486
658
  *
487
- * 1. Build Zcash transparent transaction (requires Zebra)
488
- * 2. Compute sighash (DoubleSHA256)
489
- * 3. Sign via Ika 2PC-MPC (secp256k1/ECDSA)
490
- * 4. Attach signature to transaction
659
+ * Full pipeline:
660
+ * 1. Fetch UTXOs from Zebra
661
+ * 2. Build unsigned TX, compute ZIP 244 sighashes
662
+ * 3. Sign each sighash via Ika 2PC-MPC
663
+ * 4. Attach signatures, serialize signed TX
491
664
  * 5. Broadcast via Zebra sendrawtransaction
492
- * 6. Attest via ZAP1 as AGENT_ACTION
665
+ * 6. Attest to ZAP1 as AGENT_ACTION
493
666
  */
494
667
  export async function spendTransparent(config, walletId, encryptionSeed, request) {
495
- // Build transaction, extract sighash
496
- // For now: the caller provides the sighash directly via sign()
497
- // This function will be the full pipeline once we have tx building
498
- throw new Error("spendTransparent requires Zcash transparent tx builder. " +
499
- "Use sign() directly with a pre-computed sighash for now. " +
500
- "Full pipeline: build tx -> sighash -> sign() -> attach sig -> broadcast.");
668
+ const zebraUrl = config.zebraRpcUrl;
669
+ if (!zebraUrl) {
670
+ throw new Error("zebraRpcUrl required for transparent spend");
671
+ }
672
+ // Fetch the dWallet to get the public key
673
+ const { ikaClient } = await initClients(config);
674
+ const dWallet = await ikaClient.getDWallet(walletId);
675
+ if (!dWallet?.state?.Active) {
676
+ throw new Error(`dWallet ${walletId} not Active`);
677
+ }
678
+ const rawOutput = dWallet.state.Active.public_output;
679
+ const outputBytes = new Uint8Array(Array.isArray(rawOutput) ? rawOutput : Array.from(rawOutput));
680
+ const pubkey = await publicKeyFromDWalletOutput(Curve.SECP256K1, outputBytes);
681
+ if (!pubkey || pubkey.length !== 33) {
682
+ throw new Error("Could not extract 33-byte compressed pubkey from dWallet");
683
+ }
684
+ // Derive our t-address from the pubkey
685
+ const ourAddress = deriveZcashAddress(pubkey, config.network);
686
+ // Step 1: Fetch UTXOs
687
+ const allUtxos = await fetchUTXOs(zebraUrl, ourAddress);
688
+ if (allUtxos.length === 0) {
689
+ throw new Error(`No UTXOs found for ${ourAddress}`);
690
+ }
691
+ // Step 2: Select UTXOs and build unsigned TX
692
+ const fee = estimateFee(Math.min(allUtxos.length, 3), // estimate input count
693
+ 2 // recipient + change
694
+ );
695
+ const { selected } = selectUTXOs(allUtxos, request.amount, fee);
696
+ // Recompute fee with actual input count
697
+ const actualFee = estimateFee(selected.length, 2);
698
+ const { unsignedTx, sighashes, txid } = buildUnsignedTx(selected, request.to, request.amount, actualFee, ourAddress, // change back to our address
699
+ BRANCH_ID.NU5);
700
+ // Step 3: Sign each sighash via MPC
701
+ const signatures = [];
702
+ for (const sighash of sighashes) {
703
+ const signResult = await sign(config, {
704
+ messageHash: new Uint8Array(sighash),
705
+ walletId,
706
+ chain: "zcash-transparent",
707
+ encryptionSeed,
708
+ });
709
+ signatures.push(Buffer.from(signResult.signature));
710
+ }
711
+ // Step 4: Attach signatures
712
+ const txHex = attachSignatures(selected, request.to, request.amount, actualFee, ourAddress, signatures, Buffer.from(pubkey), BRANCH_ID.NU5);
713
+ // Step 5: Broadcast
714
+ const broadcastTxid = await broadcastTx(zebraUrl, txHex);
715
+ // Step 6: Attest to ZAP1
716
+ let leafHash = "";
717
+ if (config.zap1ApiUrl && config.zap1ApiKey) {
718
+ try {
719
+ const attestResp = await fetch(`${config.zap1ApiUrl}/attest`, {
720
+ method: "POST",
721
+ headers: {
722
+ "Content-Type": "application/json",
723
+ "Authorization": `Bearer ${config.zap1ApiKey}`,
724
+ },
725
+ body: JSON.stringify({
726
+ event_type: "AGENT_ACTION",
727
+ agent_id: walletId,
728
+ action: "transparent_spend",
729
+ chain_txid: broadcastTxid,
730
+ recipient: request.to,
731
+ amount: request.amount,
732
+ fee: actualFee,
733
+ memo: request.memo || "",
734
+ }),
735
+ });
736
+ if (attestResp.ok) {
737
+ const attestData = (await attestResp.json());
738
+ leafHash = attestData.leaf_hash || "";
739
+ }
740
+ }
741
+ catch {
742
+ // Attestation failure is non-fatal - tx already broadcast
743
+ }
744
+ }
745
+ return {
746
+ txid: broadcastTxid,
747
+ leafHash,
748
+ chain: "zcash-transparent",
749
+ policyChecked: false, // policy enforcement via Move module is separate
750
+ };
501
751
  }
502
752
  /**
503
753
  * Spend from a Bitcoin wallet.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Zcash v5 transparent transaction builder with ZIP 244 sighash.
3
+ *
4
+ * Builds P2PKH transactions for MPC-signed transparent spends.
5
+ * The signing itself happens via Ika dWallet (secp256k1 ECDSA).
6
+ * This module handles everything else: UTXO fetch, TX structure,
7
+ * sighash computation, signature attachment, and broadcast.
8
+ */
9
+ export declare const BRANCH_ID: {
10
+ readonly NU5: 3268858036;
11
+ readonly NU6: 3370586197;
12
+ readonly NU61: 1307332080;
13
+ };
14
+ export interface UTXO {
15
+ txid: string;
16
+ outputIndex: number;
17
+ script: string;
18
+ satoshis: number;
19
+ }
20
+ export interface TxOutput {
21
+ address: string;
22
+ amount: number;
23
+ }
24
+ /**
25
+ * Fetch UTXOs for a transparent address from Zebra RPC.
26
+ * Uses getaddressutxos (requires Zebra with -indexer flag).
27
+ */
28
+ export declare function fetchUTXOs(zebraRpcUrl: string, tAddress: string): Promise<UTXO[]>;
29
+ /**
30
+ * Select UTXOs to cover the target amount + fee.
31
+ * Simple largest-first selection. Returns selected UTXOs and total value.
32
+ */
33
+ export declare function selectUTXOs(utxos: UTXO[], targetAmount: number, fee: number): {
34
+ selected: UTXO[];
35
+ totalInput: number;
36
+ };
37
+ /**
38
+ * Build an unsigned Zcash v5 transparent transaction.
39
+ *
40
+ * Returns the unsigned serialized TX and per-input sighashes
41
+ * that need to be signed via MPC.
42
+ */
43
+ export declare function buildUnsignedTx(utxos: UTXO[], recipient: string, amount: number, fee: number | undefined, changeAddress: string, branchId?: number): {
44
+ unsignedTx: Buffer;
45
+ sighashes: Buffer[];
46
+ txid: Buffer;
47
+ };
48
+ /**
49
+ * Attach MPC signatures to an unsigned transaction.
50
+ *
51
+ * Takes the original UTXO list (to reconstruct inputs/outputs),
52
+ * DER-encoded signatures from MPC, and the compressed pubkey.
53
+ * Returns hex-encoded signed transaction ready for broadcast.
54
+ */
55
+ export declare function attachSignatures(utxos: UTXO[], recipient: string, amount: number, fee: number, changeAddress: string, signatures: Buffer[], pubkey: Buffer, branchId?: number): string;
56
+ /**
57
+ * Broadcast a signed transaction via Zebra RPC.
58
+ * Returns the txid on success.
59
+ */
60
+ export declare function broadcastTx(zebraRpcUrl: string, txHex: string): Promise<string>;
61
+ /**
62
+ * Estimate fee for a transparent P2PKH transaction.
63
+ *
64
+ * ZIP 317 marginal fee: max(grace_actions, logical_actions) * 5000
65
+ * For simple P2PKH: 1 input + 2 outputs = 2 logical actions = 10000 zatoshis
66
+ * Each additional input adds 1 logical action = +5000 zatoshis
67
+ */
68
+ export declare function estimateFee(numInputs: number, numOutputs: number): number;
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Zcash v5 transparent transaction builder with ZIP 244 sighash.
3
+ *
4
+ * Builds P2PKH transactions for MPC-signed transparent spends.
5
+ * The signing itself happens via Ika dWallet (secp256k1 ECDSA).
6
+ * This module handles everything else: UTXO fetch, TX structure,
7
+ * sighash computation, signature attachment, and broadcast.
8
+ */
9
+ import blakejs from "blakejs";
10
+ const { blake2bInit, blake2bUpdate, blake2bFinal } = blakejs;
11
+ import { createHash } from "node:crypto";
12
+ // Zcash v5 transaction constants
13
+ const TX_VERSION = 5;
14
+ const TX_VERSION_GROUP_ID = 0x26a7270a;
15
+ // Consensus branch IDs
16
+ export const BRANCH_ID = {
17
+ NU5: 0xc2d6d0b4,
18
+ NU6: 0xc8e71055,
19
+ NU61: 0x4dec4df0,
20
+ };
21
+ // SIGHASH flags
22
+ const SIGHASH_ALL = 0x01;
23
+ // Script opcodes for P2PKH
24
+ const OP_DUP = 0x76;
25
+ const OP_HASH160 = 0xa9;
26
+ const OP_EQUALVERIFY = 0x88;
27
+ const OP_CHECKSIG = 0xac;
28
+ // Zcash t-address version prefixes (for decoding)
29
+ const T_ADDR_VERSIONS = {
30
+ "1cb8": { mainnet: true }, // t1...
31
+ "1d25": { mainnet: false }, // tm...
32
+ };
33
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
34
+ function sha256(data) {
35
+ return createHash("sha256").update(data).digest();
36
+ }
37
+ function hash160(data) {
38
+ return createHash("ripemd160").update(sha256(data)).digest();
39
+ }
40
+ // BLAKE2b-256 with personalization
41
+ // blakejs types don't expose the personal param on blake2bInit, but the JS does
42
+ function blake2b256(data, personal) {
43
+ const ctx = blake2bInit(32, undefined, undefined, personal);
44
+ blake2bUpdate(ctx, data);
45
+ return Buffer.from(blake2bFinal(ctx));
46
+ }
47
+ // Write uint32 little-endian into buffer
48
+ function writeU32LE(buf, value, offset) {
49
+ buf.writeUInt32LE(value >>> 0, offset);
50
+ }
51
+ // Write int64 little-endian (as two uint32s, safe for values < 2^53)
52
+ function writeI64LE(buf, value, offset) {
53
+ buf.writeUInt32LE(value & 0xffffffff, offset);
54
+ buf.writeUInt32LE(Math.floor(value / 0x100000000) & 0xffffffff, offset + 4);
55
+ }
56
+ // Compact size encoding (Bitcoin varint)
57
+ function compactSize(n) {
58
+ if (n < 0xfd) {
59
+ return Buffer.from([n]);
60
+ }
61
+ else if (n <= 0xffff) {
62
+ const buf = Buffer.alloc(3);
63
+ buf[0] = 0xfd;
64
+ buf.writeUInt16LE(n, 1);
65
+ return buf;
66
+ }
67
+ else {
68
+ const buf = Buffer.alloc(5);
69
+ buf[0] = 0xfe;
70
+ buf.writeUInt32LE(n, 1);
71
+ return buf;
72
+ }
73
+ }
74
+ // Decode a Zcash t-address to its 20-byte pubkey hash
75
+ function decodeTAddress(addr) {
76
+ // Base58 decode
77
+ let num = BigInt(0);
78
+ for (const c of addr) {
79
+ const idx = BASE58_ALPHABET.indexOf(c);
80
+ if (idx < 0)
81
+ throw new Error(`Invalid base58 character: ${c}`);
82
+ num = num * 58n + BigInt(idx);
83
+ }
84
+ // Convert to bytes (26 bytes: 2 version + 20 hash + 4 checksum)
85
+ const bytes = new Uint8Array(26);
86
+ for (let i = 25; i >= 0; i--) {
87
+ bytes[i] = Number(num & 0xffn);
88
+ num = num >> 8n;
89
+ }
90
+ // Verify checksum
91
+ const payload = bytes.subarray(0, 22);
92
+ const checksum = sha256(sha256(payload)).subarray(0, 4);
93
+ for (let i = 0; i < 4; i++) {
94
+ if (bytes[22 + i] !== checksum[i]) {
95
+ throw new Error(`Invalid t-address checksum: ${addr}`);
96
+ }
97
+ }
98
+ const versionHex = Buffer.from(bytes.subarray(0, 2)).toString("hex");
99
+ const info = T_ADDR_VERSIONS[versionHex];
100
+ if (!info) {
101
+ throw new Error(`Unknown t-address version: 0x${versionHex}`);
102
+ }
103
+ return {
104
+ pubkeyHash: Buffer.from(bytes.subarray(2, 22)),
105
+ mainnet: info.mainnet,
106
+ };
107
+ }
108
+ // Build a P2PKH scriptPubKey from a 20-byte pubkey hash
109
+ function p2pkhScript(pubkeyHash) {
110
+ // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
111
+ const script = Buffer.alloc(25);
112
+ script[0] = OP_DUP;
113
+ script[1] = OP_HASH160;
114
+ script[2] = 0x14; // push 20 bytes
115
+ pubkeyHash.copy(script, 3);
116
+ script[23] = OP_EQUALVERIFY;
117
+ script[24] = OP_CHECKSIG;
118
+ return script;
119
+ }
120
+ // Build P2PKH scriptPubKey from a t-address string
121
+ function scriptFromAddress(addr) {
122
+ const { pubkeyHash } = decodeTAddress(addr);
123
+ return p2pkhScript(pubkeyHash);
124
+ }
125
+ // Reverse a hex-encoded txid (internal byte order is reversed)
126
+ function reverseTxid(txid) {
127
+ const buf = Buffer.from(txid, "hex");
128
+ if (buf.length !== 32)
129
+ throw new Error(`Invalid txid length: ${buf.length}`);
130
+ return Buffer.from(buf.reverse());
131
+ }
132
+ // Consensus branch ID as 4-byte LE buffer
133
+ function branchIdBytes(branchId) {
134
+ const buf = Buffer.alloc(4);
135
+ writeU32LE(buf, branchId, 0);
136
+ return buf;
137
+ }
138
+ // ZIP 244 sighash computation for v5 transparent transactions
139
+ // Personalization string as bytes, padded/truncated to 16 bytes
140
+ function personalization(tag, suffix) {
141
+ const tagBytes = Buffer.from(tag, "ascii");
142
+ if (suffix) {
143
+ const result = Buffer.alloc(16);
144
+ tagBytes.copy(result, 0, 0, Math.min(tagBytes.length, 12));
145
+ suffix.copy(result, 12, 0, 4);
146
+ return result;
147
+ }
148
+ // Pad to 16 bytes with zeros
149
+ const result = Buffer.alloc(16);
150
+ tagBytes.copy(result, 0, 0, Math.min(tagBytes.length, 16));
151
+ return result;
152
+ }
153
+ // Hash of all prevouts (txid + index) for transparent inputs
154
+ function hashPrevouts(inputs, branchId) {
155
+ const parts = [];
156
+ for (const inp of inputs) {
157
+ const outpoint = Buffer.alloc(36);
158
+ inp.prevTxid.copy(outpoint, 0);
159
+ writeU32LE(outpoint, inp.prevIndex, 32);
160
+ parts.push(outpoint);
161
+ }
162
+ const data = Buffer.concat(parts);
163
+ return blake2b256(data, personalization("ZTxIdPrevoutHash"));
164
+ }
165
+ // Hash of all input amounts
166
+ function hashAmounts(inputs, branchId) {
167
+ const data = Buffer.alloc(inputs.length * 8);
168
+ for (let i = 0; i < inputs.length; i++) {
169
+ writeI64LE(data, inputs[i].value, i * 8);
170
+ }
171
+ return blake2b256(data, personalization("ZTxTrAmountsHash"));
172
+ }
173
+ // Hash of all input scriptPubKeys
174
+ function hashScriptPubKeys(inputs, branchId) {
175
+ const parts = [];
176
+ for (const inp of inputs) {
177
+ parts.push(compactSize(inp.script.length));
178
+ parts.push(inp.script);
179
+ }
180
+ const data = Buffer.concat(parts);
181
+ return blake2b256(data, personalization("ZTxTrScriptsHash"));
182
+ }
183
+ // Hash of all sequences
184
+ function hashSequences(inputs, branchId) {
185
+ const data = Buffer.alloc(inputs.length * 4);
186
+ for (let i = 0; i < inputs.length; i++) {
187
+ writeU32LE(data, inputs[i].sequence, i * 4);
188
+ }
189
+ return blake2b256(data, personalization("ZTxIdSequencHash"));
190
+ }
191
+ // Hash of all transparent outputs
192
+ function hashOutputs(outputs, branchId) {
193
+ const parts = [];
194
+ for (const out of outputs) {
195
+ const valueBuf = Buffer.alloc(8);
196
+ writeI64LE(valueBuf, out.value, 0);
197
+ parts.push(valueBuf);
198
+ parts.push(compactSize(out.script.length));
199
+ parts.push(out.script);
200
+ }
201
+ const data = Buffer.concat(parts);
202
+ return blake2b256(data, personalization("ZTxIdOutputsHash"));
203
+ }
204
+ // Full transparent digest for txid (ZIP 244 T.2)
205
+ // transparent_digest = BLAKE2b("ZTxIdTranspaHash", prevouts || sequences || outputs)
206
+ function transparentDigest(inputs, outputs, branchId) {
207
+ if (inputs.length === 0 && outputs.length === 0) {
208
+ return blake2b256(Buffer.alloc(0), personalization("ZTxIdTranspaHash"));
209
+ }
210
+ const prevoutsDigest = hashPrevouts(inputs, branchId);
211
+ const sequenceDigest = hashSequences(inputs, branchId);
212
+ const outputsDigest = hashOutputs(outputs, branchId);
213
+ return blake2b256(Buffer.concat([prevoutsDigest, sequenceDigest, outputsDigest]), personalization("ZTxIdTranspaHash"));
214
+ }
215
+ // Sapling digest (empty bundle)
216
+ function emptyBundleDigest(tag) {
217
+ return blake2b256(Buffer.alloc(0), personalization(tag));
218
+ }
219
+ // Header digest (ZIP 244 T.1)
220
+ function headerDigest(version, versionGroupId, branchId, lockTime, expiryHeight) {
221
+ const data = Buffer.alloc(4 + 4 + 4 + 4 + 4);
222
+ writeU32LE(data, (version | (1 << 31)) >>> 0, 0);
223
+ writeU32LE(data, versionGroupId, 4);
224
+ writeU32LE(data, branchId, 8);
225
+ writeU32LE(data, lockTime, 12);
226
+ writeU32LE(data, expiryHeight, 16);
227
+ return blake2b256(data, personalization("ZTxIdHeadersHash"));
228
+ }
229
+ // Transaction digest for txid (ZIP 244 T)
230
+ function txidDigest(inputs, outputs, branchId, lockTime, expiryHeight) {
231
+ const hdrDigest = headerDigest(TX_VERSION, TX_VERSION_GROUP_ID, branchId, lockTime, expiryHeight);
232
+ const txpDigest = transparentDigest(inputs, outputs, branchId);
233
+ const sapDigest = emptyBundleDigest("ZTxIdSaplingHash");
234
+ const orchDigest = emptyBundleDigest("ZTxIdOrchardHash");
235
+ return blake2b256(Buffer.concat([hdrDigest, txpDigest, sapDigest, orchDigest]), personalization("ZcashTxHash__", branchIdBytes(branchId)));
236
+ }
237
+ // Per-input sighash for signing (ZIP 244 signature_digest)
238
+ // Structure: BLAKE2b("ZcashTxHash_" || BRANCH_ID,
239
+ // S.1: header_digest
240
+ // S.2: transparent_sig_digest (NOT the txid transparent digest)
241
+ // S.3: sapling_digest
242
+ // S.4: orchard_digest
243
+ // )
244
+ function transparentSighash(inputs, outputs, branchId, lockTime, expiryHeight, inputIndex, hashType) {
245
+ // S.1: header digest (same as T.1)
246
+ const hdrDigest = headerDigest(TX_VERSION, TX_VERSION_GROUP_ID, branchId, lockTime, expiryHeight);
247
+ // S.2: transparent_sig_digest
248
+ // For SIGHASH_ALL without ANYONECANPAY:
249
+ // S.2a: hash_type (1 byte)
250
+ // S.2b: prevouts_sig_digest = prevouts_digest (same as T.2a)
251
+ // S.2c: amounts_sig_digest
252
+ // S.2d: scriptpubkeys_sig_digest
253
+ // S.2e: sequence_sig_digest = sequence_digest (same as T.2b)
254
+ // S.2f: outputs_sig_digest = outputs_digest (same as T.2c)
255
+ // S.2g: txin_sig_digest (per-input)
256
+ const prevoutsSigDigest = hashPrevouts(inputs, branchId);
257
+ const amountsSigDigest = hashAmounts(inputs, branchId);
258
+ const scriptpubkeysSigDigest = hashScriptPubKeys(inputs, branchId);
259
+ const sequenceSigDigest = hashSequences(inputs, branchId);
260
+ const outputsSigDigest = hashOutputs(outputs, branchId);
261
+ // S.2g: txin_sig_digest for the input being signed
262
+ const inp = inputs[inputIndex];
263
+ const prevout = Buffer.alloc(36);
264
+ inp.prevTxid.copy(prevout, 0);
265
+ writeU32LE(prevout, inp.prevIndex, 32);
266
+ const valueBuf = Buffer.alloc(8);
267
+ writeI64LE(valueBuf, inp.value, 0);
268
+ const seqBuf = Buffer.alloc(4);
269
+ writeU32LE(seqBuf, inp.sequence, 0);
270
+ const txinSigDigest = blake2b256(Buffer.concat([prevout, valueBuf, compactSize(inp.script.length), inp.script, seqBuf]), personalization("Zcash___TxInHash"));
271
+ // S.2: transparent_sig_digest
272
+ const transparentSigDigest = blake2b256(Buffer.concat([
273
+ Buffer.from([hashType]),
274
+ prevoutsSigDigest,
275
+ amountsSigDigest,
276
+ scriptpubkeysSigDigest,
277
+ sequenceSigDigest,
278
+ outputsSigDigest,
279
+ txinSigDigest,
280
+ ]), personalization("ZTxIdTranspaHash"));
281
+ // S.3: sapling digest (empty)
282
+ const sapDigest = emptyBundleDigest("ZTxIdSaplingHash");
283
+ // S.4: orchard digest (empty)
284
+ const orchDigest = emptyBundleDigest("ZTxIdOrchardHash");
285
+ // Final signature_digest
286
+ return blake2b256(Buffer.concat([hdrDigest, transparentSigDigest, sapDigest, orchDigest]), personalization("ZcashTxHash_", branchIdBytes(branchId)));
287
+ }
288
+ // Serialize a v5 transparent-only transaction to raw bytes.
289
+ // If scriptSigs is provided, inputs get signed scriptSigs.
290
+ // Otherwise inputs get empty scriptSigs (unsigned).
291
+ function serializeTx(inputs, outputs, branchId, lockTime, expiryHeight, scriptSigs) {
292
+ const parts = [];
293
+ // Header
294
+ const header = Buffer.alloc(4);
295
+ // v5: version field encodes (version | fOverwintered flag)
296
+ // fOverwintered = 1 << 31
297
+ writeU32LE(header, (TX_VERSION | (1 << 31)) >>> 0, 0);
298
+ parts.push(header);
299
+ // nVersionGroupId
300
+ const vgid = Buffer.alloc(4);
301
+ writeU32LE(vgid, TX_VERSION_GROUP_ID, 0);
302
+ parts.push(vgid);
303
+ // nConsensusBranchId
304
+ parts.push(branchIdBytes(branchId));
305
+ // nLockTime
306
+ const lt = Buffer.alloc(4);
307
+ writeU32LE(lt, lockTime, 0);
308
+ parts.push(lt);
309
+ // nExpiryHeight
310
+ const eh = Buffer.alloc(4);
311
+ writeU32LE(eh, expiryHeight, 0);
312
+ parts.push(eh);
313
+ // Transparent bundle
314
+ // tx_in_count
315
+ parts.push(compactSize(inputs.length));
316
+ // tx_in
317
+ for (let i = 0; i < inputs.length; i++) {
318
+ const inp = inputs[i];
319
+ // prevout
320
+ const outpoint = Buffer.alloc(36);
321
+ inp.prevTxid.copy(outpoint, 0);
322
+ writeU32LE(outpoint, inp.prevIndex, 32);
323
+ parts.push(outpoint);
324
+ // scriptSig
325
+ const sig = scriptSigs ? scriptSigs[i] : Buffer.alloc(0);
326
+ parts.push(compactSize(sig.length));
327
+ if (sig.length > 0)
328
+ parts.push(sig);
329
+ // sequence
330
+ const seq = Buffer.alloc(4);
331
+ writeU32LE(seq, inp.sequence, 0);
332
+ parts.push(seq);
333
+ }
334
+ // tx_out_count
335
+ parts.push(compactSize(outputs.length));
336
+ // tx_out
337
+ for (const out of outputs) {
338
+ const valueBuf = Buffer.alloc(8);
339
+ writeI64LE(valueBuf, out.value, 0);
340
+ parts.push(valueBuf);
341
+ parts.push(compactSize(out.script.length));
342
+ parts.push(out.script);
343
+ }
344
+ // Sapling bundle (empty)
345
+ parts.push(compactSize(0)); // nSpendsSapling
346
+ parts.push(compactSize(0)); // nOutputsSapling
347
+ // Orchard bundle (empty)
348
+ parts.push(Buffer.from([0x00])); // nActionsOrchard = 0
349
+ return Buffer.concat(parts);
350
+ }
351
+ /**
352
+ * Fetch UTXOs for a transparent address from Zebra RPC.
353
+ * Uses getaddressutxos (requires Zebra with -indexer flag).
354
+ */
355
+ export async function fetchUTXOs(zebraRpcUrl, tAddress) {
356
+ const resp = await fetch(zebraRpcUrl, {
357
+ method: "POST",
358
+ headers: { "Content-Type": "application/json" },
359
+ body: JSON.stringify({
360
+ jsonrpc: "2.0",
361
+ id: 1,
362
+ method: "getaddressutxos",
363
+ params: [{ addresses: [tAddress] }],
364
+ }),
365
+ });
366
+ if (!resp.ok) {
367
+ throw new Error(`Zebra RPC error: ${resp.status} ${resp.statusText}`);
368
+ }
369
+ const data = (await resp.json());
370
+ if (data.error) {
371
+ throw new Error(`Zebra RPC: ${data.error.message || JSON.stringify(data.error)}`);
372
+ }
373
+ const utxos = (data.result || []).map((u) => ({
374
+ txid: u.txid,
375
+ outputIndex: u.outputIndex ?? u.vout ?? u.index,
376
+ script: u.script ?? u.scriptPubKey,
377
+ satoshis: u.satoshis ?? u.value ?? u.amount,
378
+ }));
379
+ return utxos;
380
+ }
381
+ /**
382
+ * Select UTXOs to cover the target amount + fee.
383
+ * Simple largest-first selection. Returns selected UTXOs and total value.
384
+ */
385
+ export function selectUTXOs(utxos, targetAmount, fee) {
386
+ const needed = targetAmount + fee;
387
+ // Sort descending by value
388
+ const sorted = [...utxos].sort((a, b) => b.satoshis - a.satoshis);
389
+ const selected = [];
390
+ let total = 0;
391
+ for (const u of sorted) {
392
+ selected.push(u);
393
+ total += u.satoshis;
394
+ if (total >= needed)
395
+ break;
396
+ }
397
+ if (total < needed) {
398
+ throw new Error(`Insufficient funds: have ${total} zatoshis, need ${needed} (${targetAmount} + ${fee} fee)`);
399
+ }
400
+ return { selected, totalInput: total };
401
+ }
402
+ /**
403
+ * Build an unsigned Zcash v5 transparent transaction.
404
+ *
405
+ * Returns the unsigned serialized TX and per-input sighashes
406
+ * that need to be signed via MPC.
407
+ */
408
+ export function buildUnsignedTx(utxos, recipient, amount, fee = 10000, changeAddress, branchId = BRANCH_ID.NU61) {
409
+ if (utxos.length === 0)
410
+ throw new Error("No UTXOs provided");
411
+ if (amount <= 0)
412
+ throw new Error("Amount must be positive");
413
+ // Build inputs
414
+ const inputs = utxos.map((u) => ({
415
+ prevTxid: reverseTxid(u.txid),
416
+ prevIndex: u.outputIndex,
417
+ script: Buffer.from(u.script, "hex"),
418
+ value: u.satoshis,
419
+ sequence: 0xffffffff,
420
+ }));
421
+ // Build outputs
422
+ const totalInput = utxos.reduce((s, u) => s + u.satoshis, 0);
423
+ const change = totalInput - amount - fee;
424
+ const outputs = [
425
+ { value: amount, script: scriptFromAddress(recipient) },
426
+ ];
427
+ if (change > 0) {
428
+ // Dust threshold: skip change if below 546 zatoshis
429
+ if (change >= 546) {
430
+ outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
431
+ }
432
+ }
433
+ else if (change < 0) {
434
+ throw new Error(`UTXOs total ${totalInput} < amount ${amount} + fee ${fee}`);
435
+ }
436
+ const lockTime = 0;
437
+ const expiryHeight = 0;
438
+ // Serialize unsigned TX
439
+ const unsignedTx = serializeTx(inputs, outputs, branchId, lockTime, expiryHeight);
440
+ // Compute per-input sighashes
441
+ const sighashes = [];
442
+ for (let i = 0; i < inputs.length; i++) {
443
+ const sh = transparentSighash(inputs, outputs, branchId, lockTime, expiryHeight, i, SIGHASH_ALL);
444
+ sighashes.push(sh);
445
+ }
446
+ // Compute txid (hash of unsigned TX structure per ZIP 244)
447
+ const txid = txidDigest(inputs, outputs, branchId, lockTime, expiryHeight);
448
+ return { unsignedTx, sighashes, txid };
449
+ }
450
+ /**
451
+ * Build a P2PKH scriptSig from a DER signature and compressed pubkey.
452
+ *
453
+ * Format: <sig_length> <DER_sig + SIGHASH_ALL_byte> <pubkey_length> <compressed_pubkey>
454
+ */
455
+ function buildScriptSig(derSig, pubkey) {
456
+ // Append SIGHASH_ALL byte to signature
457
+ const sigWithHashType = Buffer.concat([derSig, Buffer.from([SIGHASH_ALL])]);
458
+ const parts = [
459
+ Buffer.from([sigWithHashType.length]),
460
+ sigWithHashType,
461
+ Buffer.from([pubkey.length]),
462
+ pubkey,
463
+ ];
464
+ return Buffer.concat(parts);
465
+ }
466
+ /**
467
+ * Attach MPC signatures to an unsigned transaction.
468
+ *
469
+ * Takes the original UTXO list (to reconstruct inputs/outputs),
470
+ * DER-encoded signatures from MPC, and the compressed pubkey.
471
+ * Returns hex-encoded signed transaction ready for broadcast.
472
+ */
473
+ export function attachSignatures(utxos, recipient, amount, fee, changeAddress, signatures, pubkey, branchId = BRANCH_ID.NU61) {
474
+ if (signatures.length !== utxos.length) {
475
+ throw new Error(`Expected ${utxos.length} signatures, got ${signatures.length}`);
476
+ }
477
+ if (pubkey.length !== 33) {
478
+ throw new Error(`Expected 33-byte compressed pubkey, got ${pubkey.length}`);
479
+ }
480
+ // Rebuild inputs/outputs (same as buildUnsignedTx)
481
+ const inputs = utxos.map((u) => ({
482
+ prevTxid: reverseTxid(u.txid),
483
+ prevIndex: u.outputIndex,
484
+ script: Buffer.from(u.script, "hex"),
485
+ value: u.satoshis,
486
+ sequence: 0xffffffff,
487
+ }));
488
+ const totalInput = utxos.reduce((s, u) => s + u.satoshis, 0);
489
+ const change = totalInput - amount - fee;
490
+ const outputs = [
491
+ { value: amount, script: scriptFromAddress(recipient) },
492
+ ];
493
+ if (change >= 546) {
494
+ outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
495
+ }
496
+ // Build scriptSigs
497
+ const scriptSigs = signatures.map((sig) => buildScriptSig(sig, pubkey));
498
+ // Serialize signed TX
499
+ const signedTx = serializeTx(inputs, outputs, branchId, 0, 0, scriptSigs);
500
+ return signedTx.toString("hex");
501
+ }
502
+ /**
503
+ * Broadcast a signed transaction via Zebra RPC.
504
+ * Returns the txid on success.
505
+ */
506
+ export async function broadcastTx(zebraRpcUrl, txHex) {
507
+ const resp = await fetch(zebraRpcUrl, {
508
+ method: "POST",
509
+ headers: { "Content-Type": "application/json" },
510
+ body: JSON.stringify({
511
+ jsonrpc: "2.0",
512
+ id: 1,
513
+ method: "sendrawtransaction",
514
+ params: [txHex],
515
+ }),
516
+ });
517
+ if (!resp.ok) {
518
+ throw new Error(`Zebra RPC error: ${resp.status} ${resp.statusText}`);
519
+ }
520
+ const data = (await resp.json());
521
+ if (data.error) {
522
+ throw new Error(`Broadcast failed: ${data.error.message || JSON.stringify(data.error)}`);
523
+ }
524
+ return data.result;
525
+ }
526
+ /**
527
+ * Estimate fee for a transparent P2PKH transaction.
528
+ *
529
+ * ZIP 317 marginal fee: max(grace_actions, logical_actions) * 5000
530
+ * For simple P2PKH: 1 input + 2 outputs = 2 logical actions = 10000 zatoshis
531
+ * Each additional input adds 1 logical action = +5000 zatoshis
532
+ */
533
+ export function estimateFee(numInputs, numOutputs) {
534
+ const logicalActions = Math.max(numInputs, numOutputs);
535
+ const graceActions = 2;
536
+ return Math.max(graceActions, logicalActions) * 5000;
537
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frontiercompute/zcash-ika",
3
- "version": "0.2.0",
4
- "description": "Split-key Zcash custody via Ika dWallet. secp256k1 MPC for transparent, hybrid model for shielded.",
3
+ "version": "0.4.0",
4
+ "description": "Split-key custody for Zcash, Bitcoin, and EVM. 2PC-MPC signing, on-chain spend policy, transparent TX builder, ZAP1 attestation.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
@@ -11,18 +11,39 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@ika.xyz/sdk": "^0.3.1",
14
- "@mysten/sui": "^2.5.0"
14
+ "@mysten/sui": "^2.5.0",
15
+ "blakejs": "^1.2.1",
16
+ "elliptic": "^6.6.1"
15
17
  },
16
18
  "devDependencies": {
17
19
  "@types/node": "^25.5.2",
18
20
  "typescript": "^5.4.0"
19
21
  },
20
- "files": ["dist/index.js", "dist/index.d.ts", "dist/hybrid.js", "dist/hybrid.d.ts"],
22
+ "files": [
23
+ "dist/index.js",
24
+ "dist/index.d.ts",
25
+ "dist/tx-builder.js",
26
+ "dist/tx-builder.d.ts",
27
+ "dist/hybrid.js",
28
+ "dist/hybrid.d.ts"
29
+ ],
21
30
  "repository": {
22
31
  "type": "git",
23
32
  "url": "https://github.com/Frontier-Compute/zcash-ika.git"
24
33
  },
25
34
  "author": "zk_nd3r <zk_nd3r@frontiercompute.io>",
26
- "keywords": ["zcash", "ika", "dwallet", "mpc", "custody", "bitcoin", "split-key"],
35
+ "keywords": [
36
+ "zcash",
37
+ "ika",
38
+ "dwallet",
39
+ "mpc",
40
+ "custody",
41
+ "bitcoin",
42
+ "split-key",
43
+ "policy",
44
+ "sui-move",
45
+ "transparent-tx",
46
+ "attestation"
47
+ ],
27
48
  "license": "MIT"
28
49
  }