|
20 | 20 |
|
21 | 21 |
|
22 | 22 | THOR_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" # ETH THORChain router |
| 23 | +THOR_ROUTER_AVAX = "00dc6100103bc402d490aee3f9a5560cbd91f1d4" # Avalanche C-Chain router |
23 | 24 | ETH_NATIVE = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # sentinel for native ETH |
24 | 25 |
|
25 | 26 |
|
@@ -137,6 +138,75 @@ def test_deposit_with_expiry_non_thor_address_blind_sign_blocked(self): |
137 | 138 | data=data, |
138 | 139 | ) |
139 | 140 |
|
| 141 | + def test_deposit_with_expiry_avalanche_router(self): |
| 142 | + """A THORChain deposit on Avalanche clear-signs — the router pin is |
| 143 | + (chain_id, address), not Ethereum-mainnet-only. |
| 144 | +
|
| 145 | + Before the per-chain pin, thor_isThorchainTx only ever matched the |
| 146 | + mainnet router, so an AVAX->ETH swap fell into the AdvancedMode |
| 147 | + blind-sign gate and the device returned a bare ActionCancelled. The |
| 148 | + signature is ECDSA-recovered against the host-built EIP-155 pre-image, |
| 149 | + so a wrong digest, chain id, or key fails — not just a shape check. |
| 150 | + The native amount screen shows msg.value with the CHAIN's ticker |
| 151 | + (AVAX), never the mainnet pseudo-token's ETH label. |
| 152 | + """ |
| 153 | + self.requires_fullFeature() |
| 154 | + self.requires_firmware("7.15.0") |
| 155 | + self.setup_mnemonic_allallall() |
| 156 | + |
| 157 | + from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 |
| 158 | + |
| 159 | + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" |
| 160 | + data = _build_deposit_with_expiry_calldata(memo) |
| 161 | + |
| 162 | + n = parse_path("m/44'/60'/0'/0/0") |
| 163 | + nonce, gas_price, gas_limit = 4, 50000000000, 300000 |
| 164 | + to = binascii.unhexlify(THOR_ROUTER_AVAX) |
| 165 | + value = 500000000000000000 # 0.5 AVAX (native = msg.value) |
| 166 | + chain_id = 43114 |
| 167 | + |
| 168 | + # AdvancedMode intentionally OFF — the deposit must clear-sign. |
| 169 | + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( |
| 170 | + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, |
| 171 | + to=to, value=value, chain_id=chain_id, data=data, |
| 172 | + ) |
| 173 | + self.assertIn(sig_v, [2 * chain_id + 35, 2 * chain_id + 36]) |
| 174 | + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, |
| 175 | + data, chain_id) |
| 176 | + from ecdsa import VerifyingKey, SECP256k1, util |
| 177 | + rec = sig_v - (35 + 2 * chain_id) |
| 178 | + keys = VerifyingKey.from_public_key_recovery_with_digest( |
| 179 | + sig_r + sig_s, digest, SECP256k1, hashfunc=None, |
| 180 | + sigdecode=util.sigdecode_string, |
| 181 | + ) |
| 182 | + signer = keccak256(keys[rec].to_string())[-20:] |
| 183 | + self.assertEqual(signer, self.client.ethereum_get_address(n)) |
| 184 | + |
| 185 | + def test_deposit_unpinned_chain_blind_sign_blocked(self): |
| 186 | + """A deposit-shaped tx on a chain with NO pinned router must fall to |
| 187 | + the blind-sign gate — a router address borrowed onto an unpinned chain |
| 188 | + (where it may hold attacker code) cannot inherit the deposit UX.""" |
| 189 | + self.requires_fullFeature() |
| 190 | + self.requires_firmware("7.15.0") |
| 191 | + self.setup_mnemonic_allallall() |
| 192 | + |
| 193 | + from keepkeylib.client import CallException |
| 194 | + |
| 195 | + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" |
| 196 | + data = _build_deposit_with_expiry_calldata(memo) |
| 197 | + |
| 198 | + with self.assertRaises((CallException, Exception)): |
| 199 | + self.client.ethereum_sign_tx( |
| 200 | + n=parse_path("m/44'/60'/0'/0/0"), |
| 201 | + nonce=5, |
| 202 | + gas_price=50000000000, |
| 203 | + gas_limit=300000, |
| 204 | + to=binascii.unhexlify(THOR_ROUTER), # real mainnet router addr |
| 205 | + value=0, |
| 206 | + chain_id=56, # BSC: no pinned router |
| 207 | + data=data, |
| 208 | + ) |
| 209 | + |
140 | 210 |
|
141 | 211 | if __name__ == "__main__": |
142 | 212 | unittest.main() |
0 commit comments