bsv-sdk 0.8.0 → 0.8.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -0
- data/lib/bsv/network/arc.rb +83 -11
- data/lib/bsv/transaction/merkle_path.rb +187 -2
- data/lib/bsv/transaction/transaction.rb +47 -6
- data/lib/bsv/transaction/var_int.rb +8 -1
- data/lib/bsv/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 534417702a1c6fe35494ff3d7bc028d302452fc21312747cd83167137fab43c7
|
|
4
|
+
data.tar.gz: d7aca5b47d7f853f41142d2ae9087351bf0160beb0b25959330e7e380bbb5d92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 109f3d7d92f210bb3bc5df4e2defaec5322be06b33a467023109fef27d1c9ffe8ca2f876b865b9a4e9f21ef69e9f5d19dd8fde5b02cefdd58f3ee2ac58cfae41
|
|
7
|
+
data.tar.gz: 70145605156016e9fbcd6a4c397a317358ad3142c97637d6ad8a849934ac8d356f40c555e55af178280693c3c4d6b1d3f76af8f10df5526e6fde6fefdef928c5
|
data/CHANGELOG.md
CHANGED
|
@@ -21,6 +21,185 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
21
21
|
and each gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|
22
22
|
independently.
|
|
23
23
|
|
|
24
|
+
## sdk-0.8.2 / wallet-0.3.4 — 2026-04-08
|
|
25
|
+
|
|
26
|
+
Paired security patch release. Three P0 findings from the
|
|
27
|
+
[2026-04-08 cross-SDK compliance review](.architecture/reviews/20260408-cross-sdk-compliance-review.md)
|
|
28
|
+
plus follow-up hardening from the PR review pass. Must be installed
|
|
29
|
+
together — the `bsv-wallet` gemspec now pins its `bsv-sdk` dependency
|
|
30
|
+
to `>= 0.8.2, < 1.0` to enforce the paired upgrade and prevent a stale
|
|
31
|
+
pair where one gem has its fixes and the other doesn't.
|
|
32
|
+
|
|
33
|
+
Two GitHub Security Advisories accompany this release (draft until
|
|
34
|
+
CVE IDs return from MITRE):
|
|
35
|
+
|
|
36
|
+
- [GHSA-hc36-c89j-5f4j](.security/advisories/2026-0001-acquire-certificate-signature-bypass.md) — F8.15 / F8.16 partial — `acquire_certificate` persists unverified certifier signatures (CWE-347, CVSS 8.1 HIGH)
|
|
37
|
+
- [GHSA-9hfr-gw99-8rhx](.security/advisories/2026-0002-arc-broadcaster-failure-statuses.md) — F5.13 — ARC broadcaster treats failure statuses as success (CWE-754, CVSS 7.5 HIGH)
|
|
38
|
+
|
|
39
|
+
### Security
|
|
40
|
+
|
|
41
|
+
- [wallet] **`acquire_certificate` now verifies certifier signatures** before
|
|
42
|
+
persisting (BRC-52). Both the `'direct'` and `'issuance'` acquisition
|
|
43
|
+
paths previously wrote user-supplied `signature:` values to storage
|
|
44
|
+
without any verification — a caller could forge a certificate that
|
|
45
|
+
`list_certificates` / `prove_certificate` would later treat as
|
|
46
|
+
authentic. This was a credential forgery primitive masquerading as
|
|
47
|
+
an API finding. The new `BSV::Wallet::CertificateSignature` module
|
|
48
|
+
builds the canonical BRC-52 preimage (matching the TS reference
|
|
49
|
+
`Certificate#toBinary(false)` byte-for-byte) and delegates to
|
|
50
|
+
`ProtoWallet#verify_signature`. Invalid certificates raise
|
|
51
|
+
`BSV::Wallet::CertificateSignature::InvalidError` and are not
|
|
52
|
+
persisted. Closes F8.15 (and the verification aspect of F8.16).
|
|
53
|
+
|
|
54
|
+
- [sdk] **`VarInt.encode` now rejects negative integers** and values
|
|
55
|
+
above 2^64 − 1. Previously `VarInt.encode(-1)` fell into the single-
|
|
56
|
+
byte branch and emitted `0xFF` (the marker for a 9-byte encoding),
|
|
57
|
+
silently corrupting the transaction stream with no exception raised.
|
|
58
|
+
The docstring already required a non-negative integer; the
|
|
59
|
+
implementation did not enforce it. Closes F1.3.
|
|
60
|
+
|
|
61
|
+
- [sdk] **ARC broadcaster recognises the full failure status set**. The
|
|
62
|
+
previous `REJECTED_STATUSES` contained only `REJECTED` and
|
|
63
|
+
`DOUBLE_SPEND_ATTEMPTED`; responses with txStatus `INVALID`,
|
|
64
|
+
`MALFORMED`, `MINED_IN_STALE_BLOCK`, or any `ORPHAN`-containing
|
|
65
|
+
`txStatus` / `extraInfo` were silently treated as successful
|
|
66
|
+
broadcasts. Callers relying on `broadcast()` to signal failure would
|
|
67
|
+
trust transactions that were never actually accepted by the network.
|
|
68
|
+
The new failure set matches the TypeScript reference broadcaster
|
|
69
|
+
exactly, and case-insensitive matching defends against ARC's
|
|
70
|
+
documented history of emitting values outside its own OpenAPI enum
|
|
71
|
+
(TS issue #105). Malformed 2xx responses without a `txid` field
|
|
72
|
+
also raise, closing the same silent-success class for shape
|
|
73
|
+
corruption. Closes F5.13.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- [sdk] **ARC broadcaster HTTP wire format** brought into line with the
|
|
78
|
+
TypeScript reference:
|
|
79
|
+
- Content-Type is now `application/json` (was `application/octet-stream`)
|
|
80
|
+
- Body is `{"rawTx": hex}` — Extended Format (BRC-30) hex when every
|
|
81
|
+
input has `source_satoshis` / `source_locking_script` populated
|
|
82
|
+
(so ARC can validate sighashes without fetching parents), falling
|
|
83
|
+
back to plain raw-tx hex otherwise
|
|
84
|
+
- New `XDeployment-ID` header (default: `bsv-ruby-sdk-<random hex>`,
|
|
85
|
+
overridable via `deployment_id:` constructor kwarg)
|
|
86
|
+
- New optional `X-CallbackUrl` and `X-CallbackToken` constructor
|
|
87
|
+
kwargs for ARC status callbacks
|
|
88
|
+
|
|
89
|
+
- [wallet] **`bsv-wallet.gemspec` bsv-sdk dependency pinned** to
|
|
90
|
+
`>= 0.8.2, < 1.0`. The previous `~> 0.4` constraint was stale (wallet
|
|
91
|
+
hasn't been tested against bsv-sdk 0.4.x in months) and would have
|
|
92
|
+
let a user install `bsv-wallet 0.3.4` against an old `bsv-sdk` that
|
|
93
|
+
was missing F1.3 and F5.13. Technically breaking — any consumer
|
|
94
|
+
pinned to `bsv-sdk < 0.8.2` must upgrade — but un-breaking in
|
|
95
|
+
practice: it forces users to the known-good pair rather than a
|
|
96
|
+
silently-broken combination.
|
|
97
|
+
|
|
98
|
+
### Internal
|
|
99
|
+
|
|
100
|
+
- [sdk] `lib/bsv/network/**/*` added to `Metrics/ClassLength` and
|
|
101
|
+
`Metrics/ParameterLists` RuboCop exclusion lists to match the
|
|
102
|
+
existing treatment of `lib/bsv/wallet_interface/**/*`. ARC is
|
|
103
|
+
HTTP-client boilerplate in the same shape.
|
|
104
|
+
- [wallet] `lib/bsv/wallet_interface/**/*` added to the
|
|
105
|
+
`Metrics/ModuleLength` exclusion list (was previously only excluded
|
|
106
|
+
from `Metrics/ClassLength`). The new `CertificateSignature` module
|
|
107
|
+
triggered the discrepancy.
|
|
108
|
+
- [sdk, wallet] Review-feedback hardening bundled into the same PR to
|
|
109
|
+
keep the security-patch window small: case-insensitive ARC failure
|
|
110
|
+
matching, `Base64.strict_decode64` on BRC-52 preimage fields,
|
|
111
|
+
`EncodingError` rescue in `CertificateSignature.verify!`, rejection
|
|
112
|
+
of mixed string / symbol duplicate field names, malformed 2xx
|
|
113
|
+
rejection in ARC, and even-length guard on hex signatures.
|
|
114
|
+
|
|
115
|
+
### Migration notes
|
|
116
|
+
|
|
117
|
+
- **Existing `bsv-wallet` users** pinned to `bsv-sdk ~> 0.4` will need
|
|
118
|
+
to relax their constraint or upgrade. Anything installed before
|
|
119
|
+
`bsv-wallet 0.3.4` is vulnerable to the F8.15 certificate forgery
|
|
120
|
+
primitive.
|
|
121
|
+
- **Callers passing negative integers to `VarInt.encode`** (unlikely —
|
|
122
|
+
the docstring already disallowed it) will now get an `ArgumentError`
|
|
123
|
+
instead of silent corruption. Fix: pass non-negative values.
|
|
124
|
+
- **Callers relying on ARC broadcaster silently succeeding for INVALID
|
|
125
|
+
/ MALFORMED / MINED_IN_STALE_BLOCK / ORPHAN responses** will now see
|
|
126
|
+
`BroadcastError` raised. Fix: handle the error — the previous
|
|
127
|
+
behaviour was objectively wrong and any downstream logic that
|
|
128
|
+
tolerated it was silently corrupt.
|
|
129
|
+
- **Callers of `acquire_certificate` with a fake or untrusted
|
|
130
|
+
`signature:` field** will now see
|
|
131
|
+
`BSV::Wallet::CertificateSignature::InvalidError`. Fix: ensure the
|
|
132
|
+
certificate has been properly signed by the declared certifier.
|
|
133
|
+
|
|
134
|
+
### Test suite
|
|
135
|
+
|
|
136
|
+
- 3112 examples, 0 failures (up from 3080 on 0.8.1)
|
|
137
|
+
- 16 new regression tests for F1.3, F5.13, and F8.15
|
|
138
|
+
- 16 further regression tests for the review-feedback hardening
|
|
139
|
+
- Ruby 2.7 — 3.4 matrix green
|
|
140
|
+
- CodeQL clean; RuboCop clean across 266 files
|
|
141
|
+
|
|
142
|
+
## sdk-0.8.1 — 2026-04-08
|
|
143
|
+
|
|
144
|
+
### Fixed
|
|
145
|
+
|
|
146
|
+
- [sdk] **`Transaction#to_beef` strips phantom `txid: true` leaves** —
|
|
147
|
+
when a proof loaded from a shared `LocalProofStore` carries txid flags
|
|
148
|
+
for transactions that are not part of the bundle being constructed,
|
|
149
|
+
`to_beef` now rebuilds each per-block BUMP from only the bundle's own
|
|
150
|
+
txids instead of propagating the phantoms into the serialised output.
|
|
151
|
+
ARC previously rejected such BEEFs with misleading parser errors,
|
|
152
|
+
blocking any wallet workflow that received a BEEF via
|
|
153
|
+
`internalize_action` and then spent the internalised UTXOs.
|
|
154
|
+
Closes #302.
|
|
155
|
+
|
|
156
|
+
### Added
|
|
157
|
+
|
|
158
|
+
- [sdk] **`MerklePath#extract(txid_hashes)`** — returns a new trimmed
|
|
159
|
+
compound path covering only the requested txids, reconstructing the
|
|
160
|
+
minimum set of sibling hashes at each tree level. Raises
|
|
161
|
+
`ArgumentError` on empty input, unknown txid, or root mismatch.
|
|
162
|
+
Ported from the TypeScript SDK. Used internally by
|
|
163
|
+
`Transaction#to_beef` and available for direct use.
|
|
164
|
+
- [sdk] **`MerklePath#trim`** — removes internal nodes not required by
|
|
165
|
+
level-zero txid leaves. Called implicitly by `#combine` and `#extract`
|
|
166
|
+
and rarely needs to be invoked directly. Ported from the TypeScript
|
|
167
|
+
SDK.
|
|
168
|
+
- [sdk] **`MerklePath#initialize_copy`** — `.dup` now produces a new
|
|
169
|
+
MerklePath whose outer and level arrays are independent of the
|
|
170
|
+
source, so the copy can be freely mutated via `#combine`, `#trim`,
|
|
171
|
+
or `#extract` without affecting the original. `PathElement`s
|
|
172
|
+
remain immutable and are shared between source and copy.
|
|
173
|
+
|
|
174
|
+
### Changed
|
|
175
|
+
|
|
176
|
+
- [sdk] **`MerklePath#combine`** now calls `#trim` at the end so merged
|
|
177
|
+
paths stay minimal across repeated merges, matching the TypeScript
|
|
178
|
+
SDK. Combined paths are strictly smaller than before — external
|
|
179
|
+
callers that inspected `mp.path` after `#combine` may see fewer
|
|
180
|
+
nodes, though every txid leaf's merkle proof is preserved.
|
|
181
|
+
- [sdk] **`MerklePath#combine`** also preserves `txid: true` flags when
|
|
182
|
+
the incoming leaf is flagged and the existing leaf at the same offset
|
|
183
|
+
isn't, so merging an ancestor's single-leaf proof into a compound
|
|
184
|
+
that already contains the same offset as a sibling no longer loses
|
|
185
|
+
the txid flag.
|
|
186
|
+
- [sdk] **`Transaction#to_beef`** now raises `ArgumentError` if an
|
|
187
|
+
ancestor's merkle path doesn't actually contain that transaction's
|
|
188
|
+
txid, or if the rebuilt BUMP's root doesn't match the source root.
|
|
189
|
+
Previously such corrupt proof data would silently emit a broken BEEF.
|
|
190
|
+
Callers relying on `to_beef` not raising on valid data are
|
|
191
|
+
unaffected; the new exception only triggers on corrupt proof stores.
|
|
192
|
+
|
|
193
|
+
### Internal
|
|
194
|
+
|
|
195
|
+
- [sdk] **`Beef#merge_transaction`** indirectly benefits from the
|
|
196
|
+
tighter `#combine` + `#trim` behaviour: compound BUMPs no longer
|
|
197
|
+
accumulate dead sibling hashes across repeated merges.
|
|
198
|
+
- [sdk] On the real-world `#302` regression fixture, the cleaned BUMP
|
|
199
|
+
shrinks from 2476 B to 1300 B (47% reduction) as a side effect of
|
|
200
|
+
`#extract` removing intermediate siblings that are no longer needed
|
|
201
|
+
once phantom leaves are gone.
|
|
202
|
+
|
|
24
203
|
## sdk-0.8.0 — 2026-04-08
|
|
25
204
|
|
|
26
205
|
### Added
|
data/lib/bsv/network/arc.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'uri'
|
|
6
|
+
require 'securerandom'
|
|
6
7
|
|
|
7
8
|
module BSV
|
|
8
9
|
module Network
|
|
@@ -14,16 +15,48 @@ module BSV
|
|
|
14
15
|
# The HTTP client is injectable for testability. It must respond to
|
|
15
16
|
# #request(uri, request) and return an object with #code and #body.
|
|
16
17
|
class ARC
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# ARC response statuses that indicate the transaction was NOT accepted.
|
|
19
|
+
# Matches the TypeScript SDK's ARC broadcaster failure set (issue #305,
|
|
20
|
+
# finding F5.13). Prior to this fix, Ruby only recognised REJECTED and
|
|
21
|
+
# DOUBLE_SPEND_ATTEMPTED, silently treating INVALID / MALFORMED /
|
|
22
|
+
# MINED_IN_STALE_BLOCK responses as successful broadcasts.
|
|
23
|
+
REJECTED_STATUSES = %w[
|
|
24
|
+
REJECTED
|
|
25
|
+
DOUBLE_SPEND_ATTEMPTED
|
|
26
|
+
INVALID
|
|
27
|
+
MALFORMED
|
|
28
|
+
MINED_IN_STALE_BLOCK
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Substring match for orphan detection in txStatus or extraInfo fields.
|
|
32
|
+
ORPHAN_MARKER = 'ORPHAN'
|
|
33
|
+
|
|
34
|
+
# @param url [String] ARC base URL (without trailing slash)
|
|
35
|
+
# @param api_key [String, nil] optional bearer token for Authorization
|
|
36
|
+
# @param deployment_id [String, nil] optional deployment identifier for
|
|
37
|
+
# the +XDeployment-ID+ header; defaults to a per-instance random value
|
|
38
|
+
# @param callback_url [String, nil] optional +X-CallbackUrl+ for ARC
|
|
39
|
+
# status callbacks
|
|
40
|
+
# @param callback_token [String, nil] optional +X-CallbackToken+ for
|
|
41
|
+
# ARC status callback authentication
|
|
42
|
+
# @param http_client [#request, nil] injectable HTTP client for testing
|
|
43
|
+
def initialize(url, api_key: nil, deployment_id: nil, callback_url: nil,
|
|
44
|
+
callback_token: nil, http_client: nil)
|
|
20
45
|
@url = url.chomp('/')
|
|
21
46
|
@api_key = api_key
|
|
47
|
+
@deployment_id = deployment_id || "bsv-ruby-sdk-#{SecureRandom.hex(8)}"
|
|
48
|
+
@callback_url = callback_url
|
|
49
|
+
@callback_token = callback_token
|
|
22
50
|
@http_client = http_client
|
|
23
51
|
end
|
|
24
52
|
|
|
25
53
|
# Submit a transaction to ARC.
|
|
26
54
|
#
|
|
55
|
+
# The transaction is encoded as Extended Format (BRC-30) hex when every
|
|
56
|
+
# input has +source_satoshis+ and +source_locking_script+ populated,
|
|
57
|
+
# which lets ARC validate sighashes without fetching parents. Falls back
|
|
58
|
+
# to plain raw-tx hex when EF is unavailable.
|
|
59
|
+
#
|
|
27
60
|
# @param tx [Transaction] the transaction to broadcast
|
|
28
61
|
# @param wait_for [String, nil] ARC wait condition — one of
|
|
29
62
|
# 'RECEIVED', 'STORED', 'ANNOUNCED_TO_NETWORK',
|
|
@@ -31,14 +64,18 @@ module BSV
|
|
|
31
64
|
# connection open until the transaction reaches the requested
|
|
32
65
|
# state (or times out). Defaults to nil (no wait).
|
|
33
66
|
# @return [BroadcastResponse]
|
|
34
|
-
# @raise [BroadcastError]
|
|
67
|
+
# @raise [BroadcastError] when ARC returns a non-2xx HTTP status or a
|
|
68
|
+
# rejected/orphan +txStatus+
|
|
35
69
|
def broadcast(tx, wait_for: nil)
|
|
36
70
|
uri = URI("#{@url}/v1/tx")
|
|
37
71
|
request = Net::HTTP::Post.new(uri)
|
|
38
|
-
request['Content-Type'] = 'application/
|
|
72
|
+
request['Content-Type'] = 'application/json'
|
|
73
|
+
request['XDeployment-ID'] = @deployment_id
|
|
39
74
|
request['X-WaitFor'] = wait_for if wait_for
|
|
75
|
+
request['X-CallbackUrl'] = @callback_url if @callback_url
|
|
76
|
+
request['X-CallbackToken'] = @callback_token if @callback_token
|
|
40
77
|
apply_auth_header(request)
|
|
41
|
-
request.body = tx
|
|
78
|
+
request.body = JSON.generate(rawTx: raw_tx_hex(tx))
|
|
42
79
|
|
|
43
80
|
response = execute(uri, request)
|
|
44
81
|
handle_broadcast_response(response)
|
|
@@ -49,6 +86,7 @@ module BSV
|
|
|
49
86
|
def status(txid)
|
|
50
87
|
uri = URI("#{@url}/v1/tx/#{txid}")
|
|
51
88
|
request = Net::HTTP::Get.new(uri)
|
|
89
|
+
request['XDeployment-ID'] = @deployment_id
|
|
52
90
|
apply_auth_header(request)
|
|
53
91
|
|
|
54
92
|
response = execute(uri, request)
|
|
@@ -57,6 +95,15 @@ module BSV
|
|
|
57
95
|
|
|
58
96
|
private
|
|
59
97
|
|
|
98
|
+
# Prefer Extended Format (BRC-30) hex so ARC can validate sighashes
|
|
99
|
+
# without fetching parent transactions. Falls back to plain raw-tx hex
|
|
100
|
+
# when any input lacks source_satoshis / source_locking_script.
|
|
101
|
+
def raw_tx_hex(tx)
|
|
102
|
+
tx.to_ef_hex
|
|
103
|
+
rescue ArgumentError
|
|
104
|
+
tx.to_hex
|
|
105
|
+
end
|
|
106
|
+
|
|
60
107
|
def apply_auth_header(request)
|
|
61
108
|
request['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
62
109
|
end
|
|
@@ -83,20 +130,45 @@ module BSV
|
|
|
83
130
|
)
|
|
84
131
|
end
|
|
85
132
|
|
|
86
|
-
|
|
87
|
-
if rejected_status?(tx_status)
|
|
133
|
+
if rejected_status?(body)
|
|
88
134
|
raise BroadcastError.new(
|
|
89
|
-
body['detail'] || body['title'] ||
|
|
135
|
+
body['detail'] || body['title'] || body['txStatus'],
|
|
90
136
|
status_code: code,
|
|
91
137
|
txid: body['txid']
|
|
92
138
|
)
|
|
93
139
|
end
|
|
94
140
|
|
|
141
|
+
# A 2xx response without a txid is a malformed ARC reply —
|
|
142
|
+
# `parse_json` falls back to `{'detail' => raw}` on non-JSON,
|
|
143
|
+
# which would otherwise produce a `BroadcastResponse` full of
|
|
144
|
+
# `nil`s and `success? => true`. That's the same silent
|
|
145
|
+
# success-as-failure class of bug F5.13 closed for explicit
|
|
146
|
+
# error statuses; closing it here for shape corruption too.
|
|
147
|
+
unless body['txid']
|
|
148
|
+
raise BroadcastError.new(
|
|
149
|
+
'ARC returned a malformed 2xx response',
|
|
150
|
+
status_code: code
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
95
154
|
build_response(body)
|
|
96
155
|
end
|
|
97
156
|
|
|
98
|
-
def rejected_status?(
|
|
99
|
-
|
|
157
|
+
def rejected_status?(body)
|
|
158
|
+
# Case-insensitive match — the TypeScript reference
|
|
159
|
+
# (`ts-sdk/src/transaction/broadcasters/ARC.ts:155-166`) explicitly
|
|
160
|
+
# `.toUpperCase()`s both fields before membership / substring checks.
|
|
161
|
+
# ARC has a documented history of emitting values outside its own
|
|
162
|
+
# OpenAPI enum (e.g. `txStatus: "success"` for orphans in TS issue
|
|
163
|
+
# #105), so case normalisation is the defensive choice.
|
|
164
|
+
tx_status = body['txStatus'].to_s.upcase
|
|
165
|
+
return true if REJECTED_STATUSES.include?(tx_status)
|
|
166
|
+
return true if tx_status.include?(ORPHAN_MARKER)
|
|
167
|
+
|
|
168
|
+
extra_info = body['extraInfo'].to_s.upcase
|
|
169
|
+
return true if extra_info.include?(ORPHAN_MARKER)
|
|
170
|
+
|
|
171
|
+
false
|
|
100
172
|
end
|
|
101
173
|
|
|
102
174
|
def parse_json(raw)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
3
5
|
module BSV
|
|
4
6
|
module Transaction
|
|
5
7
|
# A BRC-74 merkle path (BUMP — Bitcoin Unified Merkle Path).
|
|
@@ -50,6 +52,19 @@ module BSV
|
|
|
50
52
|
@path = path
|
|
51
53
|
end
|
|
52
54
|
|
|
55
|
+
# Produce an independent copy: a new MerklePath whose outer +path+
|
|
56
|
+
# array and each inner level array can be mutated (via {#combine},
|
|
57
|
+
# {#trim}, {#extract}) without affecting the original. PathElements
|
|
58
|
+
# themselves are immutable and are shared between the original and
|
|
59
|
+
# the copy.
|
|
60
|
+
#
|
|
61
|
+
# @param source [MerklePath] the MerklePath being copied from
|
|
62
|
+
# @return [void]
|
|
63
|
+
def initialize_copy(source)
|
|
64
|
+
super
|
|
65
|
+
@path = source.path.map(&:dup)
|
|
66
|
+
end
|
|
67
|
+
|
|
53
68
|
# --- Binary serialisation (BRC-74) ---
|
|
54
69
|
|
|
55
70
|
# Deserialise a merkle path from BRC-74 binary format.
|
|
@@ -270,7 +285,11 @@ module BSV
|
|
|
270
285
|
# Merge another merkle path into this one.
|
|
271
286
|
#
|
|
272
287
|
# Both paths must share the same block height and merkle root.
|
|
273
|
-
# After combining, this path contains the union of all leaves
|
|
288
|
+
# After combining, this path contains the union of all leaves,
|
|
289
|
+
# trimmed to the minimum set required to prove every txid-flagged
|
|
290
|
+
# leaf. The trim matches the TS SDK's +combine+ behaviour and
|
|
291
|
+
# prevents accumulation of unnecessary sibling hashes across
|
|
292
|
+
# repeated merges.
|
|
274
293
|
#
|
|
275
294
|
# @param other [MerklePath] the path to merge in
|
|
276
295
|
# @return [self] for chaining
|
|
@@ -289,16 +308,182 @@ module BSV
|
|
|
289
308
|
|
|
290
309
|
existing = @path[h].to_h { |e| [e.offset, e] }
|
|
291
310
|
other.path[h].each do |elem|
|
|
292
|
-
|
|
311
|
+
# Preserve txid flag when combining: if the incoming leaf is
|
|
312
|
+
# flagged, never downgrade an existing entry.
|
|
313
|
+
if existing.key?(elem.offset)
|
|
314
|
+
existing_elem = existing[elem.offset]
|
|
315
|
+
if elem.txid && !existing_elem.txid
|
|
316
|
+
existing[elem.offset] = PathElement.new(
|
|
317
|
+
offset: existing_elem.offset,
|
|
318
|
+
hash: existing_elem.hash,
|
|
319
|
+
txid: true,
|
|
320
|
+
duplicate: existing_elem.duplicate
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
else
|
|
324
|
+
existing[elem.offset] = elem
|
|
325
|
+
end
|
|
293
326
|
end
|
|
294
327
|
@path[h] = existing.values.sort_by(&:offset)
|
|
295
328
|
end
|
|
296
329
|
|
|
330
|
+
trim
|
|
297
331
|
self
|
|
298
332
|
end
|
|
299
333
|
|
|
334
|
+
# --- Trim ---
|
|
335
|
+
|
|
336
|
+
# Remove all internal nodes that are not required by level zero
|
|
337
|
+
# txid-flagged leaves. Assumes the path has at least the minimum
|
|
338
|
+
# set of sibling hashes needed to prove every txid leaf. Leaves
|
|
339
|
+
# each level sorted by increasing offset.
|
|
340
|
+
#
|
|
341
|
+
# This is the Ruby port of the TypeScript SDK's +MerklePath.trim+.
|
|
342
|
+
# It is called implicitly by {#combine} and {#extract} and rarely
|
|
343
|
+
# needs to be invoked directly.
|
|
344
|
+
#
|
|
345
|
+
# @return [self] for chaining
|
|
346
|
+
def trim
|
|
347
|
+
@path.each { |level| level.sort_by!(&:offset) }
|
|
348
|
+
|
|
349
|
+
computed_offsets = []
|
|
350
|
+
drop_offsets = []
|
|
351
|
+
|
|
352
|
+
@path[0].each_with_index do |node, i|
|
|
353
|
+
if node.txid
|
|
354
|
+
# level 0 must enable computing level 1 for every txid node
|
|
355
|
+
trim_push_if_new(computed_offsets, node.offset >> 1)
|
|
356
|
+
else
|
|
357
|
+
# Array-index peer — works for well-formed compound BUMPs
|
|
358
|
+
# where level 0 is a sequence of adjacent (txid, sibling) pairs.
|
|
359
|
+
peer_index = node.offset.odd? ? i - 1 : i + 1
|
|
360
|
+
peer = @path[0][peer_index] if peer_index.between?(0, @path[0].length - 1)
|
|
361
|
+
# Drop non-txid level 0 nodes whose peer is also non-txid
|
|
362
|
+
trim_push_if_new(drop_offsets, peer.offset) if peer && !peer.txid
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
trim_drop_offsets_from_level(drop_offsets, 0)
|
|
367
|
+
|
|
368
|
+
(1...@path.length).each do |h|
|
|
369
|
+
drop_offsets = computed_offsets
|
|
370
|
+
computed_offsets = trim_next_computed_offsets(computed_offsets)
|
|
371
|
+
trim_drop_offsets_from_level(drop_offsets, h)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
self
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# --- Extract ---
|
|
378
|
+
|
|
379
|
+
# Extract a minimal compound MerklePath covering only the specified
|
|
380
|
+
# transaction IDs.
|
|
381
|
+
#
|
|
382
|
+
# Given a compound path (e.g. one merged from multiple single-leaf
|
|
383
|
+
# proofs in the same block), this method reconstructs the minimum
|
|
384
|
+
# set of sibling hashes at each tree level for every requested txid,
|
|
385
|
+
# assembles them into a new trimmed compound path, and verifies
|
|
386
|
+
# that the extracted path computes the same merkle root as the
|
|
387
|
+
# source.
|
|
388
|
+
#
|
|
389
|
+
# The primary use case is +Transaction#to_beef+: when a BUMP loaded
|
|
390
|
+
# from a proof store carries +txid: true+ flags for transactions
|
|
391
|
+
# that are not part of the current BEEF bundle, extracting only the
|
|
392
|
+
# bundled txids strips the phantom flags (and the now-unneeded
|
|
393
|
+
# sibling nodes) from the serialised output. See issue #302 for
|
|
394
|
+
# background.
|
|
395
|
+
#
|
|
396
|
+
# Matches the TS SDK's +MerklePath.extract+ behaviour.
|
|
397
|
+
#
|
|
398
|
+
# @param txid_hashes [Array<String>] 32-byte txids in internal byte
|
|
399
|
+
# order (reverse of display order). To pass hex strings, use
|
|
400
|
+
# +txid_hexes.map { |h| [h].pack('H*').reverse }+.
|
|
401
|
+
# @return [MerklePath] a new trimmed compound path proving only the
|
|
402
|
+
# requested txids
|
|
403
|
+
# @raise [ArgumentError] if +txid_hashes+ is empty, any requested
|
|
404
|
+
# txid is not present in the source path's level 0, or the
|
|
405
|
+
# extracted path's root does not match the source root
|
|
406
|
+
def extract(txid_hashes)
|
|
407
|
+
raise ArgumentError, 'at least one txid must be provided to extract' if txid_hashes.empty?
|
|
408
|
+
|
|
409
|
+
original_root = compute_root
|
|
410
|
+
indexed = build_indexed_path
|
|
411
|
+
|
|
412
|
+
# Build a level-0 hash → offset lookup
|
|
413
|
+
txid_to_offset = {}
|
|
414
|
+
@path[0].each do |leaf|
|
|
415
|
+
txid_to_offset[leaf.hash] = leaf.offset if leaf.hash
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
max_offset = @path[0].map(&:offset).max || 0
|
|
419
|
+
tree_height = [@path.length, max_offset.bit_length].max
|
|
420
|
+
|
|
421
|
+
needed = Array.new(tree_height) { {} }
|
|
422
|
+
|
|
423
|
+
txid_hashes.each do |txid|
|
|
424
|
+
tx_offset = txid_to_offset[txid]
|
|
425
|
+
if tx_offset.nil?
|
|
426
|
+
raise ArgumentError,
|
|
427
|
+
"transaction ID #{txid.reverse.unpack1('H*')} not found in the Merkle Path"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Level 0: the txid leaf itself + its tree sibling
|
|
431
|
+
needed[0][tx_offset] = PathElement.new(offset: tx_offset, hash: txid, txid: true)
|
|
432
|
+
sib0_offset = tx_offset ^ 1
|
|
433
|
+
unless needed[0].key?(sib0_offset)
|
|
434
|
+
sib = offset_leaf(indexed, 0, sib0_offset)
|
|
435
|
+
needed[0][sib0_offset] = sib if sib
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Higher levels: just the sibling at each height
|
|
439
|
+
(1...tree_height).each do |h|
|
|
440
|
+
sib_offset = (tx_offset >> h) ^ 1
|
|
441
|
+
next if needed[h].key?(sib_offset)
|
|
442
|
+
|
|
443
|
+
sib = offset_leaf(indexed, h, sib_offset)
|
|
444
|
+
if sib
|
|
445
|
+
needed[h][sib_offset] = sib
|
|
446
|
+
elsif (tx_offset >> h) == (max_offset >> h)
|
|
447
|
+
# Rightmost path in a tree whose last leaf has no real sibling —
|
|
448
|
+
# BRC-74 represents this as a duplicate marker.
|
|
449
|
+
needed[h][sib_offset] = PathElement.new(offset: sib_offset, duplicate: true)
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
compound_path = needed.map { |level| level.values.sort_by(&:offset) }
|
|
455
|
+
compound = self.class.new(block_height: @block_height, path: compound_path)
|
|
456
|
+
compound.trim
|
|
457
|
+
|
|
458
|
+
extracted_root = compound.compute_root
|
|
459
|
+
unless extracted_root == original_root
|
|
460
|
+
raise ArgumentError,
|
|
461
|
+
"extracted path root #{extracted_root.reverse.unpack1('H*')} " \
|
|
462
|
+
"does not match source root #{original_root.reverse.unpack1('H*')}"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
compound
|
|
466
|
+
end
|
|
467
|
+
|
|
300
468
|
private
|
|
301
469
|
|
|
470
|
+
def trim_push_if_new(arr, value)
|
|
471
|
+
arr << value if arr.empty? || arr.last != value
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def trim_drop_offsets_from_level(drop_offsets, level)
|
|
475
|
+
return if drop_offsets.empty?
|
|
476
|
+
|
|
477
|
+
drop_set = drop_offsets.to_set
|
|
478
|
+
@path[level].reject! { |node| drop_set.include?(node.offset) }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def trim_next_computed_offsets(offsets)
|
|
482
|
+
next_offsets = []
|
|
483
|
+
offsets.each { |o| trim_push_if_new(next_offsets, o >> 1) }
|
|
484
|
+
next_offsets
|
|
485
|
+
end
|
|
486
|
+
|
|
302
487
|
def build_indexed_path
|
|
303
488
|
@path.map do |level|
|
|
304
489
|
level.to_h { |elem| [elem.offset, elem] }
|
|
@@ -299,24 +299,36 @@ module BSV
|
|
|
299
299
|
# Transactions with a `merkle_path` are treated as proven leaves — their
|
|
300
300
|
# ancestors are not traversed further.
|
|
301
301
|
#
|
|
302
|
+
# Proven ancestors that share a block are combined into a single BUMP per
|
|
303
|
+
# block, then trimmed via {MerklePath#extract} so the serialised bundle
|
|
304
|
+
# carries only the +txid: true+-flagged leaves that correspond to
|
|
305
|
+
# transactions in this BEEF. This prevents "phantom" txid leaves carried
|
|
306
|
+
# over from a shared {LocalProofStore} entry (issue #302) and also
|
|
307
|
+
# shrinks the BEEF by dropping intermediate sibling hashes that are no
|
|
308
|
+
# longer needed.
|
|
309
|
+
#
|
|
310
|
+
# Ancestor +merkle_path+ objects are not mutated: paths are deep-copied
|
|
311
|
+
# before any combine/trim work.
|
|
312
|
+
#
|
|
302
313
|
# @return [String] raw BEEF V1 binary
|
|
314
|
+
# @raise [ArgumentError] if an ancestor's merkle_path does not actually
|
|
315
|
+
# contain that transaction's txid, or if the cleaned BUMP's root does
|
|
316
|
+
# not match the source root (both indicate corrupt proof data)
|
|
303
317
|
def to_beef
|
|
304
318
|
beef = Beef.new
|
|
305
319
|
ancestors = collect_ancestors
|
|
306
320
|
|
|
321
|
+
bump_index_by_height = build_beef_bumps(beef, ancestors)
|
|
322
|
+
|
|
307
323
|
ancestors.each do |tx|
|
|
308
324
|
entry = if tx.merkle_path
|
|
309
|
-
bump_idx = beef.merge_bump(tx.merkle_path)
|
|
310
325
|
Beef::BeefTx.new(
|
|
311
326
|
format: Beef::FORMAT_RAW_TX_AND_BUMP,
|
|
312
327
|
transaction: tx,
|
|
313
|
-
bump_index:
|
|
328
|
+
bump_index: bump_index_by_height.fetch(tx.merkle_path.block_height)
|
|
314
329
|
)
|
|
315
330
|
else
|
|
316
|
-
Beef::BeefTx.new(
|
|
317
|
-
format: Beef::FORMAT_RAW_TX,
|
|
318
|
-
transaction: tx
|
|
319
|
-
)
|
|
331
|
+
Beef::BeefTx.new(format: Beef::FORMAT_RAW_TX, transaction: tx)
|
|
320
332
|
end
|
|
321
333
|
beef.transactions << entry
|
|
322
334
|
end
|
|
@@ -724,6 +736,35 @@ module BSV
|
|
|
724
736
|
result << tx
|
|
725
737
|
end
|
|
726
738
|
|
|
739
|
+
# Group proven ancestors by block height, combine each group into a
|
|
740
|
+
# single compound merkle path (without mutating the source paths), then
|
|
741
|
+
# extract just the txids actually in the bundle. The resulting clean
|
|
742
|
+
# BUMPs are appended to +beef.bumps+, one per block height.
|
|
743
|
+
#
|
|
744
|
+
# @return [Hash{Integer => Integer}] block height → bump index mapping
|
|
745
|
+
def build_beef_bumps(beef, ancestors)
|
|
746
|
+
proven_by_height = ancestors.each_with_object({}) do |tx, h|
|
|
747
|
+
next unless tx.merkle_path
|
|
748
|
+
|
|
749
|
+
(h[tx.merkle_path.block_height] ||= []) << tx
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
bump_index_by_height = {}
|
|
753
|
+
proven_by_height.each do |height, txs|
|
|
754
|
+
# Deep-dup the first source so combine/trim can't mutate caller state
|
|
755
|
+
merged = txs.first.merkle_path.dup
|
|
756
|
+
txs.drop(1).each { |t| merged.combine(t.merkle_path) }
|
|
757
|
+
|
|
758
|
+
txid_hashes = txs.map { |t| t.txid.reverse }
|
|
759
|
+
clean = merged.extract(txid_hashes)
|
|
760
|
+
|
|
761
|
+
bump_index_by_height[height] = beef.bumps.length
|
|
762
|
+
beef.bumps << clean
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
bump_index_by_height
|
|
766
|
+
end
|
|
767
|
+
|
|
727
768
|
def compute_fee_sats(model_or_fee)
|
|
728
769
|
case model_or_fee
|
|
729
770
|
when nil
|
|
@@ -10,11 +10,18 @@ module BSV
|
|
|
10
10
|
module VarInt
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
+
# Maximum value representable by a Bitcoin VarInt (unsigned 64-bit).
|
|
14
|
+
MAX_UINT64 = 0xFFFF_FFFF_FFFF_FFFF
|
|
15
|
+
|
|
13
16
|
# Encode an integer as a Bitcoin VarInt.
|
|
14
17
|
#
|
|
15
|
-
# @param value [Integer] non-negative integer to encode
|
|
18
|
+
# @param value [Integer] non-negative integer to encode (0..2^64-1)
|
|
16
19
|
# @return [String] encoded binary bytes
|
|
20
|
+
# @raise [ArgumentError] if +value+ is negative or exceeds 2^64-1
|
|
17
21
|
def encode(value)
|
|
22
|
+
raise ArgumentError, "varint requires non-negative integer, got #{value}" if value.negative?
|
|
23
|
+
raise ArgumentError, "varint value #{value} exceeds uint64 max (#{MAX_UINT64})" if value > MAX_UINT64
|
|
24
|
+
|
|
18
25
|
if value < 0xFD
|
|
19
26
|
[value].pack('C')
|
|
20
27
|
elsif value <= 0xFFFF
|
data/lib/bsv/version.rb
CHANGED