Skip to content

Hyperlane solver can checkpoint an intent before settlement succeeds #134

Description

@chenshj73

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

  1. Solver indexes Open(orderId, resolvedOrder) on the origin chain.
  2. Hyperlane7683Filler.fill(...) submits destination.fill(...) and the receipt succeeds.
  3. saveBlockNumber(originChainName, blockNumber, orderId) records the source event as processed.
  4. settleOrder(...) fails because quoteGasPayment, gas estimation, dispatch, RPC, or transaction inclusion fails.
  5. The settlement helper catches and returns, so the outer lifecycle does not fail.
  6. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions