Skip to content

Commit f0f8a56

Browse files
xeno097claude
andauthored
fix(sdk): stop reporting live Tron EOAs as inactive owners (hyperlane-xyz#9312)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c43b3c6 commit f0f8a56

6 files changed

Lines changed: 457 additions & 28 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperlane-xyz/tron-sdk': minor
3+
'@hyperlane-xyz/sdk': patch
4+
---
5+
6+
Tron externally-owned accounts are no longer reported as inactive owners. `TronJsonRpcProvider` gained `isAccountActive`, which reads on-chain activation from the native `wallet/getaccount` endpoint instead of leaving liveness to be inferred from `getTransactionCount` (Tron has no nonces, so that method is hardcoded to 0 and made every Tron EOA look dead). `isAddressActive` now consults that method when the provider offers it and otherwise keeps its existing code-or-nonce behaviour, so `warp check` stops emitting a permanent `ownerStatus` violation for live Tron EOA owners. A failure reaching the Tron node is thrown rather than reported as inactive.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { expect } from 'chai';
2+
3+
import { Address } from '@hyperlane-xyz/utils';
4+
5+
import type { EthersLikeProvider } from '../deploy/proxy.js';
6+
7+
import { isAddressActive } from './contracts.js';
8+
9+
const ADDRESS: Address = '0xa7eccdb9be08178f896c26b7bbd8c3d4e844d9ba';
10+
const CONTRACT_CODE = '0x60806040';
11+
12+
interface NonceProviderCalls {
13+
getTransactionCount: number;
14+
}
15+
16+
interface ActivationProviderCalls {
17+
isAccountActive: number;
18+
}
19+
20+
/** Provider double for chains whose liveness is inferred from the nonce. */
21+
function nonceProvider(
22+
code: string,
23+
txnCount: number,
24+
): { provider: EthersLikeProvider; calls: NonceProviderCalls } {
25+
const calls: NonceProviderCalls = { getTransactionCount: 0 };
26+
const double = {
27+
getCode: async () => code,
28+
getTransactionCount: async () => {
29+
calls.getTransactionCount += 1;
30+
return txnCount;
31+
},
32+
};
33+
// CAST: explicit test double exercising only the two methods isAddressActive calls.
34+
return { provider: double as unknown as EthersLikeProvider, calls };
35+
}
36+
37+
/** Provider double for chains that answer activation directly (Tron). */
38+
function activationProvider(
39+
code: string,
40+
activation: boolean | (() => Promise<boolean>),
41+
): { provider: EthersLikeProvider; calls: ActivationProviderCalls } {
42+
const calls: ActivationProviderCalls = { isAccountActive: 0 };
43+
const double = {
44+
getCode: async () => code,
45+
getTransactionCount: async () => {
46+
throw new Error('getTransactionCount must not be consulted');
47+
},
48+
isAccountActive: async () => {
49+
calls.isAccountActive += 1;
50+
return typeof activation === 'boolean' ? activation : activation();
51+
},
52+
};
53+
// CAST: explicit test double exercising only the methods isAddressActive calls.
54+
return { provider: double as unknown as EthersLikeProvider, calls };
55+
}
56+
57+
describe('isAddressActive', () => {
58+
describe('providers without an activation check', () => {
59+
it('returns false for an EOA with no code and a zero nonce', async () => {
60+
const { provider } = nonceProvider('0x', 0);
61+
62+
expect(await isAddressActive(provider, ADDRESS)).to.be.false;
63+
});
64+
65+
it('returns true for an EOA with a non-zero nonce', async () => {
66+
const { provider } = nonceProvider('0x', 1);
67+
68+
expect(await isAddressActive(provider, ADDRESS)).to.be.true;
69+
});
70+
71+
it('returns true for a contract regardless of nonce', async () => {
72+
const { provider } = nonceProvider(CONTRACT_CODE, 0);
73+
74+
expect(await isAddressActive(provider, ADDRESS)).to.be.true;
75+
});
76+
});
77+
78+
describe('providers with an activation check', () => {
79+
it('returns true for an activated EOA with no code and no nonce', async () => {
80+
const { provider, calls } = activationProvider('0x', true);
81+
82+
expect(await isAddressActive(provider, ADDRESS)).to.be.true;
83+
expect(calls.isAccountActive).to.equal(1);
84+
});
85+
86+
it('returns false for an unactivated EOA', async () => {
87+
const { provider, calls } = activationProvider('0x', false);
88+
89+
expect(await isAddressActive(provider, ADDRESS)).to.be.false;
90+
expect(calls.isAccountActive).to.equal(1);
91+
});
92+
93+
it('returns true for a contract without consulting the activation check', async () => {
94+
const { provider, calls } = activationProvider(CONTRACT_CODE, false);
95+
96+
expect(await isAddressActive(provider, ADDRESS)).to.be.true;
97+
expect(calls.isAccountActive).to.equal(0);
98+
});
99+
100+
it('returns true for a contract even when the activation check would fail', async () => {
101+
const { provider, calls } = activationProvider(
102+
CONTRACT_CODE,
103+
async () => {
104+
throw new Error('the method wallet/getaccount is not available');
105+
},
106+
);
107+
108+
expect(await isAddressActive(provider, ADDRESS)).to.be.true;
109+
expect(calls.isAccountActive).to.equal(0);
110+
});
111+
112+
it('propagates an activation-check failure instead of reporting inactive', async () => {
113+
const failure = new Error('bad response (status=503)');
114+
const { provider } = activationProvider('0x', async () => {
115+
throw failure;
116+
});
117+
118+
let thrown: unknown;
119+
try {
120+
await isAddressActive(provider, ADDRESS);
121+
} catch (error: unknown) {
122+
thrown = error;
123+
}
124+
125+
expect(thrown).to.equal(failure);
126+
});
127+
});
128+
});

typescript/sdk/src/contracts/contracts.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { constants } from 'ethers';
22

33
import { Ownable, Ownable__factory } from '@hyperlane-xyz/core';
4+
import type { TronJsonRpcProvider } from '@hyperlane-xyz/tron-sdk/runtime';
45
import {
56
Address,
67
EvmChainId,
@@ -346,10 +347,43 @@ export function transferOwnershipTransactions(
346347
];
347348
}
348349

350+
/**
351+
* Providers for chains whose account model has no nonce (Tron) answer liveness
352+
* directly instead of leaving it to be inferred from `getTransactionCount`.
353+
*
354+
* The shape is derived from the Tron provider rather than declared
355+
* independently so that renaming or removing `isAccountActive` in
356+
* @hyperlane-xyz/tron-sdk breaks this build, instead of silently reverting the
357+
* structural check below to the always-zero nonce path.
358+
*/
359+
type AccountActivationProvider = Pick<TronJsonRpcProvider, 'isAccountActive'>;
360+
361+
function canCheckActivation(
362+
provider: EthersLikeProvider,
363+
): provider is EthersLikeProvider & AccountActivationProvider {
364+
return (
365+
'isAccountActive' in provider &&
366+
typeof provider.isAccountActive === 'function'
367+
);
368+
}
369+
349370
export async function isAddressActive(
350371
provider: EthersLikeProvider,
351372
address: Address,
352373
): Promise<boolean> {
374+
if (canCheckActivation(provider)) {
375+
// Deliberately sequential: non-empty code is by itself conclusive, so a
376+
// contract must not be put at the mercy of an activation endpoint it never
377+
// needed. Issuing both in parallel would propagate an activation failure
378+
// for an address we had already proven active.
379+
const code = await provider.getCode(address);
380+
if (code !== '0x') {
381+
return true;
382+
}
383+
384+
return provider.isAccountActive(address);
385+
}
386+
353387
const [code, txnCount] = await Promise.all([
354388
provider.getCode(address),
355389
provider.getTransactionCount(address),
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { expect } from 'chai';
2+
import { TronWeb } from 'tronweb';
3+
4+
import { assert, ensure0x, strip0x } from '@hyperlane-xyz/utils';
5+
6+
import {
7+
TronNodeInfo,
8+
TronTestChainMetadata,
9+
runTronNode,
10+
stopTronNode,
11+
} from '../testing/node.js';
12+
13+
import { TronJsonRpcProvider } from './TronJsonRpcProvider.js';
14+
15+
const TEST_CHAIN: TronTestChainMetadata = {
16+
name: 'tron-test-account',
17+
chainId: 3360022319,
18+
domainId: 3360022319,
19+
port: 19091,
20+
};
21+
22+
// Never funded by TRE, so the node has no account record for it.
23+
const UNACTIVATED_ADDRESS = '0xa7eccdb9be08178f896c26b7bbd8c3d4e844d9ba';
24+
25+
describe('TronJsonRpcProvider Integration Tests', function () {
26+
this.timeout(120_000);
27+
28+
let node: TronNodeInfo;
29+
let provider: TronJsonRpcProvider;
30+
let fundedAddress: string;
31+
32+
before(async () => {
33+
node = await runTronNode(TEST_CHAIN);
34+
35+
const host = `http://127.0.0.1:${TEST_CHAIN.port}`;
36+
provider = new TronJsonRpcProvider(`${host}/jsonrpc`, TEST_CHAIN.chainId);
37+
38+
const tronWeb = new TronWeb({ fullHost: host });
39+
const base58 = tronWeb.address.fromPrivateKey(node.privateKeys[0]);
40+
assert(base58, 'TRE did not expose a funded account');
41+
// TronWeb hex is 41-prefixed; the provider is fed the 0x form production uses.
42+
fundedAddress = ensure0x(strip0x(tronWeb.address.toHex(base58)).slice(2));
43+
});
44+
45+
after(async () => {
46+
await stopTronNode(node);
47+
});
48+
49+
describe('isAccountActive', () => {
50+
it('returns true for an activated (funded) account', async () => {
51+
expect(await provider.isAccountActive(fundedAddress)).to.be.true;
52+
});
53+
54+
it('returns false for an address that was never activated', async () => {
55+
expect(await provider.isAccountActive(UNACTIVATED_ADDRESS)).to.be.false;
56+
});
57+
58+
it('still reports a zero nonce for the activated account', async () => {
59+
expect(await provider.getTransactionCount(fundedAddress)).to.equal(0);
60+
});
61+
});
62+
});

0 commit comments

Comments
 (0)