Written by OpenAI Codex, refined by Claude Opus 5; not all claims are human verified.
This educational functional test demonstrates the rangeproof-cache ambiguity
introduced by Elements commit
212c43f475fc202b5b9e6dbb1f1c616e1a06a6f7.
It creates all transactions and commitments locally on a temporary custom
regtest chain; it does not use Liquid mainnet data.
The actors use separate nodes and wallets:
- victim user: receives and continues to hold a legitimate 10 L-BTC output;
- functionary stand-in: validates transactions and produces the block;
- attacker: receives two legitimate 1 L-BTC outputs, using one for the valid cache primer and the other for the inflation transaction;
- blocks-only validator: runs with
-blocksonly, so it declines relayed transactions and only ever meets the primer while connecting a block.
The transaction sequence is:
- A funding transaction gives the victim 10 L-BTC and the poorer attacker two separate 1 L-BTC outputs. The victim's output is never spent.
- Before priming the cache, the functionary correctly rejects the inflation transaction.
- The primer creates a one-satoshi OP_RETURN output with a valid rangeproof. The first three nodes accept it into their mempools, creating the cache entry. The blocks-only node does not, and so has no entry. It is then mined in its own block, which every node connects, reaching identical chainstates.
- The inflation transaction spends the attacker's other 1 L-BTC output. It creates a positive 4,000 L-BTC output and a negative balancing output whose malformed rangeproof has the same ambiguous cache bytes as the primer's valid proof tuple. It is mined in the following block.
- The primed nodes accept the inflation transaction on the affected build. The blocks-only validator rejects its block and stays two blocks behind.
- The attacker spends the inflated 4,000 L-BTC output with an ordinary transaction, which is mined. This needs no cache trickery of its own: the commitment genuinely commits to 4,000, so the spend balances normally.
The primer is an ordinary valid transaction. Its purpose is only to place a successful proof result in the validator's process-local cache.
The primer needs a variable-length scriptPubKey that can hold exact attacker-
chosen bytes. Its long script is:
OP_RETURN PUSH67 <negative value commitment> <asset generator> OP_RETURN
The third field is an asset generator, which matters when checking any of this
against chain data. Elements does not hash the serialized nAsset field: for an
explicit asset, VerifyAmounts first replaces it with the 33-byte generator
derived from the asset tag, and only that reaches the cache key. For a blinded
asset the field already is a generator, so the two coincide.
Both incident outputs are explicit L-BTC, so their chunk is 0a0a488d…dc6d, not
016d521c…026f. The substitution is easy to miss because both are 33 bytes: use
the wrong one and the preimage is still the right length, it simply does not
match. The reconstruction below depends on getting it right.
At 69 bytes this script is nowhere near a relay limit. Elements defaults
nMaxDatacarrierBytes to MAX_OP_RETURN_RELAY, which is 100000 rather than
Bitcoin's 83.
%%{init: {"flowchart": {"nodeSpacing": 16, "rankSpacing": 16, "padding": 10, "diagramPadding": 4, "subGraphTitleMargin": {"top": 0, "bottom": 8}}}}%%
flowchart LR
subgraph P["Output 0 · 1 satoshi"]
direction LR
subgraph L[" "]
direction TB
subgraph W["Output witness · rangeproof"]
direction TB
R["① valid primer proof"]
end
C["② value commitment<br/>③ asset generator"]
W ~~~ C
end
subgraph S["scriptPubKey"]
direction TB
SL["④ OP_RETURN PUSH67 — script prefix<br/>⑤ future negative value commitment<br/>⑥ future asset generator<br/>⑦ final OP_RETURN"]
end
L ~~~ S
end
style L fill:transparent,stroke:transparent
class C,SL list
classDef list text-align:left
The nonce and surjection proof are omitted. The circled numbers mark each chunk's position in the cache-key byte sequence shown below.
That standard data-carrier form conveniently embeds three fields belonging to
the later inflation output. In the attack transaction, the field boundary is
shifted: the first OP_RETURN PUSH67 bytes become the end of the malformed
rangeproof, while the embedded commitments and final OP_RETURN become the
attack output's separate value, asset, and script fields.
The cache does not treat OP_RETURN data specially. Elements passes the entire
scriptPubKey to secp256k1_rangeproof_verify as the proof's additional
commitment, so the cache key must include the same complete script.
The vulnerable code hashes four fields without recording their boundaries:
cache key = rangeproof || value commitment || asset generator || scriptPubKey
The cache records the validated primer output like this:
| Output being checked | rangeproof field |
value field |
asset field |
scriptPubKey field |
Cache outcome |
|---|---|---|---|---|---|
| Primer | ✓ ① parsed and valid | ② primer commitment | ③ primer generator | ④ ⑤ ⑥ ⑦ | Stores H(①②③④⑤⑥⑦) = valid |
Here ④ is the primer script prefix. The attacker chose ⑤ and ⑥ for later use as
the value commitment and asset generator, and ⑦ for later use as OP_RETURN.
After checking the primer, the cache retains only the salted digest and the
fact that this sequence of bytes verified successfully.
The primer output amount does not limit or determine the amount created by the inflation transaction.
%%{init: {"flowchart": {"nodeSpacing": 16, "rankSpacing": 16, "padding": 10, "diagramPadding": 4, "subGraphTitleMargin": {"top": 0, "bottom": 8}}}}%%
flowchart TB
subgraph O0["Output 0"]
direction TB
P["Positive output<br/>4,000 L-BTC<br/>spendable<br/>valid rangeproof"]
end
subgraph O1["Output 1 · balancing output"]
direction LR
subgraph L[" "]
direction TB
subgraph W["Output witness · rangeproof"]
direction TB
R["① valid primer proof<br/>② primer value commitment<br/>③ primer asset generator<br/>④ primer script prefix"]:::skipped
end
C["⑤ negative value commitment<br/>⑥ asset generator"]
W ~~~ C
end
subgraph S["scriptPubKey"]
direction TB
S7["⑦ OP_RETURN"]
end
L ~~~ S
end
O0 ~~~ O1
style L fill:transparent,stroke:transparent
style W stroke-dasharray:6 4
class R,C list
classDef skipped stroke-dasharray:6 4
classDef list text-align:left
Compare the matching witness boxes: the primer's entire rangeproof is ①. In the balancing output, the rangeproof field is ①②③④. The added bytes make that complete field malformed, but the cache hit means it is never parsed as a rangeproof. The dashed outlines mark the field and chunks skipped by that parser; their bytes are still included in the cache hash.
The test uses ordinary test-only curve arithmetic to balance the inflation transaction's real 1 L-BTC input against its positive 4,000 L-BTC output and negative confidential output. A valid rangeproof would forbid the latter.
Now the same table can include the inflation balancing output:
| Output being checked | rangeproof field |
value field |
asset field |
scriptPubKey field |
Cache outcome |
|---|---|---|---|---|---|
| Primer | ✓ ① parsed and valid | ② primer commitment | ③ primer generator | ④ ⑤ ⑥ ⑦ | Stores H(①②③④⑤⑥⑦) = valid |
| Inflation balancing output | ⏭ ① ② ③ ④ malformed; parser skipped |
⑤ negative commitment | ⑥ asset generator | ⑦ OP_RETURN | Same bytes, so the cache returns valid |
The field boundaries have moved, but both rows concatenate to
①②③④⑤⑥⑦. For the inflation output, the rangeproof field is the malformed
sequence ①②③④. Its salted digest nevertheless hits the primer's cache entry,
so the cached valid result bypasses the libsecp256k1 rangeproof parser.
Transaction deserialization still reads the malformed rangeproof as an opaque
byte vector.
The functional test matches the incident's two-block ordering: it mines the
primer in one block and the inflation transaction in the next. The cache entry
is created when affected nodes first validate the primer for their mempools;
connecting a block consults this cache but does not populate it, because
ConnectBlock passes fCacheResults = fJustCheck. Block connection marks a
matching entry as discardable, but allow_erase() only sets a flag on the
cuckoo-cache slot: the entry stays readable until some later insert reuses one
of its eight candidate slots. Continuously running nodes therefore still hit it
in the next block. That survival is a probability rather than a guarantee,
which is one reason to keep the two blocks adjacent. A node that sees the
primer only while connecting its block is never primed at all.
In the Liquid incident, two transactions containing the same primer tuple were mined in block 4,050,335:
271147100a94f6337b6c3db39b30c92d5b97ed91597307b6f721f73a15187ec571c93d4339fe8328981a2ec0dd23808d1ff104dc4c4cbcfc5c06fb2bb622f411
Their primer outputs are byte-for-byte identical: the same 69-byte
scriptPubKey, the same value commitment, the same explicit L-BTC asset, the
same null nonce, and the same 4,166-byte proof. Each therefore produces the
same single cache entry. The malformed inflation transaction
f24a4b179b5cc7e88b25a763911f7cbdf2bf45d1d1b5ab611e94461cef0a183f
was then the first non-coinbase transaction in block 4,050,336.
The whole primer tuple is recoverable from that inflation transaction alone,
because every chunk of it is embedded in the malformed proof or in the output
being checked. Splitting the 4,234-byte proof as 4166 || 33 || 33 || 2 gives:
① proof[0:4166] valid, min_value 0, max_value 2^52-1
② 09d6c615…e683f5 primer value commitment = proof[4166:4199]
③ 0a0a488d…282ddc6d L-BTC asset generator = proof[4199:4232]
④ 6a43 primer script prefix = proof[4232:4234]
⑤ 086f5d67…5b1d01d8 balancing output's value commitment
⑥ 0a0a488d…282ddc6d the same generator (both outputs are explicit L-BTC)
⑦ 6a balancing output's scriptPubKey
so the primer's 69-byte scriptPubKey was ④⑤⑥⑦:
6a43086f5d67160fc4b477954fb09ef321e5b589d7a07740a1a6df494ed2335b1d01d8
0a0a488de4899d0ae757f6cf8368663184d164106111ed9eaecf510e35282ddc6d6a
Checked directly against libsecp256k1, ① verifies against ②, ③ and that
script, the on-chain tuple ①②③④ against ⑤, ⑥, ⑦ does not, and the two
concatenations are the same 4,301 bytes. Note that ③ and ⑥ are the derived
generator secp256k1_generator_generate(6d521c…026f), not the serialized asset
field 016d521c…026f.
Block 4,050,335 confirms the reconstruction exactly. Output 0 of each of the two transactions above is precisely the predicted primer, and each one's four-field preimage equals the malformed output's. Their two-field preimages do not, which is the mechanical reason the older implementation is unaffected however many times it sees the primer.
That block also shows how the attempt was staged. All three transactions spend
sibling outputs of one funding transaction
0fbde521636bd2c3f284a86e753f5613f06a50efa26a2cec80bb7c3b92123b95 — the two
primers take outputs 0 and 1, the inflation transaction takes output 2 — and
each pays exactly 58 satoshis in fees. The two primers are independent
transactions rather than a chain, so either could confirm on its own. That fits
propagation redundancy, though it does not establish it.
The preceding block is not fundamental to the collision, but it makes the attack dependable. The affected cache persists across blocks, so a continuously running node reaches block 4,050,336 with the valid result already stored.
Nothing about the mechanism requires two blocks, or even two transactions.
Mempool acceptance calls CheckTxInputs with pvChecks = NULL and
cacheStore = true, so the range checks run inline in output order and store
their results. A single transaction whose primer output sits at a lower index
than its balancing output would therefore prime its own cache entry while being
accepted, and would then be mined in the usual way.
What does not work is relying on ordering within a block. For an ordinary
validator this fails outright rather than unreliably: ConnectBlock never
stores results, so a primer seen only inside a block leaves no entry, whichever
order the outputs appear in. Ordering is a race only for the block producer's
own TestBlockValidity pass, which runs with fJustCheck = true and therefore
does store, but queues its checks for parallel workers.
Two primer transactions were mined rather than one, and the reason is still not established. Either alone would have primed any node that saw it, and between them they create only a single cache entry.
Every node in the test runs the same affected binary, so the split has to come
from somewhere other than the code. It comes from -blocksonly. That node
declines relayed transactions, so the primer never passes through its mempool
and it never stores the entry, but it connects both blocks normally and holds
exactly the same chainstate as its peers. It then rejects the inflation block
they have just accepted. No restart, no downgrade, and nothing else about it is
special.
A blocks-only node is not a contrived special case: it stands for any node
that reaches block 4,050,336 with an empty cache, including one syncing from
scratch. On the affected build the chain is not re-syncable, since the entry
can only be created by mempool acceptance and initial block download never
accepts the primer into a mempool. The one caveat is that VerifyAmounts
is reached only when fScriptChecks is set, which assumevalid can switch off.
That does not help today — the block is newer than any shipped assumevalid
value and inside the two-week window — but a later release whose assumevalid
sits past this height would paper the block over rather than repair it. Any
archival re-validation of this chain should therefore set assumevalid=0.
A real rejecting validator could equally be running the older implementation or a proper fix.
For this particular incident construction, there are two ways to restore deterministic rejection:
- Downgrade to the older public stable implementation. Its cache key used only the proof and value commitment, so this four-field collision does not hit even after it sees the primer. This explains why the unupgraded Mempool Liquid instance rejected block 4,050,336 while Blockstream's instance accepted it. Downgrading is useful containment, not the ideal final repair: the older key was being changed precisely because it did not bind the asset commitment and script.
- Upgrade to a proper fix. An unambiguous length-prefixed cache key (or one bound to the transaction and output) rejects the attack whether or not the node previously saw the primer.
The test therefore reproduces both visible inflation and the process-local consensus split in the affected intermediate implementation.
The test builds its own transactions rather than replaying the incident's. None of the following differences is load-bearing for the collision, but each hides something real:
| Test | Incident (f24a4b…183f) |
|
|---|---|---|
| Balancing output's asset | blinded | explicit L-BTC |
| Its surjection proof | copied from the positive output, valid because both outputs share an asset commitment | none; an explicit asset requires the field to be empty |
| Fee | none: the balancing output is C_input - C_positive, and adding a fee would need fee * H, i.e. the generator the test never derives |
explicit 58 satoshis, in output 3 |
| Outputs | 2 | 4: positive, balancing, a second confidential-asset output, and the fee |
| Attacker's funding outputs | 2, one per transaction | 3 siblings of 0fbde5…3b95, for two primers and the inflation transaction |
| Nonce on the positive output | present, so the attacker's wallet lists it | null, so the attacker's wallet would not have listed it either |
The blinded asset is the notable one: it costs the attacker an extra artefact,
the copied surjection proof, that the incident did not need. It is not avoidable
here, because the blinding RPCs always blind an output's asset alongside its
value, and matching the incident would mean building the proofs by hand — the
one thing this test is written to avoid. The missing fee has the same root: the
balancing output would absorb it perfectly well, but computing fee * H needs
the same generator.
Worth stating alongside: the surjection-proof cache is not open to the same
trick, because ComputeEntrySurjectionProof binds the wtxid, so no entry of
its can be primed by a different transaction. That is the shape the rangeproof
key should have had.
No peg-out or HSM simulation is needed to demonstrate the economic consequence:
victim user: still owns 10 L-BTC on the Liquid ledger
attacker: turns one real 1 L-BTC input into a spendable 4,000 L-BTC output
ledger: now contains more positive, spendable L-BTC than before
BTC treasury: unchanged, so not every positive L-BTC claim can be redeemed
The negative output makes the Pedersen commitment equation balance, but cannot be spent and is hidden behind the bypassed rangeproof check. A real end-to-end peg-out test would require a Bitcoin regtest node and functionary signing setup; that machinery is omitted because it does not illuminate the cache-key bug or the inflation.
This uses regtest.
git clone https://github.com/ElementsProject/elements.git
cd elements
# No release tag contains the ambiguous four-field cache key; pin its commit.
git checkout 212c43f475fc202b5b9e6dbb1f1c616e1a06a6f7
# Install the normal Elements build dependencies first, then build a daemon
# with wallet and SQLite support (the defaults).
./autogen.sh
./configure --without-gui --disable-bench
make -j4
# Copy the Python file from this gist into the functional-test directory.
cp /path/to/feature_rangeproof_cache_collision.py test/functional/
chmod +x test/functional/feature_rangeproof_cache_collision.py
test/functional/test_runner.py feature_rangeproof_cache_collision.pyExpected result on the affected commit:
Vulnerability reproduced: a 1 L-BTC input created a spendable 4,000 L-BTC output
Primed nodes accepted the next block; the blocks-only validator rejected it
The inflated output was then spent by an ordinary transaction and mined
The test deliberately passes when it reproduces the vulnerable behavior. A regression test for a corrected implementation should instead require the malformed transaction to remain rejected after the primer.