txo_parser 0.0.1 → 0.0.3
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 +13 -17
- package/README.md +36 -2
- package/demo/blocktrail.jsonld +10 -0
- package/demo/index.html +62 -0
- package/demo/ledger-history.jsonld +9 -0
- package/demo/lib/bitcoin.js +378 -0
- package/demo/panes/blocktrails-pane.js +673 -0
- package/demo/panes/builder-pane.js +190 -0
- package/demo/panes/faucet-pane.js +137 -0
- package/demo/panes/ledger-pane.js +662 -0
- package/demo/panes/parser-pane.js +171 -0
- package/demo/panes/spec-pane.js +117 -0
- package/demo/panes/voucher-pane.js +693 -0
- package/demo/voucher-data.jsonld +31 -0
- package/demo/webledger.jsonld +9 -0
- package/index.js +63 -11
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 19 November 2007
|
|
2
3
|
|
|
3
|
-
Copyright (
|
|
4
|
+
Copyright (C) 2025-2026 Melvin Carvalho
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU Affero General Public License as
|
|
8
|
+
published by the Free Software Foundation, either version 3 of the
|
|
9
|
+
License, or (at your option) any later version.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU Affero General Public License for more details.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
16
|
+
You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# TXO URI Parser
|
|
2
2
|
|
|
3
|
-
A pure JavaScript ES module for parsing and formatting TXO URIs according to the [TXO URI Specification v0.
|
|
3
|
+
A pure JavaScript ES module for parsing and formatting TXO URIs according to the [TXO URI Specification v0.2](txo_uri.md).
|
|
4
|
+
|
|
5
|
+
**[Live Demo](https://sandy-mount.github.io/txo_parser/demo/)** — Parse, build, manage vouchers, anchor state to Bitcoin. 7 interactive panes powered by [LOSOS](https://losos.org/).
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
@@ -34,6 +36,20 @@ console.log(parsed)
|
|
|
34
36
|
// amount: 0.75
|
|
35
37
|
// }
|
|
36
38
|
|
|
39
|
+
// Parse legacy format
|
|
40
|
+
const legacyUri =
|
|
41
|
+
'txo:btc:4e9c1ef9ba5fa3b0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0:0 0.75 Kx9'
|
|
42
|
+
const parsedLegacy = parseTxoUri(legacyUri)
|
|
43
|
+
console.log(parsedLegacy)
|
|
44
|
+
// Output:
|
|
45
|
+
// {
|
|
46
|
+
// network: 'btc',
|
|
47
|
+
// txid: '4e9c1ef9ba5fa3b0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0',
|
|
48
|
+
// output: 0,
|
|
49
|
+
// amount: 0.75,
|
|
50
|
+
// privkey: 'Kx9'
|
|
51
|
+
// }
|
|
52
|
+
|
|
37
53
|
// Check if a URI is valid
|
|
38
54
|
console.log(isValidTxoUri(uri)) // true
|
|
39
55
|
|
|
@@ -58,6 +74,9 @@ The package includes a command-line tool that allows you to parse TXO URIs direc
|
|
|
58
74
|
# Parse a TXO URI and output the full JSON
|
|
59
75
|
txo-parser "txo:btc:4e9c1ef9ba5fa3b0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0:0?amount=0.75"
|
|
60
76
|
|
|
77
|
+
# Parse legacy format
|
|
78
|
+
txo-parser "txo:btc:4e9c1ef9ba5fa3b0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0:0 0.75 Kx9"
|
|
79
|
+
|
|
61
80
|
# Extract just the txid
|
|
62
81
|
txo-parser "txo:btc:4e9c1ef9ba5fa3b0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0:0" --txid
|
|
63
82
|
|
|
@@ -74,6 +93,21 @@ If you haven't installed the package globally, you can use `npx`:
|
|
|
74
93
|
npx txo-parser "txo:btc:4e9c1ef9ba5fa3b0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0:0"
|
|
75
94
|
```
|
|
76
95
|
|
|
96
|
+
## Supported Formats
|
|
97
|
+
|
|
98
|
+
The parser supports two formats:
|
|
99
|
+
|
|
100
|
+
1. **Standard Format** (as per specification):
|
|
101
|
+
`txo:<network>:<txid>:<output>?key=value&key=value...`
|
|
102
|
+
|
|
103
|
+
2. **Legacy Format**:
|
|
104
|
+
`txo:<network>:<txid>:<output> [amount] [privkey]`
|
|
105
|
+
|
|
106
|
+
The legacy format supports up to two space-separated parameters after the basic structure:
|
|
107
|
+
|
|
108
|
+
- First parameter is interpreted as the amount
|
|
109
|
+
- Second parameter is interpreted as the private key
|
|
110
|
+
|
|
77
111
|
## API
|
|
78
112
|
|
|
79
113
|
### `parseTxoUri(uri)`
|
|
@@ -99,7 +133,7 @@ Formats a JSON object into a TXO URI string.
|
|
|
99
133
|
|
|
100
134
|
- **Parameters:**
|
|
101
135
|
- `data` (object): The data to format (must include network, txid, and output)
|
|
102
|
-
- **Returns:** Formatted TXO URI string
|
|
136
|
+
- **Returns:** Formatted TXO URI string (always in standard format)
|
|
103
137
|
- **Throws:** Error if required fields are missing or invalid
|
|
104
138
|
|
|
105
139
|
## Testing
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>TXO — Voucher Management & Bitcoin Anchoring</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
+
background: linear-gradient(160deg, #0a0f1a 0%, #1a1a3e 50%, #0f172a 100%);
|
|
12
|
+
color: rgba(255,255,255,0.9);
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
}
|
|
15
|
+
#losos { max-width: 100% !important; }
|
|
16
|
+
#losos > div { max-width: 100% !important; width: 100% !important; }
|
|
17
|
+
button.pane-tab {
|
|
18
|
+
color: rgba(255,255,255,0.45) !important;
|
|
19
|
+
font-weight: 600 !important;
|
|
20
|
+
font-size: 0.85em !important;
|
|
21
|
+
padding: 12px 20px !important;
|
|
22
|
+
}
|
|
23
|
+
button.pane-tab[aria-selected="true"] {
|
|
24
|
+
color: rgba(255,255,255,0.95) !important;
|
|
25
|
+
}
|
|
26
|
+
#pane-tabs { max-width: 960px !important; margin: 0 auto !important; border-bottom-color: rgba(255,255,255,0.06) !important; }
|
|
27
|
+
#pane-container { max-width: 960px !important; margin: 0 auto !important; }
|
|
28
|
+
.boot { display: flex; align-items: center; justify-content: center; height: 40vh; color: rgba(255,255,255,0.25); font-size: 0.9rem; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
|
|
33
|
+
<script src="https://unpkg.com/xlogin"></script>
|
|
34
|
+
<script id="data" type="application/ld+json" src="voucher-data.jsonld"></script>
|
|
35
|
+
<script type="module" data-pane src="panes/parser-pane.js"></script>
|
|
36
|
+
<script type="module" data-pane src="panes/builder-pane.js"></script>
|
|
37
|
+
<script type="module" data-pane src="panes/voucher-pane.js"></script>
|
|
38
|
+
<script type="module" data-pane src="panes/faucet-pane.js"></script>
|
|
39
|
+
<script type="module" data-pane src="panes/blocktrails-pane.js"></script>
|
|
40
|
+
<script type="module" data-pane src="panes/ledger-pane.js"></script>
|
|
41
|
+
<script type="module" data-pane src="panes/spec-pane.js"></script>
|
|
42
|
+
|
|
43
|
+
<div id="losos"><div class="boot">Loading...</div></div>
|
|
44
|
+
|
|
45
|
+
<script>
|
|
46
|
+
(function() {
|
|
47
|
+
// Capture ?key= for auto-import
|
|
48
|
+
var params = new URLSearchParams(location.search)
|
|
49
|
+
if (params.has('key')) {
|
|
50
|
+
window.__pendingImport = params.get('key')
|
|
51
|
+
history.replaceState({}, '', location.pathname)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Boot shell
|
|
55
|
+
var s = document.createElement('script')
|
|
56
|
+
s.type = 'module'
|
|
57
|
+
s.src = 'https://losos.org/losos/shell.js'
|
|
58
|
+
document.body.appendChild(s)
|
|
59
|
+
})()
|
|
60
|
+
</script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { parseTxoUri, isValidTxoUri, formatTxoUri } from 'https://esm.sh/txo_parser'
|
|
2
|
+
import { secp256k1, schnorr } from 'https://esm.sh/@noble/curves@1.8.1/secp256k1'
|
|
3
|
+
|
|
4
|
+
// ── Base58 ──────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
|
7
|
+
|
|
8
|
+
function b58decode(str) {
|
|
9
|
+
const bytes = []
|
|
10
|
+
for (const c of str) {
|
|
11
|
+
let carry = B58.indexOf(c)
|
|
12
|
+
if (carry < 0) throw new Error('Invalid base58 character: ' + c)
|
|
13
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
14
|
+
carry += bytes[j] * 58
|
|
15
|
+
bytes[j] = carry & 0xff
|
|
16
|
+
carry >>= 8
|
|
17
|
+
}
|
|
18
|
+
while (carry > 0) { bytes.push(carry & 0xff); carry >>= 8 }
|
|
19
|
+
}
|
|
20
|
+
for (const c of str) { if (c === '1') bytes.push(0); else break }
|
|
21
|
+
return new Uint8Array(bytes.reverse())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Hash helpers ────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export async function sha256(data) {
|
|
27
|
+
const buf = data instanceof Uint8Array ? data : new TextEncoder().encode(data)
|
|
28
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', buf))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function doubleSha256(data) {
|
|
32
|
+
return sha256(await sha256(data))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── WIF / Key decode ────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async function wifDecode(wif) {
|
|
38
|
+
const raw = b58decode(wif)
|
|
39
|
+
if (raw.length < 5) throw new Error('WIF too short')
|
|
40
|
+
const payload = raw.slice(0, -4)
|
|
41
|
+
const checksum = raw.slice(-4)
|
|
42
|
+
const hash = await doubleSha256(payload)
|
|
43
|
+
for (let i = 0; i < 4; i++) {
|
|
44
|
+
if (hash[i] !== checksum[i]) throw new Error('Invalid WIF checksum')
|
|
45
|
+
}
|
|
46
|
+
const version = payload[0]
|
|
47
|
+
const isTestnet = version === 0xef
|
|
48
|
+
const isMainnet = version === 0x80
|
|
49
|
+
if (!isTestnet && !isMainnet) throw new Error('Unknown WIF version: 0x' + version.toString(16))
|
|
50
|
+
const compressed = payload.length === 34 && payload[33] === 0x01
|
|
51
|
+
return { privkey: payload.slice(1, 33), compressed, testnet: isTestnet }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hexToBytes(hex) {
|
|
55
|
+
if (hex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(hex)) throw new Error('Invalid 64-char hex key')
|
|
56
|
+
const bytes = new Uint8Array(32)
|
|
57
|
+
for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
58
|
+
return bytes
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isHexKey(s) {
|
|
62
|
+
return s.length === 64 && /^[0-9a-fA-F]{64}$/.test(s)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function decodeKey(input) {
|
|
66
|
+
if (isHexKey(input)) {
|
|
67
|
+
return { privkey: hexToBytes(input), compressed: true, testnet: true }
|
|
68
|
+
}
|
|
69
|
+
return wifDecode(input)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function privkeyToXOnly(privkeyBytes) {
|
|
73
|
+
const pub = secp256k1.getPublicKey(privkeyBytes, true)
|
|
74
|
+
return pub.slice(1)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Bech32m (P2TR addresses) ────────────────────────────
|
|
78
|
+
|
|
79
|
+
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
|
80
|
+
const BECH32M = 0x2bc830a3
|
|
81
|
+
|
|
82
|
+
function polymod(values) {
|
|
83
|
+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
84
|
+
let chk = 1
|
|
85
|
+
for (const v of values) {
|
|
86
|
+
const b = chk >> 25
|
|
87
|
+
chk = ((chk & 0x1ffffff) << 5) ^ v
|
|
88
|
+
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i]
|
|
89
|
+
}
|
|
90
|
+
return chk
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hrpExpand(hrp) {
|
|
94
|
+
const r = []
|
|
95
|
+
for (const c of hrp) r.push(c.charCodeAt(0) >> 5)
|
|
96
|
+
r.push(0)
|
|
97
|
+
for (const c of hrp) r.push(c.charCodeAt(0) & 31)
|
|
98
|
+
return r
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function convertBits(data, from, to, pad) {
|
|
102
|
+
let acc = 0, bits = 0
|
|
103
|
+
const ret = [], maxv = (1 << to) - 1
|
|
104
|
+
for (const v of data) {
|
|
105
|
+
acc = (acc << from) | v
|
|
106
|
+
bits += from
|
|
107
|
+
while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv) }
|
|
108
|
+
}
|
|
109
|
+
if (pad && bits > 0) ret.push((acc << (to - bits)) & maxv)
|
|
110
|
+
return ret
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function bech32mEncode(hrp, version, program) {
|
|
114
|
+
const conv = convertBits(program, 8, 5, true)
|
|
115
|
+
const values = [version, ...conv]
|
|
116
|
+
const enc = [...hrpExpand(hrp), ...values, 0, 0, 0, 0, 0, 0]
|
|
117
|
+
const mod = polymod(enc) ^ BECH32M
|
|
118
|
+
const checksum = [0,1,2,3,4,5].map(i => (mod >> (5 * (5 - i))) & 31)
|
|
119
|
+
let result = hrp + '1'
|
|
120
|
+
for (const v of [...values, ...checksum]) result += BECH32_CHARSET[v]
|
|
121
|
+
return result
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function wpToP2trAddress(wpHex, testnet) {
|
|
125
|
+
if (testnet === undefined) testnet = true
|
|
126
|
+
const program = hexToU8(wpHex)
|
|
127
|
+
return bech32mEncode(testnet ? 'tb' : 'bc', 1, program)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function privkeyToAddress(privkeyBytes, testnet) {
|
|
131
|
+
if (testnet === undefined) testnet = true
|
|
132
|
+
const xonly = privkeyToXOnly(privkeyBytes)
|
|
133
|
+
return bech32mEncode(testnet ? 'tb' : 'bc', 1, xonly)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Byte helpers ────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export function bytesToHex(bytes) {
|
|
139
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function hexToU8(hex) {
|
|
143
|
+
const bytes = new Uint8Array(hex.length / 2)
|
|
144
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
145
|
+
return bytes
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function concatBytes(...arrays) {
|
|
149
|
+
const total = arrays.reduce((s, a) => s + a.length, 0)
|
|
150
|
+
const result = new Uint8Array(total)
|
|
151
|
+
let off = 0
|
|
152
|
+
for (const a of arrays) { result.set(a, off); off += a.length }
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Tagged hash (BIP340/341) ────────────────────────────
|
|
157
|
+
|
|
158
|
+
async function taggedHash(tag, ...msgs) {
|
|
159
|
+
const tagHash = await sha256(new TextEncoder().encode(tag))
|
|
160
|
+
return sha256(concatBytes(tagHash, tagHash, ...msgs))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Taproot key tweaking (BIP86) ────────────────────────
|
|
164
|
+
|
|
165
|
+
const SECP_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141')
|
|
166
|
+
|
|
167
|
+
function bytesToBigInt(bytes) {
|
|
168
|
+
let r = 0n
|
|
169
|
+
for (const b of bytes) r = (r << 8n) | BigInt(b)
|
|
170
|
+
return r
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function bigIntToBytes(n) {
|
|
174
|
+
const hex = n.toString(16).padStart(64, '0')
|
|
175
|
+
return hexToU8(hex)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function getTweakedKeys(privkeyBytes) {
|
|
179
|
+
const xonly = privkeyToXOnly(privkeyBytes)
|
|
180
|
+
const tweak = await taggedHash('TapTweak', xonly)
|
|
181
|
+
const t = bytesToBigInt(tweak)
|
|
182
|
+
let d = bytesToBigInt(privkeyBytes)
|
|
183
|
+
const fullPub = secp256k1.getPublicKey(privkeyBytes, false)
|
|
184
|
+
if (fullPub[64] & 1) d = SECP_N - d
|
|
185
|
+
const tweakedD = (d + t) % SECP_N
|
|
186
|
+
const tweakedPriv = bigIntToBytes(tweakedD)
|
|
187
|
+
const tweakedXOnly = schnorr.getPublicKey(tweakedPriv)
|
|
188
|
+
return { tweakedPriv, tweakedXOnly, internalXOnly: xonly }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function p2trScript(xonlyPubkey) {
|
|
192
|
+
return concatBytes(new Uint8Array([0x51, 0x20]), xonlyPubkey)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Transaction serialization ───────────────────────────
|
|
196
|
+
|
|
197
|
+
function writeU32LE(val) {
|
|
198
|
+
const b = new Uint8Array(4)
|
|
199
|
+
b[0] = val & 0xff; b[1] = (val >> 8) & 0xff; b[2] = (val >> 16) & 0xff; b[3] = (val >> 24) & 0xff
|
|
200
|
+
return b
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function writeU64LE(val) {
|
|
204
|
+
const b = new Uint8Array(8)
|
|
205
|
+
const n = BigInt(val)
|
|
206
|
+
for (let i = 0; i < 8; i++) b[i] = Number((n >> BigInt(i * 8)) & 0xffn)
|
|
207
|
+
return b
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function writeVarInt(val) {
|
|
211
|
+
if (val < 0xfd) return new Uint8Array([val])
|
|
212
|
+
if (val <= 0xffff) return new Uint8Array([0xfd, val & 0xff, (val >> 8) & 0xff])
|
|
213
|
+
throw new Error('VarInt too large')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function reverseTxid(txidHex) {
|
|
217
|
+
const bytes = hexToU8(txidHex)
|
|
218
|
+
bytes.reverse()
|
|
219
|
+
return bytes
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function estimateVsize(numInputs, numOutputs) {
|
|
223
|
+
return Math.ceil((42 + 230 * numInputs + 172 * numOutputs) / 4)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function buildTransaction(inputs, outputs, privkeyBytes) {
|
|
227
|
+
const internalXOnly = privkeyToXOnly(privkeyBytes)
|
|
228
|
+
const { tweakedPriv } = await getTweakedKeys(privkeyBytes)
|
|
229
|
+
const untweakedHex = '5120' + bytesToHex(internalXOnly)
|
|
230
|
+
const signingKey = bytesToHex(inputs[0].scriptPubKey) === untweakedHex ? privkeyBytes : tweakedPriv
|
|
231
|
+
|
|
232
|
+
const version = 2, locktime = 0, sequence = 0xfffffffd
|
|
233
|
+
|
|
234
|
+
const serOutputs = outputs.map(o =>
|
|
235
|
+
concatBytes(writeU64LE(o.amount), writeVarInt(o.scriptPubKey.length), o.scriptPubKey)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const shaPrevouts = await sha256(concatBytes(...inputs.map(i =>
|
|
239
|
+
concatBytes(reverseTxid(i.txid), writeU32LE(i.vout))
|
|
240
|
+
)))
|
|
241
|
+
const shaAmounts = await sha256(concatBytes(...inputs.map(i => writeU64LE(i.amount))))
|
|
242
|
+
const shaScriptPubKeys = await sha256(concatBytes(...inputs.map(i =>
|
|
243
|
+
concatBytes(writeVarInt(i.scriptPubKey.length), i.scriptPubKey)
|
|
244
|
+
)))
|
|
245
|
+
const shaSequences = await sha256(concatBytes(...inputs.map(() => writeU32LE(sequence))))
|
|
246
|
+
const shaOutputs = await sha256(concatBytes(...serOutputs))
|
|
247
|
+
|
|
248
|
+
const sigs = []
|
|
249
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
250
|
+
const sigMsg = concatBytes(
|
|
251
|
+
new Uint8Array([0x00, 0x00]),
|
|
252
|
+
writeU32LE(version), writeU32LE(locktime),
|
|
253
|
+
shaPrevouts, shaAmounts, shaScriptPubKeys, shaSequences, shaOutputs,
|
|
254
|
+
new Uint8Array([0x00]),
|
|
255
|
+
writeU32LE(i)
|
|
256
|
+
)
|
|
257
|
+
const sighash = await taggedHash('TapSighash', sigMsg)
|
|
258
|
+
sigs.push(schnorr.sign(sighash, signingKey))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const parts = [
|
|
262
|
+
writeU32LE(version),
|
|
263
|
+
new Uint8Array([0x00, 0x01]),
|
|
264
|
+
writeVarInt(inputs.length)
|
|
265
|
+
]
|
|
266
|
+
for (const inp of inputs) {
|
|
267
|
+
parts.push(reverseTxid(inp.txid), writeU32LE(inp.vout), new Uint8Array([0x00]), writeU32LE(sequence))
|
|
268
|
+
}
|
|
269
|
+
parts.push(writeVarInt(outputs.length))
|
|
270
|
+
for (const so of serOutputs) parts.push(so)
|
|
271
|
+
for (const sig of sigs) {
|
|
272
|
+
parts.push(new Uint8Array([0x01]), writeVarInt(sig.length), sig)
|
|
273
|
+
}
|
|
274
|
+
parts.push(writeU32LE(locktime))
|
|
275
|
+
return bytesToHex(concatBytes(...parts))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Mempool API ─────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
const MEMPOOL = 'https://mempool.space/testnet4/api'
|
|
281
|
+
|
|
282
|
+
export async function fetchUtxos(address) {
|
|
283
|
+
const res = await fetch(MEMPOOL + '/address/' + address + '/utxo')
|
|
284
|
+
if (!res.ok) throw new Error('Mempool API error: ' + res.status)
|
|
285
|
+
return res.json()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function checkOutspend(txid, vout) {
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch(MEMPOOL + '/tx/' + txid + '/outspend/' + vout)
|
|
291
|
+
if (!res.ok) return null
|
|
292
|
+
return res.json()
|
|
293
|
+
} catch { return null }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function fetchTxDetails(txid) {
|
|
297
|
+
const res = await fetch(MEMPOOL + '/tx/' + txid)
|
|
298
|
+
if (!res.ok) throw new Error('Failed to fetch tx: ' + res.status)
|
|
299
|
+
return res.json()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function broadcastTx(rawTxHex) {
|
|
303
|
+
const res = await fetch(MEMPOOL + '/tx', {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
306
|
+
body: rawTxHex
|
|
307
|
+
})
|
|
308
|
+
if (!res.ok) {
|
|
309
|
+
const err = await res.text()
|
|
310
|
+
throw new Error(err)
|
|
311
|
+
}
|
|
312
|
+
return res.text()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function getFeeRate() {
|
|
316
|
+
try {
|
|
317
|
+
const res = await fetch(MEMPOOL + '/v1/fees/recommended')
|
|
318
|
+
if (!res.ok) return 2
|
|
319
|
+
const data = await res.json()
|
|
320
|
+
return data.fastestFee || data.halfHourFee || 2
|
|
321
|
+
} catch { return 2 }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── TXO URI helpers ─────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export function toSats(amount) {
|
|
327
|
+
if (!amount) return 0
|
|
328
|
+
if (Number.isInteger(amount) && amount >= 1) return amount
|
|
329
|
+
const converted = Math.round(amount * 1e8)
|
|
330
|
+
if (converted > 2_100_000_000_000_000) return Math.round(amount)
|
|
331
|
+
return converted
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function buildTxoUri(v) {
|
|
335
|
+
const base = 'txo:btc:' + v.txid + ':' + v.vout
|
|
336
|
+
const params = []
|
|
337
|
+
if (v.amount) params.push('amount=' + v.amount)
|
|
338
|
+
if (v.privkey) params.push('key=' + v.privkey)
|
|
339
|
+
return params.length ? base + '?' + params.join('&') : base
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function parseVoucherFromItem(item) {
|
|
343
|
+
var txoUri = item['schema:identifier'] || ''
|
|
344
|
+
var txid = '', vout = 0, amount = 0, privkey = ''
|
|
345
|
+
if (txoUri) {
|
|
346
|
+
try {
|
|
347
|
+
var parsed = parseTxoUri(txoUri)
|
|
348
|
+
txid = parsed.txid || ''
|
|
349
|
+
vout = parsed.output || 0
|
|
350
|
+
amount = toSats(parsed.amount)
|
|
351
|
+
privkey = parsed.privkey || parsed.key || ''
|
|
352
|
+
} catch {
|
|
353
|
+
try {
|
|
354
|
+
var parts = txoUri.split('?')
|
|
355
|
+
var segs = parts[0].replace(/^txo:/, '').split(':')
|
|
356
|
+
if (segs.length >= 3) { txid = segs[1] || ''; vout = parseInt(segs[2]) || 0 }
|
|
357
|
+
if (parts[1]) {
|
|
358
|
+
var params = new URLSearchParams(parts[1])
|
|
359
|
+
if (params.has('amount')) amount = toSats(parseFloat(params.get('amount')))
|
|
360
|
+
if (params.has('key')) privkey = params.get('key')
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
id: item['@id'] || Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
367
|
+
txid: txid,
|
|
368
|
+
vout: vout,
|
|
369
|
+
amount: amount,
|
|
370
|
+
privkey: privkey,
|
|
371
|
+
address: item['schema:address'] || '',
|
|
372
|
+
network: 'btc',
|
|
373
|
+
status: item['schema:status'] || 'unknown',
|
|
374
|
+
dateAdded: item['schema:dateCreated'] || new Date().toISOString()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export { parseTxoUri, isValidTxoUri, formatTxoUri }
|