Relay Protocol's simplistic Bitcoin transaction method hides complexities that may expose users to manipulation risks. Understand its settlement flaws.
SIGHASH_ALL signature does not include the amount of the Bitcoin being spent. In situations where the signer's identity and context regarding the transaction are obvious, this could be manageable. However, issues arise when the signer is an agnostic NEAR Chain Signatures contract, which only engages with a 32-byte hash.
This design opens the proverbial doors to manipulation. The permissionless nature of the sweep() function, which directly uses a UTXO value extracted from user-supplied calldata, creates a clear pathway for would-be attackers. They could initiate a transaction that reports a far lower value for a confirmed deposit than its actual worth. In effect, they would coax the MPC to sign for this misleadingly smaller hash value and broadcast it. Bitcoin’s network would validate the transaction as legitimate, approving a tiny output while chalking up the remaining difference as a miner's fee.
The crux of the problem, then, isn't that Relay is mistakenly signing erroneous transactions; rather, it’s effectively signing exactly what it’s asked to. The absence of an amount field leads to a fundamental misalignment between what Relay presupposes the transaction entails and what the Bitcoin network recognizes as its true context.
The Technical Underpinnings
If you’re already familiar with Bitcoin's script types and have a handle on BIP-143 specifications, you might want to skip ahead to the [Root Cause](https://www.zellic.io/blog/signing-blind#the-eight-bytes-that-arent-there) for a deeper dive.Bitcoin UTXO Explained
- **Unspent Transaction Outputs (UTXO)**: In Bitcoin, there's no concept of accounts. Instead, your balance is effectively the sum of various unspent transaction outputs, each linked to a specific script. The UTXO set is publicly accessible and can be queried from any full node, including those managing deposits within this protocol. - **Signature Types**: The incident hinges on a critical question: Does the signature commit to the input’s amount? The difference between P2PKH, Pay-to-Witness-Public-Key-Hash (P2WPKH), and Pay-to-Taproot (P2TR) makes a significant impact on this aspect. | Script | Year | Signature | Commits to input amount? | |---------|-------|-----------|--------------------------| | P2PKH | ~2009 | ECDSA | **No** | | P2WPKH | 2017 | ECDSA | Yes, via [BIP-143](https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki) | | P2TR | 2021 | Schnorr | Yes, via BIP-341 | - **Aurora and Chain Signatures**: Aurora serves as NEAR's execution layer compatible with Ethereum Virtual Machine (EVM), where its native Chain Signatures contracts operate. This framework supports an MPC-based signing oracle, which generates valid ECDSA signatures based on a given 32-byte hash and a derivation path.Permissionless by Nature
The structure of Relay relies on a specific deposit address each time a new Bitcoin transaction is settled via NEAR’s MPC. Users direct BTC to this unique address, and once the deposit is confirmed, any entity can invoke thesweep() function with the relevant UTXO and associated fees. The resulting transaction is crafted to funnel the deposit—minus transaction fees—into Relay’s main depository while logging the corresponding order ID in an OP_RETURN output. The NEAR MPC then signs off on the transaction, ready for broadcasting by the caller.
However, it’s essential to highlight that Aurora lacks visibility into the Bitcoin UTXO set, necessitating that the sweep be executed off-chain. The caller must read the UTXO specific to the designated deposit address and relay that information to the contract as calldata. Subsequently, the contract assembles the transaction, computes the hash, and solicits a signature from NEAR MPC, which the caller ultimately broadcasts.
For a clearer picture of how the payload is constructed, let’s look at the corresponding entry function:When examining the verification mechanism in play, three key checks take center stage. The contract compares the feeRate against a ceiling defined by maxFeeRate, ensures that the declared UTXO value meets the fee requirements (InsufficientUTXOValue), and confirms that the amount swept exceeds a certain dust threshold (SweepAmountBelowDust). But here's the catch: there's a critical verification missing.
The fundamental flaw is the lack of a check to confirm whether utxo.value accurately reflects the real UTXO value linked to utxo.txid and utxo.index. Aurora operates without any visibility into Bitcoin’s complete UTXO database, and any data received comes directly from potentially untrustworthy inputs.
How Bitcoin Handles Validation
On the Bitcoin network, the validation process is far more thorough. For each UTXO being accessed, a node retrieves the outpoint (prev_txid, prev_index) from its own UTXO database. This allows it to validate the locking script required for spending and determine the actual value of the output in satoshis.
Take, for example, a traditional P2PKH transaction. There are two significant checks that need to occur. Initially, the node reconstructs the legacy sighash preimage for the input and evaluates the ECDSA signature against its double-SHA-256 digest. The second vital step involves economics: the cumulative value of the consumed UTXOs—which the node reads from its own set—must equal or exceed the new output values. Any remainder from the total value is considered the fee.
However, there’s a disparity here that needs addressing. The term “fee” tends to be used interchangeably, yet it can refer to two distinct quantities:
- Contract Sizing Fee: This is calculated as
feeRate * SWEEP_TX_SIZE, purely used to determine the size ofoutputs[0]. This is confined within the limit set bymaxFeeRate. - Realized Bitcoin Fee: This is the result of the formula
sum(inputs) - sum(outputs), derived from the node’s own UTXO valuations, and is not constrained by anything in the contract.
This distinction is pivotal: while Relay bases output sizes purely on the caller's specified values, Bitcoin itself assesses the transaction against actual UTXO values. This difference could spell trouble.
The Missing Eight Bytes
A closer look reveals that the fault lies not exclusively with either party, but rather at the intersection of their processes. The preimage generation adheres meticulously to legacy specifications:
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 // that's it: txData.inputs[i].value is never serialized
);
}
// ...outputs, locktime, hashType (0x01 = SIGHASH_ALL)...
}
Despite each BitcoinTransactionDataInput containing a .value attribute, the serializer fails to extract this information. It only includes components such as the outpoint, the scriptSig, and the sequence. The actual amount is not factored into the preimage.
The layers of the issue can be described as follows:
- A permissionless access point (
sweep→buildSweepPayload) that accepts an externally suppliedutxo.valuewithout verifying it against the actual Bitcoin UTXO data. - A legacy
SIGHASH_ALLpreimage that omits crucial input amounts, as per established specifications. - A generic ECDSA oracle (
ChainSignatures) that signs any provided double-SHA-256 hash, lacking any knowledge of Bitcoin’s context.
If the communication was only one-way, with trusted relayers calling the shots, the risk would be minimized. However, sweep() is designed to be permissionless by intent, as highlighted in the NatSpec comments:
// 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...
}
Discussion
Sign in to join the discussion.