Skip to content

Commit 1e6194b

Browse files
authored
Merge pull request #138 from hl-archive-node/feat/system-tx-sender-from-field
feat: use upstream `from` for system-tx sender
2 parents dad836e + e9d2c18 commit 1e6194b

5 files changed

Lines changed: 168 additions & 34 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ Ensure careful handling when indexing.
1313

1414
To disable this behavior, add `--hl-node-compliant` to the CLI arguments-this will not show system transactions and their receipts, mimicking hl-node's output.
1515

16+
### System transaction sender encoding
17+
18+
System transactions arrive from hl-node unsigned; nanoreth fabricates a signature that encodes the real `msg.sender` into its `s` value. Sender recovery decodes it back:
19+
20+
| `s` | recovered `msg.sender` |
21+
| --- | --- |
22+
| `1` | `0x2222...2222` (the HYPE system address) |
23+
| `0x10000000000000000000000000000000000000001` | `0x00...01` |
24+
| anything else | the low 20 bytes of `s` (e.g. spot-token `0x2000...00` senders) |
25+
26+
The middle row is an escape: a real sender of `0x00...01` would otherwise encode to `s == 1` and collide with the HYPE sentinel, so it is lifted to `(1 << 160) | 1` (which still decodes back via the low 20 bytes).
27+
28+
The RPC returns this recovered address as each transaction's `from`; together with the zero `gasPrice` and the table above, that lets you recognize system transactions. To confirm them authoritatively, use hl-node's [`eth_getSystemTxsByBlockHash` / `eth_getSystemTxsByBlockNumber`](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm/json-rpc) methods, which return the system transactions originating from HyperCore, and source blocks only from a node you trust.
29+
1630
### Per-request compliance multiplexing
1731

1832
If you need a single endpoint to serve both filtered and unfiltered responses, use `--hl-node-compliant-multiplexed`. This adds a middleware layer that reads the `?hl=` query parameter from each request:

src/node/primitives/transaction.rs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use alloy_consensus::{
1010
};
1111
use alloy_eips::Encodable2718;
1212
use alloy_network::TxSigner;
13-
use alloy_primitives::{Address, TxHash, U256, address};
13+
use alloy_primitives::{Address, TxHash, U256, address, uint};
1414
use alloy_rpc_types::{Transaction, TransactionInfo, TransactionRequest};
1515
use alloy_signer::Signature;
1616
use reth_codecs::alloy::transaction::{Envelope, FromTxCompact};
@@ -39,13 +39,37 @@ pub enum TransactionSigned {
3939
Default(InnerType),
4040
}
4141

42+
/// The HYPE system address (sender of native HYPE transfer system txs), encoded as `s == 1`.
43+
/// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm/hypercore-less-than-greater-than-hyperevm-transfers>
44+
const HYPE_SYSTEM_ADDRESS: Address = address!("2222222222222222222222222222222222222222");
45+
46+
/// 0x00..01: its natural encoding `s == 1` would collide with [`HYPE_SYSTEM_ADDRESS`], so it is
47+
/// escaped to [`ADDRESS_ONE_S`].
48+
const ADDRESS_ONE: Address = address!("0000000000000000000000000000000000000001");
49+
50+
/// The escaped `s` for [`ADDRESS_ONE`]: `(1 << 160) | 1`, one bit above the 20-byte address space.
51+
const ADDRESS_ONE_S: U256 = uint!(0x10000000000000000000000000000000000000001_U256);
52+
53+
/// Decode a system tx's synthetic signature `s` into `msg.sender`; inverse of [`address_to_s`].
4254
fn s_to_address(s: U256) -> Address {
4355
if s == U256::ONE {
44-
return address!("2222222222222222222222222222222222222222");
56+
return HYPE_SYSTEM_ADDRESS;
57+
}
58+
if s == ADDRESS_ONE_S {
59+
return ADDRESS_ONE;
60+
}
61+
Address::from_slice(&s.to_be_bytes::<32>()[12..])
62+
}
63+
64+
/// Encode `msg.sender` into a system tx's synthetic signature `s`; inverse of [`s_to_address`].
65+
pub(crate) fn address_to_s(addr: Address) -> U256 {
66+
if addr == HYPE_SYSTEM_ADDRESS {
67+
return U256::ONE;
68+
}
69+
if addr == ADDRESS_ONE {
70+
return ADDRESS_ONE_S;
4571
}
46-
let mut buf = [0u8; 20];
47-
buf[0..20].copy_from_slice(&s.to_be_bytes::<32>()[12..32]);
48-
Address::from_slice(&buf)
72+
U256::from_be_slice(addr.as_slice())
4973
}
5074

5175
impl TxHashRef for TransactionSigned {

src/node/types/mod.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use alloy_primitives::{Address, B256, Bytes, Log};
77
use alloy_rlp::{Decodable, Encodable, RlpDecodable, RlpEncodable};
88
use bytes::BufMut;
99
use reth_ethereum_primitives::EthereumReceipt;
10-
use reth_primitives_traits::InMemorySize;
10+
use reth_primitives_traits::{InMemorySize, SignerRecoverable};
1111
use serde::{Deserialize, Serialize};
1212

1313
use crate::HlBlock;
@@ -118,9 +118,13 @@ impl BlockAndReceipts {
118118
let system_txs: Vec<SystemTx> = system_tx_list
119119
.into_iter()
120120
.zip(system_receipts)
121-
.map(|(tx, receipt)| SystemTx {
122-
tx: reth_compat::TransactionSigned::extract_transaction(tx),
123-
receipt: Some(receipt.into()),
121+
.map(|(tx, receipt)| {
122+
let from = tx.recover_signer().ok();
123+
SystemTx {
124+
tx: reth_compat::TransactionSigned::extract_transaction(tx),
125+
receipt: Some(receipt.into()),
126+
from,
127+
}
124128
})
125129
.collect();
126130

@@ -226,6 +230,8 @@ enum LegacyTxType {
226230
pub struct SystemTx {
227231
pub tx: reth_compat::Transaction,
228232
pub receipt: Option<LegacyReceipt>,
233+
#[serde(default)]
234+
pub from: Option<Address>,
229235
}
230236

231237
impl SystemTx {

src/node/types/patch.rs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
use super::LegacyReceipt;
33
use crate::chainspec::TESTNET_CHAIN_ID;
44
use alloy_primitives::{Address, B256, U256, address, b256};
5+
use std::ops::Range;
56

6-
/// `keccak256("Transfer(address,address,uint256)")` the ERC-20 `Transfer` topic.
7+
/// `keccak256("Transfer(address,address,uint256)")` - the ERC-20 `Transfer` topic.
78
const ERC20_TRANSFER_TOPIC: B256 =
89
b256!("ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef");
910

@@ -14,12 +15,14 @@ const ERC20_TRANSFER_TOPIC: B256 =
1415
/// once the true `msg.sender` is recovered from the receipt.
1516
const SENDER_RECOVERY_TOKEN: Address = address!("0x2b3370ee501b4a559b57d449569354196457d8ab");
1617

17-
/// First testnet block at which the recovery applies.
18-
const SENDER_RECOVERY_FROM_BLOCK: u64 = 55231857;
18+
/// Half-open testnet block range in which the recovery applies: from the first affected block
19+
/// until upstream starts emitting `from`, which supersedes this log recovery (the next system tx,
20+
/// at `55432087`, carries it; none occur in between).
21+
const SENDER_RECOVERY_BLOCKS: Range<u64> = 55231857..55432000;
1922

2023
/// Recover the real `msg.sender` of a token system transaction from its receipt,
21-
/// restricted to the minimal known-affected set (testnet, one token, at or after
22-
/// the first affected block). Returns `None` everywhere else so the caller falls
24+
/// restricted to the minimal known-affected set (testnet, one token,
25+
/// [`SENDER_RECOVERY_BLOCKS`]). Returns `None` everywhere else so the caller falls
2326
/// back to the legacy synthetic-sender (`to_s`) encoding.
2427
///
2528
/// For an ERC-20 transfer the caller is observable as the `from` topic of the
@@ -31,7 +34,7 @@ pub(super) fn recover_testnet_system_tx_sender(
3134
receipt: Option<&LegacyReceipt>,
3235
) -> Option<Address> {
3336
if chain_id != TESTNET_CHAIN_ID
34-
|| block_number < SENDER_RECOVERY_FROM_BLOCK
37+
|| !SENDER_RECOVERY_BLOCKS.contains(&block_number)
3538
|| token != SENDER_RECOVERY_TOKEN
3639
{
3740
return None;
@@ -85,7 +88,7 @@ mod tests {
8588
fn recovers_sender_for_targeted_token() {
8689
let logs = vec![transfer_log(SENDER_RECOVERY_TOKEN, HOLDER_B, HOLDER_A)];
8790
assert_eq!(
88-
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_FROM_BLOCK, SENDER_RECOVERY_TOKEN, logs),
91+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.start, SENDER_RECOVERY_TOKEN, logs),
8992
Some(HOLDER_B)
9093
);
9194
}
@@ -94,23 +97,32 @@ mod tests {
9497
fn declines_outside_the_minimal_set() {
9598
let log = || vec![transfer_log(SENDER_RECOVERY_TOKEN, HOLDER_B, HOLDER_A)];
9699
// wrong chain
97-
assert_eq!(recover(999, SENDER_RECOVERY_FROM_BLOCK, SENDER_RECOVERY_TOKEN, log()), None);
100+
assert_eq!(recover(999, SENDER_RECOVERY_BLOCKS.start, SENDER_RECOVERY_TOKEN, log()), None);
98101
// before the first affected block
99102
assert_eq!(
100-
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_FROM_BLOCK - 1, SENDER_RECOVERY_TOKEN, log()),
103+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.start - 1, SENDER_RECOVERY_TOKEN, log()),
101104
None
102105
);
106+
// at/after the upper bound, `from` supersedes log recovery
107+
assert_eq!(
108+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.end, SENDER_RECOVERY_TOKEN, log()),
109+
None
110+
);
111+
assert_eq!(
112+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.end - 1, SENDER_RECOVERY_TOKEN, log()),
113+
Some(HOLDER_B)
114+
);
103115
// a different (non-targeted) token, even though it emits a Transfer
104116
let other = vec![transfer_log(OTHER_TOKEN, HOLDER_B, HOLDER_A)];
105-
assert_eq!(recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_FROM_BLOCK, OTHER_TOKEN, other), None);
117+
assert_eq!(recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.start, OTHER_TOKEN, other), None);
106118
}
107119

108120
#[test]
109121
fn ignores_unrelated_logs() {
110122
// Transfer emitted by some other contract (not the token `to`).
111123
let foreign = vec![transfer_log(OTHER_TOKEN, HOLDER_B, HOLDER_A)];
112124
assert_eq!(
113-
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_FROM_BLOCK, SENDER_RECOVERY_TOKEN, foreign),
125+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.start, SENDER_RECOVERY_TOKEN, foreign),
114126
None
115127
);
116128
// Non-Transfer event (wrong topic0) from the token.
@@ -122,7 +134,7 @@ mod tests {
122134
),
123135
};
124136
assert_eq!(
125-
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_FROM_BLOCK, SENDER_RECOVERY_TOKEN, vec![
137+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.start, SENDER_RECOVERY_TOKEN, vec![
126138
approval
127139
]),
128140
None
@@ -134,7 +146,7 @@ mod tests {
134146
let one = address!("0000000000000000000000000000000000000001");
135147
let logs = vec![transfer_log(SENDER_RECOVERY_TOKEN, one, HOLDER_A)];
136148
assert_eq!(
137-
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_FROM_BLOCK, SENDER_RECOVERY_TOKEN, logs),
149+
recover(TESTNET_CHAIN_ID, SENDER_RECOVERY_BLOCKS.start, SENDER_RECOVERY_TOKEN, logs),
138150
None
139151
);
140152
}

src/node/types/reth_compat.rs

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use super::patch::recover_testnet_system_tx_sender;
1616
use crate::{
1717
HlBlock, HlBlockBody, HlHeader,
1818
node::{
19-
primitives::TransactionSigned as TxSigned,
19+
primitives::{TransactionSigned as TxSigned, transaction::address_to_s},
2020
spot_meta::{SpotId, erc20_contract_to_spot_token},
2121
types::{LegacyReceipt, ReadPrecompileCalls, SystemTx},
2222
},
@@ -191,18 +191,18 @@ fn system_tx_to_reth_transaction(
191191
let TxKind::Call(to) = tx.to else {
192192
panic!("Unexpected contract creation");
193193
};
194-
let s = if let Some(sender) = recover_testnet_system_tx_sender(
195-
chain_id,
196-
block_number,
197-
to,
198-
transaction.receipt.as_ref(),
199-
) {
200-
// Encode the real `msg.sender` (recovered from the ERC-20 `Transfer` log) into
201-
// `s`, so that `s_to_address(s)` recovers the actual holder. This lets each
202-
// sender's nonces validate normally, removing the need to disable nonce checks
203-
// for these system transactions.
204-
U256::from_be_slice(sender.as_slice())
194+
// System txs arrive unsigned; nanoreth fabricates the signature below, encoding `msg.sender`
195+
// into `s` (via `address_to_s`) so `s_to_address(s)` recovers the holder and nonces validate.
196+
let s = if let Some(sender) = transaction.from {
197+
// Prefer the authoritative upstream `from`.
198+
address_to_s(sender)
199+
} else if let Some(sender) =
200+
recover_testnet_system_tx_sender(chain_id, block_number, to, transaction.receipt.as_ref())
201+
{
202+
// Fall back to `super::patch` testnet log recovery.
203+
address_to_s(sender)
205204
} else if tx.input.is_empty() {
205+
// Native HYPE transfer: sender is the HYPE system address (0x2222…2222), encoded `s == 1`.
206206
U256::from(0x1)
207207
} else {
208208
loop {
@@ -326,6 +326,8 @@ mod tests {
326326
input: Bytes::from_static(&[0xa9, 0x05, 0x9c, 0xbb]),
327327
}),
328328
receipt: Some(receipt),
329+
// `from` path set explicitly per-test.
330+
from: None,
329331
}
330332
}
331333

@@ -342,4 +344,80 @@ mod tests {
342344
let signed2 = system_tx_to_reth_transaction(&tx2, TESTNET_CHAIN_ID, FROM_BLOCK);
343345
assert_eq!(signed2.recover_signer().unwrap(), HOLDER_A);
344346
}
347+
348+
#[test]
349+
fn from_field_takes_precedence_over_log() {
350+
// On disagreement, upstream `from` wins over the `Transfer` log.
351+
let mut tx = system_tx(HOLDER_A, TOKEN, 3);
352+
tx.from = Some(HOLDER_B);
353+
let signed = system_tx_to_reth_transaction(&tx, TESTNET_CHAIN_ID, FROM_BLOCK);
354+
assert_eq!(signed.recover_signer().unwrap(), HOLDER_B);
355+
}
356+
357+
#[test]
358+
fn from_db_round_trip_preserves_real_holder() {
359+
// A `from`-bearing system tx past the log-recovery window survives the sync round trip
360+
// (to_reth_block -> from_db -> to_reth_block) recovering the real holder, not a collision.
361+
let mut tx = system_tx(HOLDER_A, TOKEN, 1);
362+
tx.from = Some(HOLDER_B);
363+
assert_eq!(round_trip_signer(tx), HOLDER_B);
364+
}
365+
366+
#[test]
367+
fn from_db_round_trip_preserves_hype_sender() {
368+
// A native HYPE transfer (empty input, sender = HYPE system address -> `s == 1`) recovers
369+
// to 0x2222…2222 and re-encodes back to `s == 1` across the round trip - no drift.
370+
let receipt: LegacyReceipt = EthereumReceipt {
371+
tx_type: TxType::Legacy,
372+
success: true,
373+
cumulative_gas_used: 0,
374+
logs: vec![],
375+
}
376+
.into();
377+
let tx = SystemTx {
378+
tx: Transaction::Legacy(TxLegacy {
379+
chain_id: Some(TESTNET_CHAIN_ID),
380+
nonce: 0,
381+
gas_price: 0,
382+
gas_limit: 200_000,
383+
to: TxKind::Call(TOKEN),
384+
value: U256::ZERO,
385+
input: Bytes::new(),
386+
}),
387+
receipt: Some(receipt),
388+
from: None,
389+
};
390+
assert_eq!(round_trip_signer(tx), address!("2222222222222222222222222222222222222222"));
391+
}
392+
393+
#[test]
394+
fn degenerate_from_one_is_escaped_and_round_trips() {
395+
// `from == 0x00..01` would collide with the `s == 1` sentinel; the escape lifts it above
396+
// the address space so it recovers to 0x00..01 (not the 0x2222 sentinel) and round-trips.
397+
let one = address!("0000000000000000000000000000000000000001");
398+
let mut tx = system_tx(HOLDER_A, TOKEN, 1);
399+
tx.from = Some(one);
400+
let signed = system_tx_to_reth_transaction(&tx, TESTNET_CHAIN_ID, FROM_BLOCK);
401+
assert_eq!(signed.recover_signer().unwrap(), one);
402+
assert_eq!(round_trip_signer(tx), one);
403+
}
404+
405+
/// Full sync round trip (to_reth_block -> from_db -> to_reth_block); returns the re-served
406+
/// system tx's recovered signer, which must equal the original's.
407+
fn round_trip_signer(tx: SystemTx) -> Address {
408+
let sealed = SealedBlock {
409+
header: SealedHeader {
410+
hash: Default::default(),
411+
header: Header { number: 56_000_000, ..Default::default() },
412+
},
413+
body: BlockBody { transactions: vec![], ommers: vec![], withdrawals: None },
414+
};
415+
let receipts = vec![tx.receipt.clone().unwrap().into()];
416+
let block =
417+
sealed.to_reth_block(Default::default(), None, vec![tx], vec![], TESTNET_CHAIN_ID);
418+
let restored = crate::node::types::BlockAndReceipts::from_db(block, receipts);
419+
assert_eq!(restored.system_txs.len(), 1);
420+
let reserved = restored.to_reth_block(TESTNET_CHAIN_ID);
421+
reserved.body.inner.transactions[0].recover_signer().unwrap()
422+
}
345423
}

0 commit comments

Comments
 (0)