@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 +108 -56
- package/dist/index.d.ts +51 -9
- package/dist/index.js +268 -18
- package/dist/tx-builder.d.ts +68 -0
- package/dist/tx-builder.js +537 -0
- package/package.json +26 -5
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
|
[](https://www.npmjs.com/package/@frontiercompute/zcash-ika)
|
|
6
6
|
|
|
7
|
-
## What this
|
|
7
|
+
## What this does
|
|
8
8
|
|
|
9
|
-
One secp256k1 dWallet on [Ika's 2PC-MPC network](https://ika.xyz) signs for
|
|
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
|
-
|
|
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 |
|
|
16
|
-
| Bitcoin | secp256k1 | ECDSA | DoubleSHA256 | Same dWallet,
|
|
17
|
-
| Ethereum/EVM | secp256k1 | ECDSA | KECCAK256 | Same dWallet,
|
|
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
|
|
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
|
-
##
|
|
36
|
+
## Quick start
|
|
34
37
|
|
|
35
38
|
```typescript
|
|
36
39
|
import {
|
|
37
40
|
createWallet,
|
|
38
41
|
sign,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
52
|
-
const
|
|
53
|
-
console.log("dWallet:",
|
|
54
|
-
console.log("
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
74
|
-
const
|
|
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
|
-
##
|
|
87
|
+
## Architecture
|
|
78
88
|
|
|
79
89
|
```
|
|
80
|
-
Operator (
|
|
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
|
-
+--
|
|
89
|
-
| max per tx, daily cap,
|
|
98
|
+
+-- Spend Policy (Sui Move contract)
|
|
99
|
+
| max per tx, daily cap, recipient whitelist, freeze
|
|
90
100
|
|
|
|
91
|
-
+-- Sign ZEC transparent
|
|
92
|
-
+-- Sign BTC
|
|
93
|
-
+-- Sign ETH
|
|
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
|
-
+--
|
|
98
|
-
+--
|
|
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
|
|
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.
|
|
104
|
-
2.
|
|
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
|
-
|
|
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
|
|
147
|
+
secp256k1 dWallet on Ika testnet:
|
|
111
148
|
|
|
112
149
|
- dWalletId: `0xd9055400c88aeae675413b78143aa54e25eca7061ab659f54a42167cbfdd7aec`
|
|
113
150
|
- TX: [`CYrS5X1S3itHUtux4qS35AJz5AAyUaJYeWZuqm1CcX2L`](https://testnet.suivision.xyz/txblock/CYrS5X1S3itHUtux4qS35AJz5AAyUaJYeWZuqm1CcX2L)
|
|
114
|
-
-
|
|
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
|
|
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
|
-
*
|
|
172
|
-
*
|
|
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
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
|
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
|
|
476
|
-
*
|
|
477
|
-
* The
|
|
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(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
"
|
|
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
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
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
|
|
665
|
+
* 6. Attest to ZAP1 as AGENT_ACTION
|
|
493
666
|
*/
|
|
494
667
|
export async function spendTransparent(config, walletId, encryptionSeed, request) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
4
|
-
"description": "Split-key Zcash
|
|
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": [
|
|
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": [
|
|
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
|
}
|