Tracking Events

This section contains hints for how you can implement your own managed service to track Across events throughout their lifecycle. As an alternative, we also host a public managed solution that you can learn more about in the API section.

Using our API

Using your own managed service

Implementation

Deposit and corresponding fill events are conveniently scraped by a database and available here. The database implementation can be found in this repository.

How to detect the status of a deposit

When a user deposits capital to a SpokePool, a V3FundsDeposited event is emitted. This event is now emitted for both deposit() (legacy function interface) and depositV3() calls. FundsDeposited, the V2 event, is no longer possible to emit.

event V3FundsDeposited(
        address inputToken,
        // Note: outputToken can be set to 0x0 (zero address) in which case, the filler
        // should replace this address with the "equivalent" destination chain token
        // as the input token. For example, if input token is USDC on chain A, then the output
        // token should be the USDC token supported by Across on the destination chain
        address outputToken,
        uint256 inputAmount,
        uint256 outputAmount,
        uint256 indexed destinationChainId,
        uint32 indexed depositId,
        uint32 quoteTimestamp,
        uint32 fillDeadline,
        uint32 exclusivityDeadline,
        address indexed depositor,
        address recipient,
        address exclusiveRelayer,
        bytes message
)

This data comprises the new deposit's "RelayData" which is combined with the block.chainId of the SpokePool that emitted the event to form:

// This struct represents the data to fully specify a **unique** relay submitted on this chain.
// This data is hashed with the chainId() and saved by the SpokePool to prevent collisions and protect against
// replay attacks on other chains. If any portion of this data differs, the relay is considered to be
// completely distinct. See _getV3RelayHash() below for how the relay hash is derived from the relay data.
struct V3RelayData {
        // The address that made the deposit on the origin chain.
        address depositor;
        // The recipient address on the destination chain.
        address recipient;
        // This is the exclusive relayer who can fill the deposit before the exclusivity deadline.
        address exclusiveRelayer;
        // Token that is deposited on origin chain by depositor.
        address inputToken;
        // Token that is received on destination chain by recipient.
        address outputToken;
        // The amount of input token deposited by depositor.
        uint256 inputAmount;
        // The amount of output token to be received by recipient.
        uint256 outputAmount;
        // Origin chain id.
        uint256 originChainId;
        // The id uniquely identifying this deposit on the origin chain.
        uint32 depositId;
        // The timestamp on the destination chain after which this deposit can no longer be filled.
        uint32 fillDeadline;
        // The timestamp on the destination chain after which any relayer can fill the deposit.
        uint32 exclusivityDeadline;
        // Data that is forwarded to the recipient.
        bytes message;
}

function _getV3RelayHash(V3RelayData memory relayData) private view returns (bytes32) {
        return keccak256(abi.encode(relayData, chainId()));
}

There are two ways to determine whether a deposit has been filled on the destinationChain:

  1. Each fill of a deposit emits a FilledV3Relay which emits all of the data that you'd need to construct another V3RelayData structure (along with the destination chain's block.chainId). If this relay hash matches the deposit relay hash, then the deposit is considered valid and the filler will receive a refund in the next bundle.

    event FilledV3Relay(
            address inputToken,
            // Note: outputToken should never be 0x0 in this event, unlike in a V3FundDeposited
            // event. If this is 0x0 in the corresponding deposit event, then this should
            // be equal to the equivalent destination token supported by this SpokePool.
            address outputToken,
            uint256 inputAmount,
            uint256 outputAmount,
            uint256 repaymentChainId,
            uint256 indexed originChainId,
            uint32 indexed depositId,
            uint32 fillDeadline,
            uint32 exclusivityDeadline,
            address exclusiveRelayer,
            address indexed relayer,
            address depositor,
            address recipient,
            bytes message,
            V3RelayExecutionEventInfo relayExecutionInfo
    )
  2. Call the SpokePool.fillStatuses(bytes32): uint256 function passing in the deposit relay hash. The possible fill statuses are:

    // Fill status tracks on-chain state of deposit, uniquely identified by relayHash.
    enum FillStatus {
            // Self-explanatory
            Unfilled, 
            // Someone requested a slow fill for this deposit, it will be 
            // slow filled to the user in the next root bundle unless it gets filled
            // by a relayer before that bundle is validated and the slow fill 
            // is executed.
            RequestedSlowFill,
            // Filled by a relayer.
            Filled
    }

Expired Deposits

When an unfilled deposit's fillDeadline exceeds the destination chain's block.timestamp, it is no longer fillable--the SpokePool contract on the destination chain will revert on a fillRelay() call.

Expired deposits are technically refunded by the next root bundle proposed to the HubPool containing an expired deposit refund. Root bundles contain bridge events for a set of block ranges (start and end blocks) for each chain supported in Across. Root bundles are proposed optimistically as a batch of Merkle Roots, one of which is a list of refund payments including deposits that expired in the deposit's origin chain block range. Therefore, to know when precisely an expired deposit was refunded requires reconstructing root bundle data. This is a complex task and we're working on making this data available in a hosted service with an API interface.

In the meantime, we believe its accurate to state that expired deposits will be refunded approximately 90 minutes after their fillDeadline. Expired deposits are sent to the depositor address on the originChainId. The 90 minute figure assumes that on average, deposits expire in the middle of a pending root bundle proposal which implies that the next root bundle will contain the expired deposit refund. Because root bundle proposals are validated optimistically and the current challenge period is 60 minutes, then on average it will take 30 minutes for the next root bundle to be proposed containing a refund for the deposit and another 60 minutes for that root bundle to be executed.

Forecasting a pending deposit's estimated time of arrival

Fill ETAs for deposits vary based on route and available filler liquidity. For example, most deposit transactions will not be filled until they probabilistically finalized on the origin chain, where the deposit occurred.

In general, deposits that exceed current filler liquidity on the destination chain will be delayed, and unprofitable deposits (where the difference in the output amount and the input amount is insufficient to cover the filler's transaction and opportunity costs) might never be filled until they expire. Expired deposits will eventually be refunded, as described above.

The Limits endpoint can be called with the deposit information to forecast the fill ETA. If the deposit amount exceeds the maxDepositShortDelay, then the deposit is likely to have been marked for slow fill. To deterministically check if a deposit has been marked for slow fill, you can call the fillStatuses() function on the destination SpokePool as described above.

Last updated