pivx-wallet 0.1.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/LICENSE +22 -0
- package/README.md +36 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/shield-bindings.d.ts +29 -0
- package/dist/shield-bindings.js +65 -0
- package/dist/transparent-tx.d.ts +27 -0
- package/dist/transparent-tx.js +132 -0
- package/dist/transparent-wallet.d.ts +103 -0
- package/dist/transparent-wallet.js +233 -0
- package/dist/transparent.d.ts +31 -0
- package/dist/transparent.js +82 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +4 -0
- package/dist/wallet.d.ts +161 -0
- package/dist/wallet.js +662 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The PIVX developers
|
|
4
|
+
Wallet crypto core: Copyright (c) PIVX Labs (pivx-shield)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pivx-wallet
|
|
2
|
+
|
|
3
|
+
Standalone [PIVX](https://pivx.org) wallet SDK: the application owns the keys.
|
|
4
|
+
ZIP32 derivation, shielded (SHIELD/Sapling) block scanning with note
|
|
5
|
+
decryption, checkpointed sync verified against `finalsaplingroot`, and
|
|
6
|
+
locally-proved shielded transactions — plus a transparent (BIP44 HD, UTXO)
|
|
7
|
+
wallet. The node is only a chain-data source; point it at one you trust.
|
|
8
|
+
Amounts are integer satoshis.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
npm install pivx-wallet pivx-rpc
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node >= 20.19. ESM only.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { PivxClient } from 'pivx-rpc';
|
|
22
|
+
import { PivxWallet } from 'pivx-wallet';
|
|
23
|
+
|
|
24
|
+
// exchange deposit detection: keys never on this host
|
|
25
|
+
const wallet = await PivxWallet.create({ viewingKey, birthHeight: 4_800_000 });
|
|
26
|
+
await wallet.sync(new PivxClient({ user, pass }));
|
|
27
|
+
console.log(wallet.getBalance()); // sats
|
|
28
|
+
|
|
29
|
+
// standalone send (with a spending key + prover loaded)
|
|
30
|
+
const txid = await wallet.send(client, { to: 'ps1…', amount: 150_000_000, memo: 'hi' });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A wallet is built from a seed, spending key, or viewing key — watch-only is a
|
|
34
|
+
capability level, upgradeable in place.
|
|
35
|
+
|
|
36
|
+
Full docs, security model, and examples: https://github.com/Liquid369/pivx-js-sdk
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { PivxWallet, NoSpendAuthorityError, ScanDivergedError } from './wallet.js';
|
|
2
|
+
export { deriveKey, p2pkhAddress, encodeAddress, decodeAddress, isValidAddress, hash160, type AddressKind, type DecodedAddress, type TransparentKey, } from './transparent.js';
|
|
3
|
+
export { buildTransparentTx, scriptPubKeyForAddress, type TxInput, type TxOutput, } from './transparent-tx.js';
|
|
4
|
+
export { TransparentWallet, type OwnedUtxo, type ScannedOutput, type ScannedInput, } from './transparent-wallet.js';
|
|
5
|
+
export type { ProvingOptions } from './shield-bindings.js';
|
|
6
|
+
export type { SpendableNote, WalletBlock, TransparentInput, CreateWalletOptions, CreateTransactionOptions, BuiltTransaction, SyncOptions, WalletState, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { PivxWallet, NoSpendAuthorityError, ScanDivergedError } from './wallet.js';
|
|
2
|
+
export { deriveKey, p2pkhAddress, encodeAddress, decodeAddress, isValidAddress, hash160, } from './transparent.js';
|
|
3
|
+
export { buildTransparentTx, scriptPubKeyForAddress, } from './transparent-tx.js';
|
|
4
|
+
export { TransparentWallet, } from './transparent-wallet.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the pivx-shield WASM module once per process.
|
|
3
|
+
*
|
|
4
|
+
* Two builds exist. The single-core build (`pivx-shield-rust`) runs anywhere
|
|
5
|
+
* but proves transactions on one thread, which is slow. The multicore build
|
|
6
|
+
* (`pivx-shield-rust-multicore`) proves in parallel across Web Workers and is
|
|
7
|
+
* much faster, but it needs a browser with SharedArrayBuffer available under
|
|
8
|
+
* cross-origin isolation. Multicore is opt-in through {@link ProvingOptions}.
|
|
9
|
+
*
|
|
10
|
+
* For server-side proving throughput, use the native Rust SDK rather than the
|
|
11
|
+
* WASM build — native proving does not have this constraint.
|
|
12
|
+
*/
|
|
13
|
+
import * as singleCore from 'pivx-shield-rust';
|
|
14
|
+
export type Shield = typeof singleCore;
|
|
15
|
+
export interface ProvingOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Use the multicore WASM build and spin up a worker thread pool. Requires a
|
|
18
|
+
* browser with SharedArrayBuffer (cross-origin isolated). Ignored with a
|
|
19
|
+
* warning where that is unavailable, falling back to single-core.
|
|
20
|
+
*/
|
|
21
|
+
multicore?: boolean;
|
|
22
|
+
/** Worker threads for the multicore pool. Defaults to the hardware core count. */
|
|
23
|
+
threads?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Load and memoize the WASM module. The first call decides single- vs
|
|
27
|
+
* multicore for the process; later calls return the same instance.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadShield(opts?: ProvingOptions): Promise<Shield>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the pivx-shield WASM module once per process.
|
|
3
|
+
*
|
|
4
|
+
* Two builds exist. The single-core build (`pivx-shield-rust`) runs anywhere
|
|
5
|
+
* but proves transactions on one thread, which is slow. The multicore build
|
|
6
|
+
* (`pivx-shield-rust-multicore`) proves in parallel across Web Workers and is
|
|
7
|
+
* much faster, but it needs a browser with SharedArrayBuffer available under
|
|
8
|
+
* cross-origin isolation. Multicore is opt-in through {@link ProvingOptions}.
|
|
9
|
+
*
|
|
10
|
+
* For server-side proving throughput, use the native Rust SDK rather than the
|
|
11
|
+
* WASM build — native proving does not have this constraint.
|
|
12
|
+
*/
|
|
13
|
+
import * as singleCore from 'pivx-shield-rust';
|
|
14
|
+
let ready;
|
|
15
|
+
async function initSingleCore() {
|
|
16
|
+
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
17
|
+
const { readFile } = await import('node:fs/promises');
|
|
18
|
+
const { fileURLToPath } = await import('node:url');
|
|
19
|
+
const wasmUrl = import.meta.resolve('pivx-shield-rust/pivx_shield_rust_bg.wasm');
|
|
20
|
+
await singleCore.default({ module_or_path: await readFile(fileURLToPath(wasmUrl)) });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
await singleCore.default();
|
|
24
|
+
}
|
|
25
|
+
return singleCore;
|
|
26
|
+
}
|
|
27
|
+
function multicoreUsable() {
|
|
28
|
+
// The rayon thread pool is built on Web Workers + SharedArrayBuffer, so it
|
|
29
|
+
// only runs in a browser that is cross-origin isolated.
|
|
30
|
+
return (typeof SharedArrayBuffer !== 'undefined' &&
|
|
31
|
+
typeof Worker !== 'undefined' &&
|
|
32
|
+
(typeof process === 'undefined' || !process.versions?.node));
|
|
33
|
+
}
|
|
34
|
+
async function initMulticore(threads) {
|
|
35
|
+
// Loaded dynamically so single-core users need not install the package.
|
|
36
|
+
const mc = await import('pivx-shield-rust-multicore');
|
|
37
|
+
await mc.default();
|
|
38
|
+
const n = threads ?? (globalThis.navigator?.hardwareConcurrency ?? 4);
|
|
39
|
+
await mc.initThreadPool(n);
|
|
40
|
+
return mc;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Load and memoize the WASM module. The first call decides single- vs
|
|
44
|
+
* multicore for the process; later calls return the same instance.
|
|
45
|
+
*/
|
|
46
|
+
export function loadShield(opts = {}) {
|
|
47
|
+
ready ??= (async () => {
|
|
48
|
+
if (opts.multicore) {
|
|
49
|
+
if (multicoreUsable()) {
|
|
50
|
+
try {
|
|
51
|
+
return await initMulticore(opts.threads);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.warn(`multicore proving unavailable (${err.message}); using single-core`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.warn('multicore proving needs a cross-origin-isolated browser; using single-core. ' +
|
|
59
|
+
'For server-side proving use the native Rust SDK.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return initSingleCore();
|
|
63
|
+
})();
|
|
64
|
+
return ready;
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TxInput {
|
|
2
|
+
txid: string;
|
|
3
|
+
vout: number;
|
|
4
|
+
amount: number;
|
|
5
|
+
/** scriptPubKey of the output being spent (bytes, as from listunspent hex). */
|
|
6
|
+
scriptPubKey: Uint8Array;
|
|
7
|
+
/** 32-byte private key controlling the input. */
|
|
8
|
+
privateKey: Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
export interface TxOutput {
|
|
11
|
+
address: string;
|
|
12
|
+
amount: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* scriptPubKey for a destination address (P2PKH, P2SH, or exchange).
|
|
16
|
+
*
|
|
17
|
+
* An exchange address is NOT a plain P2PKH: PIVX prefixes the P2PKH script
|
|
18
|
+
* with OP_EXCHANGEADDR (0xe0) — see GetScriptForDestination in
|
|
19
|
+
* src/script/standard.cpp. Emitting a plain P2PKH would send to the wrong script.
|
|
20
|
+
*/
|
|
21
|
+
export declare function scriptPubKeyForAddress(address: string): Uint8Array;
|
|
22
|
+
/**
|
|
23
|
+
* Build and sign a transparent transaction; returns raw tx hex for
|
|
24
|
+
* `sendrawtransaction`. Caller selects inputs and includes change as an
|
|
25
|
+
* explicit output; no coin selection or fee computation is done here.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildTransparentTx(inputs: TxInput[], outputs: TxOutput[], locktime?: number): string;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build and sign transparent (LEGACY, v1) PIVX transactions.
|
|
3
|
+
*
|
|
4
|
+
* PIVX serializes int16 nVersion, int16 nType, vin, vout, nLockTime, then
|
|
5
|
+
* (sapling versions only) sapData — see src/primitives/transaction.h. For a
|
|
6
|
+
* legacy transparent tx (nVersion=1, nType=0) the leading four bytes are
|
|
7
|
+
* `01 00 00 00` with no sapData, i.e. a standard Bitcoin v1 transaction.
|
|
8
|
+
* Signing is legacy P2PKH SIGHASH_ALL.
|
|
9
|
+
*/
|
|
10
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
12
|
+
import { decodeAddress } from './transparent.js';
|
|
13
|
+
const SIGHASH_ALL = 1;
|
|
14
|
+
const hexToBytes = (h) => Uint8Array.from(h.match(/../g).map((b) => parseInt(b, 16)));
|
|
15
|
+
const bytesToHex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
16
|
+
const doubleSha256 = (d) => sha256(sha256(d));
|
|
17
|
+
class Writer {
|
|
18
|
+
parts = [];
|
|
19
|
+
u16le(n) { this.parts.push(n & 0xff, (n >> 8) & 0xff); }
|
|
20
|
+
u32le(n) { for (let i = 0; i < 4; i++)
|
|
21
|
+
this.parts.push((n >>> (8 * i)) & 0xff); }
|
|
22
|
+
u64le(n) {
|
|
23
|
+
let v = BigInt(n);
|
|
24
|
+
for (let i = 0; i < 8; i++) {
|
|
25
|
+
this.parts.push(Number(v & 0xffn));
|
|
26
|
+
v >>= 8n;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
varint(n) {
|
|
30
|
+
if (n < 0xfd)
|
|
31
|
+
this.parts.push(n);
|
|
32
|
+
else if (n <= 0xffff) {
|
|
33
|
+
this.parts.push(0xfd);
|
|
34
|
+
this.u16le(n);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
this.parts.push(0xfe);
|
|
38
|
+
this.u32le(n);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
bytes(b) { for (const x of b)
|
|
42
|
+
this.parts.push(x); }
|
|
43
|
+
script(s) { this.varint(s.length); this.bytes(s); }
|
|
44
|
+
done() { return Uint8Array.from(this.parts); }
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* scriptPubKey for a destination address (P2PKH, P2SH, or exchange).
|
|
48
|
+
*
|
|
49
|
+
* An exchange address is NOT a plain P2PKH: PIVX prefixes the P2PKH script
|
|
50
|
+
* with OP_EXCHANGEADDR (0xe0) — see GetScriptForDestination in
|
|
51
|
+
* src/script/standard.cpp. Emitting a plain P2PKH would send to the wrong script.
|
|
52
|
+
*/
|
|
53
|
+
export function scriptPubKeyForAddress(address) {
|
|
54
|
+
const d = decodeAddress(address);
|
|
55
|
+
if (d.kind === 'p2pkh') {
|
|
56
|
+
return Uint8Array.from([0x76, 0xa9, 0x14, ...d.hash, 0x88, 0xac]);
|
|
57
|
+
}
|
|
58
|
+
if (d.kind === 'exchange') {
|
|
59
|
+
return Uint8Array.from([0xe0, 0x76, 0xa9, 0x14, ...d.hash, 0x88, 0xac]);
|
|
60
|
+
}
|
|
61
|
+
if (d.kind === 'p2sh') {
|
|
62
|
+
return Uint8Array.from([0xa9, 0x14, ...d.hash, 0x87]);
|
|
63
|
+
}
|
|
64
|
+
throw new Error('sending to a cold-staking address is not supported');
|
|
65
|
+
}
|
|
66
|
+
function serialize(inputs, scriptSigs, outputs, locktime) {
|
|
67
|
+
const w = new Writer();
|
|
68
|
+
w.u16le(1); // nVersion = 1 (LEGACY)
|
|
69
|
+
w.u16le(0); // nType = 0 (NORMAL)
|
|
70
|
+
w.varint(inputs.length);
|
|
71
|
+
inputs.forEach((input, i) => {
|
|
72
|
+
const txid = hexToBytes(input.txid);
|
|
73
|
+
if (txid.length !== 32)
|
|
74
|
+
throw new Error('txid must be 32 bytes');
|
|
75
|
+
w.bytes(txid.reverse()); // little-endian prevout hash
|
|
76
|
+
w.u32le(input.vout);
|
|
77
|
+
w.script(scriptSigs[i]);
|
|
78
|
+
w.u32le(0xffffffff); // nSequence
|
|
79
|
+
});
|
|
80
|
+
w.varint(outputs.length);
|
|
81
|
+
for (const [script, value] of outputs) {
|
|
82
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
83
|
+
throw new Error('output amount must be a non-negative integer (satoshis)');
|
|
84
|
+
w.u64le(value);
|
|
85
|
+
w.script(script);
|
|
86
|
+
}
|
|
87
|
+
w.u32le(locktime);
|
|
88
|
+
return w.done();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build and sign a transparent transaction; returns raw tx hex for
|
|
92
|
+
* `sendrawtransaction`. Caller selects inputs and includes change as an
|
|
93
|
+
* explicit output; no coin selection or fee computation is done here.
|
|
94
|
+
*/
|
|
95
|
+
export function buildTransparentTx(inputs, outputs, locktime = 0) {
|
|
96
|
+
if (inputs.length === 0)
|
|
97
|
+
throw new Error('transaction has no inputs');
|
|
98
|
+
for (const input of inputs) {
|
|
99
|
+
if (input.privateKey.length !== 32)
|
|
100
|
+
throw new Error('private key must be 32 bytes');
|
|
101
|
+
if (!Number.isSafeInteger(input.amount) || input.amount < 0) {
|
|
102
|
+
throw new Error('input amount must be a non-negative integer (satoshis)');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const outScripts = outputs.map((o) => [scriptPubKeyForAddress(o.address), o.amount]);
|
|
106
|
+
const empty = inputs.map(() => new Uint8Array(0));
|
|
107
|
+
const scriptSigs = inputs.map(() => new Uint8Array(0));
|
|
108
|
+
inputs.forEach((input, i) => {
|
|
109
|
+
// Legacy SIGHASH_ALL preimage: this input's scriptSig = its prevout
|
|
110
|
+
// scriptPubKey, all others empty; append the 4-byte sighash type.
|
|
111
|
+
const preimageSigs = empty.slice();
|
|
112
|
+
preimageSigs[i] = input.scriptPubKey;
|
|
113
|
+
const preimage = serialize(inputs, preimageSigs, outScripts, locktime);
|
|
114
|
+
const w = new Writer();
|
|
115
|
+
w.bytes(preimage);
|
|
116
|
+
w.u32le(SIGHASH_ALL);
|
|
117
|
+
const digest = doubleSha256(w.done());
|
|
118
|
+
// prehash: false — sign the double-SHA256 sighash directly. Without it,
|
|
119
|
+
// @noble applies its own SHA-256 to the input, producing a signature over
|
|
120
|
+
// the wrong (triple-hashed) value that the node rejects.
|
|
121
|
+
const der = secp256k1.sign(digest, input.privateKey, { lowS: true, format: 'der', prehash: false });
|
|
122
|
+
const pubkey = secp256k1.getPublicKey(input.privateKey, true); // compressed
|
|
123
|
+
const ss = new Writer();
|
|
124
|
+
ss.varint(der.length + 1);
|
|
125
|
+
ss.bytes(der);
|
|
126
|
+
ss.bytes(Uint8Array.from([SIGHASH_ALL]));
|
|
127
|
+
ss.varint(pubkey.length);
|
|
128
|
+
ss.bytes(pubkey);
|
|
129
|
+
scriptSigs[i] = ss.done();
|
|
130
|
+
});
|
|
131
|
+
return bytesToHex(serialize(inputs, scriptSigs, outScripts, locktime));
|
|
132
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transparent wallet: HD address management, UTXO tracking (from a block scan
|
|
3
|
+
* or caller-supplied), coin selection, and sending. Complements the shielded
|
|
4
|
+
* PivxWallet; both derive from the seed.
|
|
5
|
+
*
|
|
6
|
+
* PIVX has no address index, so UTXOs are discovered either by scanning blocks
|
|
7
|
+
* ({@link scan}) or supplied by the caller ({@link addUtxo}).
|
|
8
|
+
*/
|
|
9
|
+
import type { PivxClient } from 'pivx-rpc';
|
|
10
|
+
import { type Network } from './transparent.js';
|
|
11
|
+
import { scriptPubKeyForAddress } from './transparent-tx.js';
|
|
12
|
+
/** A tracked unspent transparent output we can spend. */
|
|
13
|
+
export interface OwnedUtxo {
|
|
14
|
+
txid: string;
|
|
15
|
+
vout: number;
|
|
16
|
+
amount: number;
|
|
17
|
+
scriptPubKey: Uint8Array;
|
|
18
|
+
keyHash: string;
|
|
19
|
+
coinbase: boolean;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ScannedOutput {
|
|
23
|
+
txid: string;
|
|
24
|
+
vout: number;
|
|
25
|
+
amount: number;
|
|
26
|
+
scriptPubKey: Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
export interface ScannedInput {
|
|
29
|
+
txid: string;
|
|
30
|
+
vout: number;
|
|
31
|
+
}
|
|
32
|
+
export declare class TransparentWallet {
|
|
33
|
+
private readonly network;
|
|
34
|
+
private keys;
|
|
35
|
+
private external;
|
|
36
|
+
private change;
|
|
37
|
+
private nextExternal;
|
|
38
|
+
private nextChange;
|
|
39
|
+
private utxos;
|
|
40
|
+
private lastScanned;
|
|
41
|
+
private constructor();
|
|
42
|
+
/**
|
|
43
|
+
* Derive `gap` external and `gap` change addresses from `seed` under
|
|
44
|
+
* `account`. Only outputs to these addresses are recognized.
|
|
45
|
+
*/
|
|
46
|
+
static create(seed: Uint8Array, network: Network, account?: number, gap?: number): TransparentWallet;
|
|
47
|
+
/** Next unused external receive address. */
|
|
48
|
+
newAddress(): string;
|
|
49
|
+
private nextChangeHash;
|
|
50
|
+
/**
|
|
51
|
+
* Add a caller-supplied UTXO if it pays one of our addresses. Returns true if
|
|
52
|
+
* ours. Assumed a normal (non-coinbase) spendable output; use {@link scanBlock}
|
|
53
|
+
* for chain data where coinbase maturity is tracked.
|
|
54
|
+
*/
|
|
55
|
+
addUtxo(txid: string, vout: number, amount: number, scriptPubKey: Uint8Array): boolean;
|
|
56
|
+
private insertUtxo;
|
|
57
|
+
/** Apply a scanned block's transparent outputs (added if ours) and spent inputs (removed). */
|
|
58
|
+
scan(outputs: ScannedOutput[], spent: ScannedInput[]): void;
|
|
59
|
+
/**
|
|
60
|
+
* Scan one decoded block (`getblock <hash> 2`): credit every output that
|
|
61
|
+
* pays us and remove every tracked UTXO the block spends. Coinbase vins (no
|
|
62
|
+
* prevout `txid`) are skipped. Records the block's height as last scanned.
|
|
63
|
+
*/
|
|
64
|
+
scanBlock(block: any): void;
|
|
65
|
+
/** Height of the last block passed to {@link scanBlock} (0 if none). */
|
|
66
|
+
lastScannedBlock(): number;
|
|
67
|
+
/**
|
|
68
|
+
* Sync from the node into the wallet, from `max(fromHeight, lastScanned+1)`
|
|
69
|
+
* up to the current tip, fetching each block with getBlockHash +
|
|
70
|
+
* getBlock(hash, 2) and feeding it to {@link scanBlock}. Blocks are fetched
|
|
71
|
+
* with bounded concurrency but scanned in ascending order.
|
|
72
|
+
*
|
|
73
|
+
* Like the shield wallet's sync this is a chain-data pull, not chain
|
|
74
|
+
* authentication: point it at a node you trust. See SECURITY.md.
|
|
75
|
+
*/
|
|
76
|
+
sync(client: PivxClient, { fromHeight, batchSize, onProgress }?: {
|
|
77
|
+
fromHeight?: number;
|
|
78
|
+
batchSize?: number;
|
|
79
|
+
onProgress?: (height: number, tip: number) => void;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
/** Total tracked transparent balance in satoshis. */
|
|
82
|
+
balance(): number;
|
|
83
|
+
getUtxos(): readonly OwnedUtxo[];
|
|
84
|
+
private static estSize;
|
|
85
|
+
/**
|
|
86
|
+
* Build and sign a transparent send of `amount` sats to `to`, selecting
|
|
87
|
+
* UTXOs largest-first with change to a fresh change address. `feePerByte`
|
|
88
|
+
* defaults to 100 sats/byte. Returns the raw tx hex and the spent inputs.
|
|
89
|
+
*/
|
|
90
|
+
buildSend(to: string, amount: number, feePerByte?: number): {
|
|
91
|
+
hex: string;
|
|
92
|
+
spent: {
|
|
93
|
+
txid: string;
|
|
94
|
+
vout: number;
|
|
95
|
+
}[];
|
|
96
|
+
};
|
|
97
|
+
/** Mark inputs spent after a successful broadcast. */
|
|
98
|
+
markSpent(spent: {
|
|
99
|
+
txid: string;
|
|
100
|
+
vout: number;
|
|
101
|
+
}[]): void;
|
|
102
|
+
}
|
|
103
|
+
export { scriptPubKeyForAddress };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { decodeAddress, deriveKey, encodeAddress, hash160 } from './transparent.js';
|
|
2
|
+
import { buildTransparentTx, scriptPubKeyForAddress } from './transparent-tx.js';
|
|
3
|
+
const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
4
|
+
/**
|
|
5
|
+
* PIVX dust threshold (sats) for an output whose scriptPubKey is `scriptLen`
|
|
6
|
+
* bytes. Matches `GetDustThreshold` in src/policy/policy.cpp: the output plus
|
|
7
|
+
* the 148-byte input to spend it, priced at dustRelayFee = 30000 sat/kB. For
|
|
8
|
+
* our scripts (< 253 bytes) the length prefix is one byte, so the serialized
|
|
9
|
+
* output is `8 + 1 + scriptLen`; a 25-byte P2PKH gives 5460.
|
|
10
|
+
*/
|
|
11
|
+
const dustThreshold = (scriptLen) => Math.floor((30_000 * (8 + 1 + scriptLen + 148)) / 1000);
|
|
12
|
+
/** Coinbase/coinstake maturity in blocks (nCoinbaseMaturity): mainnet 100, testnet 15. */
|
|
13
|
+
const coinbaseMaturity = (network) => (network === 'mainnet' ? 100 : 15);
|
|
14
|
+
/** hash160 of a standard P2PKH scriptPubKey (76a914<20>88ac), if it is one. */
|
|
15
|
+
function p2pkhHash(script) {
|
|
16
|
+
if (script.length === 25 &&
|
|
17
|
+
script[0] === 0x76 && script[1] === 0xa9 && script[2] === 0x14 &&
|
|
18
|
+
script[23] === 0x88 && script[24] === 0xac) {
|
|
19
|
+
return hex(script.slice(3, 23));
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export class TransparentWallet {
|
|
24
|
+
network;
|
|
25
|
+
keys = new Map(); // hex hash160 → privkey
|
|
26
|
+
external = [];
|
|
27
|
+
change = [];
|
|
28
|
+
nextExternal = 0;
|
|
29
|
+
nextChange = 0;
|
|
30
|
+
utxos = new Map(); // "txid:vout" → utxo
|
|
31
|
+
lastScanned = 0; // height of the last block passed to scanBlock
|
|
32
|
+
constructor(network) {
|
|
33
|
+
this.network = network;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Derive `gap` external and `gap` change addresses from `seed` under
|
|
37
|
+
* `account`. Only outputs to these addresses are recognized.
|
|
38
|
+
*/
|
|
39
|
+
static create(seed, network, account = 0, gap = 100) {
|
|
40
|
+
const w = new TransparentWallet(network);
|
|
41
|
+
for (let i = 0; i < gap; i++) {
|
|
42
|
+
const ext = deriveKey(seed, network, account, 0, i);
|
|
43
|
+
const eh = hex(hash160(ext.publicKey));
|
|
44
|
+
w.external.push({ hash: eh, address: ext.address });
|
|
45
|
+
w.keys.set(eh, ext.privateKey);
|
|
46
|
+
const ch = deriveKey(seed, network, account, 1, i);
|
|
47
|
+
const chh = hex(hash160(ch.publicKey));
|
|
48
|
+
w.change.push(chh);
|
|
49
|
+
w.keys.set(chh, ch.privateKey);
|
|
50
|
+
}
|
|
51
|
+
return w;
|
|
52
|
+
}
|
|
53
|
+
/** Next unused external receive address. */
|
|
54
|
+
newAddress() {
|
|
55
|
+
const e = this.external[this.nextExternal];
|
|
56
|
+
if (!e)
|
|
57
|
+
throw new Error('address gap limit reached; increase gap');
|
|
58
|
+
this.nextExternal++;
|
|
59
|
+
return e.address;
|
|
60
|
+
}
|
|
61
|
+
nextChangeHash() {
|
|
62
|
+
const h = this.change[this.nextChange];
|
|
63
|
+
if (!h)
|
|
64
|
+
throw new Error('change gap limit reached; increase gap');
|
|
65
|
+
this.nextChange++;
|
|
66
|
+
return h;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Add a caller-supplied UTXO if it pays one of our addresses. Returns true if
|
|
70
|
+
* ours. Assumed a normal (non-coinbase) spendable output; use {@link scanBlock}
|
|
71
|
+
* for chain data where coinbase maturity is tracked.
|
|
72
|
+
*/
|
|
73
|
+
addUtxo(txid, vout, amount, scriptPubKey) {
|
|
74
|
+
return this.insertUtxo(txid, vout, amount, scriptPubKey, false, 0);
|
|
75
|
+
}
|
|
76
|
+
insertUtxo(txid, vout, amount, scriptPubKey, coinbase, height) {
|
|
77
|
+
const h = p2pkhHash(scriptPubKey);
|
|
78
|
+
if (h && this.keys.has(h)) {
|
|
79
|
+
this.utxos.set(`${txid}:${vout}`, { txid, vout, amount, scriptPubKey, keyHash: h, coinbase, height });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
/** Apply a scanned block's transparent outputs (added if ours) and spent inputs (removed). */
|
|
85
|
+
scan(outputs, spent) {
|
|
86
|
+
for (const o of outputs)
|
|
87
|
+
this.addUtxo(o.txid, o.vout, o.amount, o.scriptPubKey);
|
|
88
|
+
for (const s of spent)
|
|
89
|
+
this.utxos.delete(`${s.txid}:${s.vout}`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Scan one decoded block (`getblock <hash> 2`): credit every output that
|
|
93
|
+
* pays us and remove every tracked UTXO the block spends. Coinbase vins (no
|
|
94
|
+
* prevout `txid`) are skipped. Records the block's height as last scanned.
|
|
95
|
+
*/
|
|
96
|
+
scanBlock(block) {
|
|
97
|
+
if (typeof block.height === 'number')
|
|
98
|
+
this.lastScanned = block.height;
|
|
99
|
+
const height = this.lastScanned;
|
|
100
|
+
for (const tx of block.tx ?? []) {
|
|
101
|
+
if (typeof tx.txid !== 'string')
|
|
102
|
+
continue;
|
|
103
|
+
// Coinbase: first vin carries `coinbase` and no prevout. Coinstake (PoS):
|
|
104
|
+
// a spending vin plus an empty vout[0] (zero value). Both are maturity-
|
|
105
|
+
// gated for spending (src/txmempool.cpp).
|
|
106
|
+
const firstVin = tx.vin?.[0];
|
|
107
|
+
const isCoinbase = firstVin?.coinbase !== undefined;
|
|
108
|
+
const isCoinstake = firstVin?.txid !== undefined && tx.vout?.[0]?.value === 0;
|
|
109
|
+
const coinbase = isCoinbase || isCoinstake;
|
|
110
|
+
for (const o of tx.vout ?? []) {
|
|
111
|
+
const hexStr = o?.scriptPubKey?.hex;
|
|
112
|
+
// Skip malformed vouts rather than poisoning balance with NaN or
|
|
113
|
+
// throwing mid-sync (matches the Rust scanner).
|
|
114
|
+
if (typeof o?.n !== 'number' || typeof o?.value !== 'number' || typeof hexStr !== 'string')
|
|
115
|
+
continue;
|
|
116
|
+
const script = Uint8Array.from((hexStr.match(/../g) ?? []).map((b) => parseInt(b, 16)));
|
|
117
|
+
this.insertUtxo(tx.txid, o.n, Math.round(o.value * 1e8), script, coinbase, height);
|
|
118
|
+
}
|
|
119
|
+
for (const i of tx.vin ?? []) {
|
|
120
|
+
if (i.txid !== undefined)
|
|
121
|
+
this.utxos.delete(`${i.txid}:${i.vout}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Height of the last block passed to {@link scanBlock} (0 if none). */
|
|
126
|
+
lastScannedBlock() {
|
|
127
|
+
return this.lastScanned;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Sync from the node into the wallet, from `max(fromHeight, lastScanned+1)`
|
|
131
|
+
* up to the current tip, fetching each block with getBlockHash +
|
|
132
|
+
* getBlock(hash, 2) and feeding it to {@link scanBlock}. Blocks are fetched
|
|
133
|
+
* with bounded concurrency but scanned in ascending order.
|
|
134
|
+
*
|
|
135
|
+
* Like the shield wallet's sync this is a chain-data pull, not chain
|
|
136
|
+
* authentication: point it at a node you trust. See SECURITY.md.
|
|
137
|
+
*/
|
|
138
|
+
async sync(client, { fromHeight = 0, batchSize = 100, onProgress } = {}) {
|
|
139
|
+
const concurrency = 8;
|
|
140
|
+
const tip = await client.getBlockCount();
|
|
141
|
+
const fetchBlock = async (h) => client.getBlock(await client.getBlockHash(h), 2);
|
|
142
|
+
// NaN/0/fractional → sane integer: 0 would loop forever and fractional
|
|
143
|
+
// heights would skip blocks (matches Rust batch.max(1)).
|
|
144
|
+
const batch = Math.max(1, Math.floor(batchSize) || 1);
|
|
145
|
+
let from = Math.max(fromHeight, this.lastScanned + 1);
|
|
146
|
+
while (from <= tip) {
|
|
147
|
+
const to = Math.min(from + batch - 1, tip);
|
|
148
|
+
const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
149
|
+
for (let i = 0; i < heights.length; i += concurrency) {
|
|
150
|
+
const blocks = await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock));
|
|
151
|
+
for (const block of blocks)
|
|
152
|
+
this.scanBlock(block);
|
|
153
|
+
}
|
|
154
|
+
onProgress?.(to, tip);
|
|
155
|
+
from = to + 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Total tracked transparent balance in satoshis. */
|
|
159
|
+
balance() {
|
|
160
|
+
return [...this.utxos.values()].reduce((s, u) => s + u.amount, 0);
|
|
161
|
+
}
|
|
162
|
+
getUtxos() {
|
|
163
|
+
return [...this.utxos.values()];
|
|
164
|
+
}
|
|
165
|
+
static estSize(nIn, nOut) {
|
|
166
|
+
return nIn * 148 + nOut * 34 + 10;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build and sign a transparent send of `amount` sats to `to`, selecting
|
|
170
|
+
* UTXOs largest-first with change to a fresh change address. `feePerByte`
|
|
171
|
+
* defaults to 100 sats/byte. Returns the raw tx hex and the spent inputs.
|
|
172
|
+
*/
|
|
173
|
+
buildSend(to, amount, feePerByte = 100) {
|
|
174
|
+
if (!Number.isSafeInteger(amount) || amount <= 0)
|
|
175
|
+
throw new Error('amount must be a positive integer (satoshis)');
|
|
176
|
+
if (!Number.isInteger(feePerByte) || feePerByte <= 0)
|
|
177
|
+
throw new Error('feePerByte must be a positive integer (satoshis/byte)');
|
|
178
|
+
const dest = decodeAddress(to); // throws on an invalid address
|
|
179
|
+
// A mainnet wallet must not send to a testnet-encoded address (or vice
|
|
180
|
+
// versa): the hash would be spent to this network's equivalent — a silent
|
|
181
|
+
// loss. Reject the mismatch up front.
|
|
182
|
+
if (dest.network !== this.network)
|
|
183
|
+
throw new Error('destination address is for a different network');
|
|
184
|
+
if (dest.kind === 'staking')
|
|
185
|
+
throw new Error('sending to a cold-staking address is not supported');
|
|
186
|
+
// Reject a recipient amount the node would drop as dust.
|
|
187
|
+
const toScript = scriptPubKeyForAddress(to);
|
|
188
|
+
if (amount < dustThreshold(toScript.length))
|
|
189
|
+
throw new Error('amount is below the dust threshold');
|
|
190
|
+
const feerate = feePerByte;
|
|
191
|
+
// Exclude immature coinbase/coinstake outputs: the node rejects a spend of
|
|
192
|
+
// one before nCoinbaseMaturity confirmations (depth vs. last scanned block).
|
|
193
|
+
const maturity = coinbaseMaturity(this.network);
|
|
194
|
+
const avail = [...this.utxos.values()]
|
|
195
|
+
.filter((u) => !(u.coinbase && this.lastScanned - u.height + 1 < maturity))
|
|
196
|
+
.sort((a, b) => b.amount - a.amount);
|
|
197
|
+
const selected = [];
|
|
198
|
+
let total = 0;
|
|
199
|
+
for (const u of avail) {
|
|
200
|
+
selected.push(u);
|
|
201
|
+
total += u.amount;
|
|
202
|
+
if (total >= amount + feerate * TransparentWallet.estSize(selected.length, 2))
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const fee = feerate * TransparentWallet.estSize(selected.length, 2);
|
|
206
|
+
if (total < amount + fee)
|
|
207
|
+
throw new Error('insufficient transparent balance to cover amount + fee');
|
|
208
|
+
const changeVal = total - amount - fee;
|
|
209
|
+
const outputs = [{ address: to, amount }];
|
|
210
|
+
// Emit change only above both floors: the node's fixed dust threshold (else
|
|
211
|
+
// the tx is rejected as dust) and the fee to later spend the change input.
|
|
212
|
+
// Change is always P2PKH (25-byte script).
|
|
213
|
+
if (changeVal > Math.max(feerate * 148, dustThreshold(25))) {
|
|
214
|
+
const chAddr = encodeAddress(Uint8Array.from(this.nextChangeHash().match(/../g).map((b) => parseInt(b, 16))), this.network, 'p2pkh');
|
|
215
|
+
outputs.push({ address: chAddr, amount: changeVal });
|
|
216
|
+
}
|
|
217
|
+
const inputs = selected.map((u) => ({
|
|
218
|
+
txid: u.txid,
|
|
219
|
+
vout: u.vout,
|
|
220
|
+
amount: u.amount,
|
|
221
|
+
scriptPubKey: u.scriptPubKey,
|
|
222
|
+
privateKey: this.keys.get(u.keyHash),
|
|
223
|
+
}));
|
|
224
|
+
const spent = selected.map((u) => ({ txid: u.txid, vout: u.vout }));
|
|
225
|
+
return { hex: buildTransparentTx(inputs, outputs, 0), spent };
|
|
226
|
+
}
|
|
227
|
+
/** Mark inputs spent after a successful broadcast. */
|
|
228
|
+
markSpent(spent) {
|
|
229
|
+
for (const s of spent)
|
|
230
|
+
this.utxos.delete(`${s.txid}:${s.vout}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export { scriptPubKeyForAddress };
|