Skip to content

Commit 8a6f742

Browse files
authored
fix(sdk): cache signers and simulate ISM deploy address (#8292)
1 parent f2620a1 commit 8a6f742

6 files changed

Lines changed: 205 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperlane-xyz/sdk': patch
3+
---
4+
5+
MultiProvider was updated to cache connected signers for stable instance identity and route setProviders() through setProvider() for consistent signer reconnection. ISM factory now simulates deploy address via eth_call when getAddress() returns incorrect results. Defensive null assertions were added across MultiProvider methods. HyperlaneCore onDispatch errors are now caught and logged separately.

typescript/sdk/src/core/HyperlaneCore.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -207,16 +207,31 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
207207
mailbox.on<DispatchEvent>(
208208
mailbox.filters.Dispatch(),
209209
(_sender, _destination, _recipient, message, event) => {
210-
const dispatched = HyperlaneCore.parseDispatchedMessage(message);
211-
212-
// add human readable chain names
213-
dispatched.parsed.originChain = this.getOrigin(dispatched);
214-
dispatched.parsed.destinationChain = this.getDestination(dispatched);
210+
let dispatched: DispatchedMessage;
211+
try {
212+
dispatched = HyperlaneCore.parseDispatchedMessage(message);
213+
214+
// add human readable chain names
215+
dispatched.parsed.originChain = this.getOrigin(dispatched);
216+
dispatched.parsed.destinationChain =
217+
this.getDestination(dispatched);
218+
} catch (err: unknown) {
219+
this.logger.error(
220+
`Failed to parse dispatched message on ${originChain}`,
221+
err instanceof Error ? err.message : String(err),
222+
);
223+
return;
224+
}
215225

216226
this.logger.info(
217227
`Observed message ${dispatched.id} on ${originChain} to ${dispatched.parsed.destinationChain}`,
218228
);
219-
return handler(dispatched, event);
229+
return handler(dispatched, event).catch((err: unknown) => {
230+
this.logger.error(
231+
`Error in dispatch handler on ${originChain}`,
232+
err instanceof Error ? err.message : String(err),
233+
);
234+
});
220235
},
221236
);
222237
});
@@ -466,7 +481,10 @@ export class HyperlaneCore extends HyperlaneApp<CoreFactories> {
466481
this.multiProvider.tryGetChainName(parsed.origin) ?? undefined;
467482
const destinationChain =
468483
this.multiProvider.tryGetChainName(parsed.destination) ?? undefined;
469-
return { parsed: { ...parsed, originChain, destinationChain }, ...other };
484+
return {
485+
parsed: { ...parsed, originChain, destinationChain },
486+
...other,
487+
};
470488
});
471489
}
472490

typescript/sdk/src/ism/HyperlaneIsmFactory.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ export class HyperlaneIsmFactory extends HyperlaneApp<ProxyFactoryFactories> {
630630
receipt = await this.multiProvider.handleTx(destination, tx);
631631

632632
// TODO: Break this out into a generalized function
633-
const dispatchLogs = receipt.logs
633+
const dispatchLogs = (receipt.logs ?? [])
634634
.map((log) => {
635635
try {
636636
return domainRoutingIsmFactory.interface.parseLog(log);
@@ -714,10 +714,22 @@ export class HyperlaneIsmFactory extends HyperlaneApp<ProxyFactoryFactories> {
714714
): Promise<Address> {
715715
const sorted = [...values].sort();
716716

717-
const address = await factory['getAddress(address[],uint8)'](
717+
const getAddressResult = await factory['getAddress(address[],uint8)'](
718718
sorted,
719719
threshold,
720720
);
721+
const address =
722+
(await this.previewFactoryDeployAddress(
723+
chain,
724+
factory,
725+
'deploy(address[],uint8)',
726+
[sorted, threshold],
727+
)) ?? getAddressResult;
728+
if (!eqAddress(address, getAddressResult)) {
729+
logger.debug(
730+
`Factory getAddress mismatch on ${chain}, using deploy simulation address ${address}`,
731+
);
732+
}
721733
const code = await this.multiProvider.getProvider(chain).getCode(address);
722734
if (code === '0x') {
723735
logger.debug(
@@ -756,10 +768,21 @@ export class HyperlaneIsmFactory extends HyperlaneApp<ProxyFactoryFactories> {
756768
): Promise<Address> {
757769
const sorted = [...values].sort();
758770

759-
const address = await factory['getAddress((address,uint96)[],uint96)'](
760-
sorted,
761-
thresholdWeight,
762-
);
771+
const getAddressResult = await factory[
772+
'getAddress((address,uint96)[],uint96)'
773+
](sorted, thresholdWeight);
774+
const address =
775+
(await this.previewFactoryDeployAddress(
776+
chain,
777+
factory,
778+
'deploy((address,uint96)[],uint96)',
779+
[sorted, thresholdWeight],
780+
)) ?? getAddressResult;
781+
if (!eqAddress(address, getAddressResult)) {
782+
logger.debug(
783+
`Weighted factory getAddress mismatch on ${chain}, using deploy simulation address ${address}`,
784+
);
785+
}
763786
const code = await this.multiProvider.getProvider(chain).getCode(address);
764787
if (code === '0x') {
765788
logger.debug(
@@ -790,4 +813,41 @@ export class HyperlaneIsmFactory extends HyperlaneApp<ProxyFactoryFactories> {
790813
}
791814
return address;
792815
}
816+
817+
private async previewFactoryDeployAddress(
818+
chain: ChainName,
819+
factory: {
820+
address: string;
821+
interface: {
822+
encodeFunctionData(
823+
functionName: string,
824+
args?: readonly unknown[],
825+
): string;
826+
decodeFunctionResult(functionName: string, data: string): unknown;
827+
};
828+
},
829+
signature: string,
830+
args: readonly unknown[],
831+
): Promise<Address | undefined> {
832+
try {
833+
const data = factory.interface.encodeFunctionData(signature, args);
834+
const result = await this.multiProvider.getProvider(chain).call({
835+
to: factory.address,
836+
data,
837+
});
838+
const decoded = factory.interface.decodeFunctionResult(signature, result);
839+
if (Array.isArray(decoded) && typeof decoded[0] === 'string') {
840+
return decoded[0];
841+
}
842+
if (typeof decoded === 'string') {
843+
return decoded;
844+
}
845+
return undefined;
846+
} catch (e: unknown) {
847+
this.logger.warn(
848+
`Failed to preview factory deploy address on ${chain} (factory=${factory.address}, fn=${signature}): ${e instanceof Error ? e.message : String(e)}`,
849+
);
850+
return undefined;
851+
}
852+
}
793853
}

typescript/sdk/src/providers/MultiProvider.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,76 @@ describe('MultiProvider', () => {
249249
waitForBlockTagStub.restore();
250250
});
251251
});
252+
253+
describe('tryGetSigner', () => {
254+
it('should cache the connected signer for subsequent calls', () => {
255+
const chainMetadata = {
256+
[TestChainName.test1]: test1,
257+
[TestChainName.test2]: test2,
258+
};
259+
const mp = new MultiProvider(chainMetadata);
260+
261+
let connectCallCount = 0;
262+
const mockProvider = {} as any;
263+
const mockConnectedSigner = { provider: mockProvider } as any;
264+
const mockSigner = {
265+
provider: undefined,
266+
connect: sinon.stub().callsFake(() => {
267+
connectCallCount += 1;
268+
return mockConnectedSigner;
269+
}),
270+
} as any;
271+
272+
mp.signers[TestChainName.test1] = mockSigner;
273+
mp.providers[TestChainName.test1] = mockProvider;
274+
275+
// First call should connect and cache
276+
const result1 = mp.tryGetSigner(TestChainName.test1);
277+
expect(result1).to.equal(mockConnectedSigner);
278+
expect(connectCallCount).to.equal(1);
279+
280+
// Second call should return cached signer without calling connect again
281+
const result2 = mp.tryGetSigner(TestChainName.test1);
282+
expect(result2).to.equal(mockConnectedSigner);
283+
expect(connectCallCount).to.equal(1);
284+
});
285+
286+
it('should not cache signer in shared-signer mode so provider swaps take effect', () => {
287+
const chainMetadata = {
288+
[TestChainName.test1]: test1,
289+
[TestChainName.test2]: test2,
290+
};
291+
const mp = new MultiProvider(chainMetadata);
292+
293+
const oldProvider = {} as any;
294+
const newProvider = {} as any;
295+
296+
let connectArg: any;
297+
const mockSigner = {
298+
provider: undefined,
299+
connect: sinon.stub().callsFake((p: any) => {
300+
connectArg = p;
301+
return { provider: p, getAddress: () => '0x1' } as any;
302+
}),
303+
} as any;
304+
305+
// Use shared signer mode
306+
mp.useSharedSigner = true;
307+
mp.signers[TestChainName.test1] = mockSigner;
308+
mp.providers[TestChainName.test1] = oldProvider;
309+
310+
// First call connects to old provider
311+
const result1 = mp.tryGetSigner(TestChainName.test1);
312+
expect(connectArg).to.equal(oldProvider);
313+
expect(result1!.provider).to.equal(oldProvider);
314+
315+
// Swap provider — in shared mode, setProvider skips reconnection
316+
mp.providers[TestChainName.test1] = newProvider;
317+
318+
// Second call should reconnect to new provider (not return stale cached signer)
319+
const result2 = mp.tryGetSigner(TestChainName.test1);
320+
expect(connectArg).to.equal(newProvider);
321+
expect(result2!.provider).to.equal(newProvider);
322+
});
323+
});
252324
});

typescript/sdk/src/providers/MultiProvider.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
Address,
2121
ProtocolType,
2222
addBufferToGasLimit,
23+
assert,
2324
pick,
2425
rootLogger,
2526
timeout,
@@ -171,8 +172,20 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
171172
const chainName = this.getChainName(chainNameOrId);
172173
this.providers[chainName] = provider;
173174
const signer = this.signers[chainName];
174-
if (signer && signer.provider) {
175-
this.setSigner(chainName, signer.connect(provider));
175+
if (signer && signer.provider && !this.useSharedSigner) {
176+
try {
177+
this.setSigner(chainName, signer.connect(provider));
178+
} catch (e: unknown) {
179+
// JsonRpcSigner throws UNSUPPORTED_OPERATION for .connect();
180+
// use a type guard instead of `as` cast to safely access .code
181+
const code =
182+
typeof e === 'object' && e !== null && 'code' in e
183+
? String((e as Record<string, unknown>).code)
184+
: undefined;
185+
if (code !== 'UNSUPPORTED_OPERATION') {
186+
throw e;
187+
}
188+
}
176189
}
177190
return provider;
178191
}
@@ -183,8 +196,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
183196
*/
184197
setProviders(providers: ChainMap<Provider>): void {
185198
for (const chain of Object.keys(providers)) {
186-
const chainName = this.getChainName(chain);
187-
this.providers[chainName] = providers[chain];
199+
this.setProvider(chain, providers[chain]);
188200
}
189201
}
190202

@@ -201,7 +213,15 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
201213
// Auto-connect the signer for convenience
202214
const provider = this.tryGetProvider(chainName);
203215
if (!provider) return signer;
204-
return signer.connect(provider);
216+
const connected = signer.connect(provider);
217+
// Only cache when not using a shared signer. In shared-signer mode,
218+
// caching pins the signer to this provider; setProvider() skips
219+
// reconnection when useSharedSigner is true, so the cached signer
220+
// would go stale after a provider swap.
221+
if (!this.useSharedSigner) {
222+
this.signers[chainName] = connected;
223+
}
224+
return connected;
205225
}
206226

207227
/**
@@ -336,6 +356,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
336356
rangeSize = this.getMaxBlockRange(chainNameOrId),
337357
): Promise<{ fromBlock: number; toBlock: number }> {
338358
const toBlock = await this.getProvider(chainNameOrId).getBlock('latest');
359+
assert(toBlock, `Unable to fetch latest block for ${chainNameOrId}`);
339360
const fromBlock = Math.max(toBlock.number - rangeSize, 0);
340361
return { fromBlock, toBlock: toBlock.number };
341362
}
@@ -395,6 +416,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
395416
...overrides,
396417
});
397418
// manually wait for deploy tx to be confirmed
419+
assert(contract.deployTransaction, 'Deploy transaction missing');
398420
await this.handleTx(chainNameOrId, contract.deployTransaction);
399421
}
400422

@@ -489,11 +511,13 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
489511
this.logger.info(
490512
`Pending ${txUrl || response.hash} (wait(0) returned pending, waiting for initial inclusion)`,
491513
);
492-
return timeout(
514+
const inclusionReceipt = await timeout(
493515
response.wait(1),
494516
timeoutMs,
495517
`Timeout (${timeoutMs}ms) waiting for initial inclusion for tx ${response.hash}`,
496518
);
519+
assert(inclusionReceipt, `Transaction ${response.hash} was not included`);
520+
return inclusionReceipt;
497521
}
498522

499523
/**
@@ -511,6 +535,11 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
511535
): Promise<ContractReceipt> {
512536
const provider = this.getProvider(chainNameOrId);
513537
const receipt = await response.wait(1); // Wait for initial inclusion
538+
assert(receipt, `Transaction ${response.hash} was not included`);
539+
assert(
540+
typeof receipt.blockNumber === 'number',
541+
`Receipt missing block number for tx ${response.hash}`,
542+
);
514543
const txBlock = receipt.blockNumber;
515544

516545
// Check if block tag is supported on first call

typescript/sdk/src/utils/HyperlaneReader.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ export class HyperlaneReader {
2121
* @param level - The log level to set, e.g. 'debug', 'info', 'warn', 'error'.
2222
*/
2323
protected setSmartProviderLogLevel(level: LevelWithSilentOrString): void {
24-
if ('setLogLevel' in this.provider) {
25-
(this.provider as HyperlaneSmartProvider).setLogLevel(level);
24+
if (this.provider instanceof HyperlaneSmartProvider) {
25+
this.provider.setLogLevel(level);
2626
}
2727
}
2828
}

0 commit comments

Comments
 (0)