Skip to main content
Quentin

Eight Bytes to Burn a Bridge - Relay Protocol’s Legacy Bitcoin Sighash Bug

How 64 missing bits let anyone turn confirmed Relay Protocol BTC deposits into miner fees.
Article heading

During our review of Relay Protocol’s Settlement Protocol, we spent some time around the Bitcoin sweep path, the small piece of machinery that moves a confirmed BTC deposit from a per-order address into Relay’s depository. It looked boring in the way good settlement code should look: one input, one depository output, one OP_RETURN, one MPC signature.

The dangerous part was not in a weird curve trick or a broken MPC protocol but in an older Bitcoin signing rule. Relay’s sweep used legacy P2PKH, whose SIGHASH_ALL preimage does not include the amount of the input being spent. That was fine when the signer was assumed to know the coin it was signing. It is much less fine when the signer is a generic NEAR Chain Signatures contract that only sees a 32-byte hash.

The function sweep() was also permissionless, and its UTXO value came straight from calldata. An attacker could take a real confirmed deposit, claim it was worth much less, ask the MPC to sign the resulting hash, and broadcast the transaction. Bitcoin would verify the signature against the real UTXO, accept the tiny outputs, and book the difference as miner fee.

So the bug was not that Relay signed the wrong transaction, it signed exactly the transaction it was asked to sign. The missing eight-byte amount field meant Bitcoin and Relay were not agreeing on what that transaction actually spent.

The Pieces on the Board

Know Bitcoin’s script types and BIP-143 already? Skip to the Root Cause.

Bitcoin UTXO

  • UTXO - Bitcoin has no accounts, a balance is a pile of unspent transaction outputs, each locked to a script. The UTXO set is public and queryable from any full node - including the deposit UTXOs this protocol holds.
  • P2PKH vs P2WPKH vs P2TR - one question drives the whole bug: does the signature commit to the input’s amount, or not?
ScriptYearSignatureCommits to input amount?
P2PKH~2009ECDSAno
P2WPKH2017ECDSAyes, via BIP-143
P2TR2021Schnorryes, via BIP-341
  • Aurora + Chain Signatures - Aurora is NEAR’s EVM-compatible execution layer, where the native NEAR contract Chain Signatures contracts run - an MPC-backed signing oracle: hand it a 32-byte hash and a derivation path, and it hands you back a valid ECDSA signature.

Permissionless by Design

Relay settles each Bitcoin-side order through a unique deposit address derived by NEAR MPC. The user sends BTC to this address. After the deposit confirms, anyone can call sweep() with the UTXO and fee. Relay builds a transaction that sends the deposit, minus the fee, to the canonical depository and records the order ID in an OP_RETURN output. NEAR MPC signs the transaction, and the caller broadcasts it.

BITCOIN UTXO SET
────────────────
real deposit: 1,000,000 sats
(txid, idx, scriptPubKey)
│ read

AURORA · BitcoinDepositAddress
──────────────────────────────
sweep(orderId, UTXO{value: 1,000,000}, fee)
buildSweepPayload → out[997,310 | OP_RETURN]
hashToSign = SHA256d(legacy preimage)
│ hash

NEAR MPC ─── sign(hash) ─── returns sig


OFF-CHAIN CALLER
────────────────
assembles scriptSig(sig, derived pubkey)
broadcasts raw Bitcoin transaction


BITCOIN CONSENSUS
─────────────────
sig valid against REAL 1,000,000-sat UTXO
fee = 1,000,000 − 997,310 = 2,690 sats
depository receives 997,310 sats

Aurora can’t see Bitcoin’s UTXO set, so the sweep runs off-chain. A caller reads the UTXO sent to the per-order deposit address and passes it to the contract as calldata. The contract builds the transaction, hashes it, and asks the NEAR MPC to sign that hash. The caller broadcasts the result.

Here’s the entry point that builds the payload:

function buildSweepPayload(
bytes32 orderId,
bytes calldata data
) external view returns (bytes memory payload, uint64 sweepAmount) {

(UTXO memory utxo, uint64 feeRate) = abi.decode(data, (UTXO, uint64));

if (feeRate > maxFeeRate) {
revert FeeRateTooHigh(feeRate, maxFeeRate);
}

uint256 fees = uint256(feeRate) * SWEEP_TX_SIZE;

if (utxo.value < fees) {
revert InsufficientUTXOValue(utxo.value, fees);
}

sweepAmount = utxo.value - uint64(fees);

if (sweepAmount < DUST_THRESHOLD) {
revert SweepAmountBelowDust(sweepAmount);
}

// ...
}

Three checks happen here, the contract bounds feeRate against an owner-set ceiling (maxFeeRate), confirms the declared value covers the fee (InsufficientUTXOValue), and makes sure the swept amount clears dust (SweepAmountBelowDust). So what’s missing?

Nowhere does it ask whether utxo.value is the value of the real UTXO sitting at utxo.txid / utxo.index. Aurora has no view of Bitcoin’s UTXO set, and data arrives straight from untrusted calldata.

What Bitcoin Actually Checks

So the signer will sign anything, and on Bitcoin, for each input, a node resolves the outpoint (prev_txid, prev_index) against its own UTXO set, learning the locking script it must satisfy and the output’s real value in sats.

For a legacy P2PKH spend, two checks matter. First, the node rebuilds the legacy sighash preimage for the input and verifies the ECDSA signature against its double-SHA-256 digest. Second, the economics, the total value of consumed UTXOs - read from the node’s own UTXO set - has to be at least the total value of the new outputs. And whatever’s left is the fee

Two different numbers here both get called “the fee”. The bug lives in the gap between them:

  • The contract’s sizing fee, feeRate * SWEEP_TX_SIZE, is a number the contract computes purely to size outputs[0]. It’s bounded by maxFeeRate.
  • Bitcoin’s realized fee is sum(inputs) - sum(outputs), computed by the node from its own UTXO set. Nothing in the contract bounds it.

The important split is Relay sizes the outputs from the caller’s declared value, while Bitcoin validates the spend against the real UTXO value.

The Eight Bytes That Aren’t There

After looking at both sides, it’s clear the bug isn’t on either side, it’s where they connect. The preimage builder serializes the legacy format faithfully, exactly to spec:

function buildPreImageForInput(
BitcoinTransactionData memory txData,
uint256 whichInput
) internal pure returns (bytes memory) {
bytes memory versionLE = Utils.encodeUint32LE(1);
bytes memory inputCountLE = encodeVarInt(txData.inputs.length);

bytes memory allInputs;
for (uint256 i = 0; i < txData.inputs.length; i++) {
bytes memory prevTxidLe = txData.inputs[i].txid;
bytes memory prevIndexLe = txData.inputs[i].index;
// scriptSig is the prevout scriptPubKey for the input being signed, empty otherwise
// ...
bytes memory sequenceLE = Utils.encodeUint32LE(0xFFFFFFFD);

allInputs = bytes.concat(
allInputs,
prevTxidLe, // which outpoint
prevIndexLe, // which index
scriptSigLen,
scriptSigBytes,
sequenceLE // and that's it: txData.inputs[i].value is never serialized
);
}

// ...outputs, locktime, hashType (0x01 = SIGHASH_ALL)...
}

Each BitcoinTransactionDataInput already carries a .value field. The serializer just never reads it back, the bytes.concat contributes outpoint, scriptSig, and sequence, then stops. The amount never enters the preimage.

Overall, the bug has three layers:

  1. A permissionless entry point (sweepbuildSweepPayload) that accepts a caller-supplied utxo.value without verifying it against the real Bitcoin UTXO.
  2. A legacy SIGHASH_ALL preimage that omits input amounts, as defined by the legacy spec.
  3. A general-purpose ECDSA oracle (ChainSignatures) that signs any double-SHA-256 hash you give it, with zero Bitcoin awareness.

If a trusted relayer were the only caller, the pattern would be safe. But sweep() is permissionless on purpose - the NatSpec says so:

// contracts/BitcoinDepositAddress.sol  (sweep, L133-155)
/// @dev Permissionless — anyone can call. Re-callable after the pending signature
/// cooldown expires to prevent griefing via low gas settings.
function sweep(
bytes32 orderId,
bytes calldata data,
GasSettings calldata gasSettings
) external {

// ...builds the payload, hashes it, and requests the MPC signature...

}

Putting it together, anyone can call sweep() with a real confirmed outpoint, but a fake low UTXO input value. The contract produces a valid P2PKH signature believing the input holds fewer sats. A node checks that signature using the real UTXO’s locking script, and because the amount never entered the digest, nothing objects. The transaction creates outputs worth very few sats, nobody compares the signer’s expected input to the real one, and the node books the leftover huge amount of sats as fee.

So why burn the coins? Why not point them at an attacker’s address and walk away rich? Because the caller doesn’t get to pick the destination. Both outputs are defined by the contract, not by calldata:

// Output 0: Depository (P2PKH) - script is constructor-set, NOT from calldata

outputs[0] = BitcoinTransactionDataOutput({
value: Utils.encodeUint64LE(sweepAmount),
script: depositoryScriptBytes
});


// Output 1: OP_RETURN carrying orderId, value 0 - not a value sink

outputs[1] = BitcoinTransactionDataOutput({
value: Utils.encodeUint64LE(0),
script: abi.encodePacked(hex"6a42", "0x", ChainSignatures.stringifyBytes(...))
});

Output 0 pays depositoryScriptBytes, baked in at construction. Output 1 is an OP_RETURN worth zero.

So the attacker can lie about utxo.value, but cannot redirect either output. The only lever left is Bitcoin’s implicit fee. Pull it and the deposit leaves as fee. That looks like pure destruction, but the fee goes to whoever mines the block - a miner-attacker, or anyone with a fee-split arrangement with one, recaptures it. For everyone else it’s arson. Either way the depositor’s coins are gone.

One Outpoint, Two Truths

A PoC here doesn’t need to spend a coin. There’s a sharper way, show that the MPC signs the same digest whether you tell the contract the input is worth 10,000 sats or 1,000,000. If the two digests are equal, one ECDSA signature is valid for both - which means the input value was never part of the contract’s promise.

Two Foundry tests pin down the two halves of the bug. Both build the same transaction and vary only the input’s declared value:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {BitcoinDepositSweepBuilder} from "../contracts/BitcoinDepositSweepBuilder.sol";
import {Utils} from "../contracts/Utils.sol";
import {
UTXO,
BitcoinTransactionDataInput,
BitcoinTransactionDataOutput,
BitcoinTransactionData
} from "../contracts/PayloadBuilders/BitcoinPayloadBuilder.sol";

contract LegacySighashTest is Test {
BitcoinDepositSweepBuilder builder;

bytes32 constant TXID = bytes32(type(uint256).max / 0xFF * 0xAA); // 0xaaaa…aaaa
bytes32 constant ORDER_ID = bytes32(type(uint256).max / 0xFF * 0x11); // 0x1111…1111
bytes REAL_SCRIPT = hex"76a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac";
// Depository P2PKH script (25 bytes); base64 of this is what the constructor receives.
bytes DEPOSITORY = hex"76a914cccccccccccccccccccccccccccccccccccccccc88ac";
// OP_RETURN output for ORDER_ID: 0x6a42 ‖ "0x" ‖ hex(orderId), value 0 (68 bytes).
bytes OP_RETURN_OUT =
hex"6a42307831313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131";

function setUp() public {
// base64("76a914cc..cc88ac") == "dqkUzMzMzMzMzMzMzMzMzMzMzMzMzMyIrA=="; maxFeeRate = 50.
builder = new BitcoinDepositSweepBuilder(address(this), "dqkUzMzMzMzMzMzMzMzMzMzMzMzMzMyIrA==", 50);
}

function _payload(uint64 inputValue) internal view returns (bytes memory) {
BitcoinTransactionDataInput[] memory ins = new BitcoinTransactionDataInput[](1);
ins[0] = BitcoinTransactionDataInput({
txid: abi.encodePacked(TXID),
index: Utils.encodeUint32LE(0),
script: REAL_SCRIPT,
value: Utils.encodeUint64LE(inputValue) // the ONLY thing we vary
});
BitcoinTransactionDataOutput[] memory outs = new BitcoinTransactionDataOutput[](2);
outs[0] = BitcoinTransactionDataOutput({ // depository P2PKH, sweepAmount = 7,310
value: Utils.encodeUint64LE(7_310), script: DEPOSITORY
});
outs[1] = BitcoinTransactionDataOutput({ // OP_RETURN, value 0
value: Utils.encodeUint64LE(0), script: OP_RETURN_OUT
});
return abi.encode(BitcoinTransactionData({inputs: ins, outputs: outs}));
}

function test_legacySighashIgnoresInputValue() public view {
bytes32 hLie = builder.hashToSign(_payload(10_000)); // the attacker's lie
bytes32 hTruth = builder.hashToSign(_payload(1_000_000)); // the real UTXO
assertEq(hLie, hTruth); // identical -> one signature is valid for both
}

function test_underdeclaredValueIsAccepted() public view {
UTXO memory utxo = UTXO({txid: TXID, index: 0, value: 10_000, scriptPubKey: REAL_SCRIPT});
(, uint64 sweepAmount) = builder.buildSweepPayload(ORDER_ID, abi.encode(utxo, uint64(10)));
assertEq(sweepAmount, 7_310);
// On Bitcoin: real input 1,000,000 - outputs 7,310 = 992,690 sats burned as miner fee.
}
}

The digest ignores the value, and the test test_legacySighashIgnoresInputValue is the whole bug in one assertEq, the lie (10,000) and the truth (1,000,000) hash to the same double-SHA-256 digest, so a single MPC signature is valid for both:

lie   (value = 10,000 sats):
0xa2700ab86539ee75bddd64ea4b0f3a00f82bc82ff31a625b5f069e00e0b0d4ba

truth (value = 1,000,000 sats):
0xa2700ab86539ee75bddd64ea4b0f3a00f82bc82ff31a625b5f069e00e0b0d4ba

The contract swallows the lie, and the test_underdeclaredValueIsAccepted runs the under-declared 10,000 through buildSweepPayload itself - it clears every check and sizes the depository output to 7,310 sats. Against the real 1,000,000-sat UTXO, that leaves 992,690 sats unaccounted for, waiting to become fee.

Letting Bitcoin Burn It

A signature becomes a loss when someone broadcasts a spend that beats the honest sweep, and every ingredient for that spend is already public. The deposit outpoint and its scriptPubKey sit on-chain, and the per-order pubkey derives from (address(this), orderId). So an attacker reads the confirmed UTXO from any Bitcoin node, asks the NEAR MPC to sign hashToSign(payload) for a value far below the real one, assembles the P2PKH scriptSig (<sig> <pubkey>), and broadcasts the transaction.

The attacker’s transaction does not compete for a place in the next block, it replaces the honest, depository-crediting sweep - both spend the same outpoint, so only one can confirm. Every input carries nSequence = 0xFFFFFFFD, which opts the transaction into Replace-By-Fee (BIP-125), and the attacker’s version offers almost the entire deposit to the miner as fee. No honest replacement at a normal fee can outbid that without spending more than the deposit is worth.

Teaching the Signer to Count

The remediation (commit 4beb1a4) goes after the root cause at the signing layer instead of bolting a permission check onto sweep(). We offered two options, a minimal one (restrict sweep() to authorized callers) and a stronger one (move to SegWit and let Bitcoin enforce the amount). Relay took the stronger path and kept sweep() permissionless, meaning Bitcoin’s consensus layer now enforces what a fragile off-chain trust assumption used to enforce badly.

The function buildPreImageForInput was rewritten into a real BIP-143 P2WPKH sighash, and this time the input amount is in the commitment:

function buildPreImageForInput(
BitcoinTransactionData memory txData,
uint256 whichInput
) internal pure returns (bytes memory) {
BitcoinTransactionDataInput memory input = txData.inputs[whichInput];
bytes memory outpoint = bytes.concat(input.txid, input.index);
bytes memory scriptCode = _buildScriptCode(input.script);

bytes memory firstHalf = bytes.concat(
Utils.encodeUint32LE(1),
_hashPrevouts(txData.inputs),
_hashSequence(txData.inputs),
outpoint,
scriptCode
);
return bytes.concat(
firstHalf,
input.value, // value of the input being signed
Utils.encodeUint32LE(0xFFFFFFFD),
_hashOutputs(txData.outputs),
Utils.encodeUint32LE(0),
Utils.encodeUint32LE(0x01)
);
}

Run the two-transaction experiment again and where it used to produce one identical digest, BIP-143 now produces two. If the contract signs a hash built from the declared 10,000, a node verifies against a hash built from the real 1,000,000 it reads from its own UTXO set:

contract signs  (value = 10,000):
0x2b1fbf13703033f154e96679e0a09549276053fea8412bfc5b5a5c123fc9c0d8

node verifies (value = 1,000,000):
0x67912bcf4c83c286e2757bc39b9297de47602b467754c13043c577e591e2be26

The lie no longer produces a spendable transaction.

Engagement & Disclosure

  • Audit: Zellic review of Relay Protocol’s Settlement Protocol contracts.
  • Finding: Permissionless sweep with unverified UTXO value burns depositor funds as miner fees - Critical (Likelihood: High, Impact: Critical).
DateEvent
March 10, 2026Kick-off and start of the primary review period
March 10–17, 2026Finding identified and reported to Relay
March 17, 2026End of the primary review period
Post-reviewFixed in commit 4beb1a4 (legacy P2PKH → P2WPKH + BIP-143)
This postCoordinated public disclosure

Conclusion

A permissionless sweep and a legacy P2PKH sighash are both defensible on their own, and the trust boundary that broke belongs to neither. It appeared only when 3 choices were combined: calldata trusted behind an open caller, a sighash silent about the input amount, and a generic ECDSA oracle that signs whatever 32-byte hash it is handed.

This general shape is worth keeping in mind. Anything that builds a hash on one chain and hands it to a signer on another has two seams worth auditing: what the signature commits to, and what the contract verifies. For this bug, both are short:

LEGACY SIGNATURE
────────────────
commits: outpoint (txid, index)
scriptCode (prevout script)
outputs (amounts + scripts)
locktime, sequence, hashType
omits: the input amount

THE SWEEP CONTRACT
──────────────────
verifies: feeRate ≤ maxFeeRate
utxo.value ≥ fees
sweepAmount ≥ dust
ignores: utxo.value vs the chain
caller identity

Neither side commits to the input amount, and neither checks it against the chain. It comes back to the missing eight-byte field: Bitcoin and Relay never agreed on what the transaction spent, and the difference left as the miner’s fee.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.