Summary
The Hyperlane7683 solver records an origin Open event as processed inside the destination-fill step, before settlement has succeeded. If the destination fill transaction succeeds but settleOrder(...) fails, the solver can persist the source block/order id as processed and then skip that same Open event after restart. The result is a filled destination order whose origin-side settlement is no longer naturally retried by the event loop.
Reviewed commit: 62a181ff597f
Why this matters
For a cross-chain intent, fill and settle are different lifecycle stages. A destination fill fronts funds, while settlement is what lets the filler claim the origin-chain locked funds. Checkpointing the source event between those two stages can strand the lifecycle in a partially completed state:
Open event indexed -> destination fill succeeds -> source checkpoint saved -> settlement fails
restart -> Open event is skipped as already processed -> settlement is not retried
That is especially risky because settleOrder(...) catches its own errors and returns, so BaseFiller cannot tell that settlement failed.
Affected code
typescript/solver/solvers/BaseFiller.ts
The generic lifecycle runs fill(...) first and only then calls settleOrder(...).
81 try {
82 await this.fill(parsedArgs, data, originChainName, blockNumber);
83
84 await this.settleOrder(parsedArgs, data, originChainName);
85 } catch (error) {
86 this.log.error({
87 msg: `Failed processing intent`,
88 intent: `${this.metadata.protocolName}-${parsedArgs.orderId}`,
89 error: JSON.stringify(error),
90 });
91 }
This ordering is fine only if fill(...) does not durably mark the source event complete before settlement is finished.
typescript/solver/solvers/hyperlane7683/filler.ts
Hyperlane7683Filler.fill(...) performs the destination fill(...) calls, waits for those receipts, and then saves the origin block/order id as processed before BaseFiller.create() gets to settleOrder(...).
158 await Promise.all(
159 data.fillInstructions.map(
160 async (
161 { destinationChainId, destinationSettler, originData },
162 index,
163 ) => {
164 destinationSettler = bytes32ToAddress(destinationSettler);
165 const _chainId = destinationChainId.toString();
166
167 const filler = this.multiProvider.getSigner(_chainId);
168 const fillerAddress = await filler.getAddress();
169 const destination = Hyperlane7683__factory.connect(
170 destinationSettler,
171 filler,
172 );
173
174 const value =
175 bytes32ToAddress(data.maxSpent[index].token) === AddressZero
176 ? data.maxSpent[index].amount
177 : undefined;
178
179 // Depending on the implementation we may call `destination.fill` directly or call some other
180 // contract that will produce the funds needed to execute this leg and then in turn call
181 // `destination.fill`
182 const tx = await destination.fill(
183 parsedArgs.orderId,
184 originData,
185 addressToBytes32(fillerAddress),
186 { value },
187 );
188
189 const receipt = await tx.wait();
190 const baseUrl =
191 this.multiProvider.getChainMetadata(_chainId).blockExplorers?.[0]
192 .url;
193
194 const txInfo = baseUrl
195 ? `${baseUrl}/tx/${receipt.transactionHash}`
196 : receipt.transactionHash;
197
198 log.info({
199 msg: "Filled Intent",
200 intent: `${this.metadata.protocolName}-${parsedArgs.orderId}`,
201 txDetails: txInfo,
202 txHash: receipt.transactionHash,
203 });
204 },
205 ),
206 );
207
208 await saveBlockNumber(originChainName, blockNumber, parsedArgs.orderId);
At line 208 the source event is checkpointed even though settlement has not yet been attempted.
typescript/solver/solvers/hyperlane7683/utils.ts
Settlement failures are logged and swallowed inside settleOrder(...).
49 try {
50 const value = await destination.quoteGasPayment(originChainId);
51
52 const _tx = await destination.populateTransaction.settle(
53 [orderId],
54 { value },
55 );
56
57 const gasLimit = await multiProvider.estimateGas(
58 destinationChain,
59 _tx,
60 await filler.getAddress(),
61 );
62
63 const tx = await destination.settle([orderId], {
64 value,
65 gasLimit: gasLimit.mul(110).div(100),
66 });
67
68 const receipt = await tx.wait();
69
70 log.info({
71 msg: "Settled Intent",
72 intent: `${solverName}-${orderId}`,
73 txDetails: `https://explorer.hyperlane.xyz/?search=${receipt.transactionHash}`,
74 txHash: receipt.transactionHash,
75 });
76 } catch (error) {
77 log.error({
78 msg: `Failed settling`,
79 intent: `${solverName}-${orderId}`,
80 error,
81 });
82 return;
83 }
Because the catch block returns instead of throwing, BaseFiller sees the overall lifecycle as successful after settleOrder(...) returns.
typescript/solver/solvers/hyperlane7683/db.ts
The checkpoint stores the block and appends processedIds for the source event.
54 export const saveBlockNumber = (
55 chainName: string,
56 blockNumber: number,
57 processedIds: string,
58 ) => {
59 db.execute({
60 sql: `INSERT INTO indexedBlocks (chainName, blockNumber, processedIds)
61 VALUES (:chainName, :blockNumber, :processedIds)
62 ON CONFLICT(chainName, blockNumber)
63 DO UPDATE SET processedIds = indexedBlocks.processedIds || ',' || excluded.processedIds;`,
64 args: {
65 chainName,
66 blockNumber,
67 processedIds,
68 },
69 });
70 };
There is also a smaller persistence issue here: saveBlockNumber does not return the db.execute(...) promise, so await saveBlockNumber(...) in filler.ts does not actually wait for the write.
typescript/solver/solvers/hyperlane7683/listener.ts
On restart, the listener resumes from the last persisted block and carries the persisted processed ids into the listener metadata.
42 export const create = async () => {
43 const { intentSources } = metadata;
44 const blocksByChain = await getLastIndexedBlocks();
45
46 metadata.intentSources = intentSources.map((intentSource) => {
47 const chainBlockNumber =
48 blocksByChain[intentSource.chainName]?.blockNumber;
49
50 if (
51 chainBlockNumber &&
52 chainBlockNumber >= (intentSource.initialBlock ?? 0)
53 ) {
54 return {
55 ...intentSource,
56 initialBlock: blocksByChain[intentSource.chainName].blockNumber,
57 processedIds: blocksByChain[intentSource.chainName].processedIds,
58 };
59 }
60 return intentSource;
61 });
62
63 return new Hyperlane7683Listener(metadata).create();
64 };
typescript/solver/solvers/BaseListener.ts
When replaying the resumed block, an event at the checkpoint block is skipped if its orderId is in processedIds.
186 const pastEvents = await contract.queryFilter(filter, from, to);
187
188 for (let event of pastEvents) {
189 const parsedArgs = this.parseEventArgs((event as TEvent).args);
190 if (
191 event.blockNumber === from &&
192 processedIds?.includes(parsedArgs.orderId)
193 ) {
194 continue;
195 }
196 await handler(parsedArgs, chainName, event.blockNumber);
197 }
This makes the loss mode concrete: after saveBlockNumber(originChainName, blockNumber, orderId), a restart skips the exact source event that would have re-driven settlement.
Example failure sequence
- Solver indexes
Open(orderId, resolvedOrder) on the origin chain.
Hyperlane7683Filler.fill(...) submits destination.fill(...) and the receipt succeeds.
saveBlockNumber(originChainName, blockNumber, orderId) records the source event as processed.
settleOrder(...) fails because quoteGasPayment, gas estimation, dispatch, RPC, or transaction inclusion fails.
- The settlement helper catches and returns, so the outer lifecycle does not fail.
- After restart,
listener.ts resumes from blockNumber with processedIds = [orderId], and BaseListener.processPrevBlocks(...) skips the Open event.
At that point, the destination side may be FILLED, but the origin-side settlement message was never dispatched successfully.
Suggested fix
- Do not write the source-event processed checkpoint until settlement has succeeded, or split the checkpoint into explicit lifecycle states such as
OPEN_SEEN, DESTINATION_FILLED, SETTLE_PENDING, and SETTLED.
- Make
settleOrder(...) propagate failures to the caller, or return a per-settler result that prevents the lifecycle from being marked complete while any required settlement failed.
- Add a recovery worker that resumes
SETTLE_PENDING orders independently of replaying the original Open event.
- Return
db.execute(...) from saveBlockNumber so await saveBlockNumber(...) waits for the durable write.
Summary
The Hyperlane7683 solver records an origin
Openevent as processed inside the destination-fill step, before settlement has succeeded. If the destination fill transaction succeeds butsettleOrder(...)fails, the solver can persist the source block/order id as processed and then skip that sameOpenevent after restart. The result is a filled destination order whose origin-side settlement is no longer naturally retried by the event loop.Reviewed commit:
62a181ff597fWhy this matters
For a cross-chain intent,
fillandsettleare different lifecycle stages. A destination fill fronts funds, while settlement is what lets the filler claim the origin-chain locked funds. Checkpointing the source event between those two stages can strand the lifecycle in a partially completed state:That is especially risky because
settleOrder(...)catches its own errors and returns, soBaseFillercannot tell that settlement failed.Affected code
typescript/solver/solvers/BaseFiller.tsThe generic lifecycle runs
fill(...)first and only then callssettleOrder(...).This ordering is fine only if
fill(...)does not durably mark the source event complete before settlement is finished.typescript/solver/solvers/hyperlane7683/filler.tsHyperlane7683Filler.fill(...)performs the destinationfill(...)calls, waits for those receipts, and then saves the origin block/order id as processed beforeBaseFiller.create()gets tosettleOrder(...).At line 208 the source event is checkpointed even though settlement has not yet been attempted.
typescript/solver/solvers/hyperlane7683/utils.tsSettlement failures are logged and swallowed inside
settleOrder(...).Because the catch block returns instead of throwing,
BaseFillersees the overall lifecycle as successful aftersettleOrder(...)returns.typescript/solver/solvers/hyperlane7683/db.tsThe checkpoint stores the block and appends
processedIdsfor the source event.There is also a smaller persistence issue here:
saveBlockNumberdoes not return thedb.execute(...)promise, soawait saveBlockNumber(...)infiller.tsdoes not actually wait for the write.typescript/solver/solvers/hyperlane7683/listener.tsOn restart, the listener resumes from the last persisted block and carries the persisted processed ids into the listener metadata.
typescript/solver/solvers/BaseListener.tsWhen replaying the resumed block, an event at the checkpoint block is skipped if its
orderIdis inprocessedIds.This makes the loss mode concrete: after
saveBlockNumber(originChainName, blockNumber, orderId), a restart skips the exact source event that would have re-driven settlement.Example failure sequence
Open(orderId, resolvedOrder)on the origin chain.Hyperlane7683Filler.fill(...)submitsdestination.fill(...)and the receipt succeeds.saveBlockNumber(originChainName, blockNumber, orderId)records the source event as processed.settleOrder(...)fails becausequoteGasPayment, gas estimation, dispatch, RPC, or transaction inclusion fails.listener.tsresumes fromblockNumberwithprocessedIds = [orderId], andBaseListener.processPrevBlocks(...)skips theOpenevent.At that point, the destination side may be
FILLED, but the origin-side settlement message was never dispatched successfully.Suggested fix
OPEN_SEEN,DESTINATION_FILLED,SETTLE_PENDING, andSETTLED.settleOrder(...)propagate failures to the caller, or return a per-settler result that prevents the lifecycle from being marked complete while any required settlement failed.SETTLE_PENDINGorders independently of replaying the originalOpenevent.db.execute(...)fromsaveBlockNumbersoawait saveBlockNumber(...)waits for the durable write.