Skip to content

feat(SvmSpokePoolClient): relayFillStatus and fillStatusArray implementation #990

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: epic/svm-client
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 80 additions & 16 deletions src/arch/svm/SpokeUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import assert from "assert";
import { Rpc, SolanaRpcApi, Address } from "@solana/kit";
import { SvmSpokeIdl } from "@across-protocol/contracts";
import { fetchState } from "@across-protocol/contracts/dist/src/svm/clients/SvmSpoke";

import { SvmCpiEventsClient } from "./eventsClient";
import { Deposit, FillStatus, FillWithBlock, RelayData } from "../../interfaces";
import { BigNumber, isUnsafeDepositId } from "../../utils";
import { fetchState } from "@across-protocol/contracts/dist/src/svm/clients/SvmSpoke";
import { BigNumber, chainIsSvm, chunk, isUnsafeDepositId } from "../../utils";
import { getFillStatusPda } from "./utils";
import { SVMEventNames } from "./types";

type Provider = Rpc<SolanaRpcApi>;

Expand Down Expand Up @@ -125,27 +130,86 @@ export function findDepositBlock(
}

/**
* Find the amount filled for a deposit at a particular block.
* Find the fill status for a deposit at a particular block.
* @param spokePool SpokePool contract instance.
* @param relayData Deposit information that is used to complete a fill.
* @param blockTag Block tag (numeric or "latest") to query at.
* @returns The amount filled for the specified deposit at the requested block (or latest).
* @param fromSlot Slot to start the search at.
* @param blockTag Slot (numeric or "confirmed") to query at.
* @returns The fill status for the specified deposit at the requested slot (or at the current confirmed slot).
*/
export function relayFillStatus(
_spokePool: unknown,
_relayData: RelayData,
_blockTag?: number | "latest",
_destinationChainId?: number
export async function relayFillStatus(
programId: Address,
relayData: RelayData,
fromSlot: number,
blockTag: number | "confirmed" = "confirmed",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the delta between processed and confirmed is reliably 0 - 2 slots then I wonder if we can just revert to a raw slot number for the upper bound (i.e. toSlot?: number), where toSlot would be a caller-specified slot, or otherwise resolved dynamically to the latest confirmed slot. wdyt?

destinationChainId: number,
provider: Provider
): Promise<FillStatus> {
throw new Error("relayFillStatus: not implemented");
assert(chainIsSvm(destinationChainId), "Destination chain must be an SVM chain");

// Get fill status PDA using relayData
const fillStatusPda = await getFillStatusPda(programId, relayData, destinationChainId);

// Set search range
let toSlot: bigint;
if (blockTag === "confirmed") {
toSlot = await provider.getSlot({ commitment: "confirmed" }).send();
} else {
toSlot = BigInt(blockTag);
Copy link
Contributor

@pxrl pxrl Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth validating toSlot >= fromSlot here? (nb. this assumes we retain fromSlot).

}

// Get fill and requested slow fill events from fillStatusPda
const pdaEventsClient = await SvmCpiEventsClient.createFor(provider, programId, SvmSpokeIdl, fillStatusPda);
const eventsToQuery = [SVMEventNames.FilledRelay, SVMEventNames.RequestedSlowFill];
const relevantEvents = (
await Promise.all(
eventsToQuery.map((eventName) =>
pdaEventsClient.queryDerivedAddressEvents(eventName, BigInt(fromSlot), toSlot, { limit: 50 })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooc, is there an increased cost by setting a low fromSlot here? Having to be explicit about fromSlot seems like a burden on the caller; in most (all?) cases I think the caller probably only cares about the upper bound (i.e. was this completed before some previous point in time, or alternatively, what's the current status?).

(If we do need to set reasonable bounds on fromSlot then I wonder if we could infer a safe lower bound based on the relayData.fillDeadline property)

)
)
).flat();

if (relevantEvents.length === 0) {
// No fill or requested slow fill events found for this fill status PDA
return FillStatus.Unfilled;
}

// Sort events in ascending order of slot number
relevantEvents.sort((a, b) => Number(a.slot - b.slot));

// At this point we have an ordered array of fill and requested slow fill events and since it's not possible to
// submit a slow fill request once a fill has been submitted, we can use the last event in the sorted list to
// determine the fill status at the requested block.
const fillStatusEvent = relevantEvents.pop();
switch (fillStatusEvent!.name) {
case SVMEventNames.FilledRelay:
return FillStatus.Filled;
case SVMEventNames.RequestedSlowFill:
return FillStatus.RequestedSlowFill;
default:
throw new Error(`Unexpected event name: ${fillStatusEvent!.name}`);
}
}

export function fillStatusArray(
_spokePool: unknown,
_relayData: RelayData[],
_blockTag = "processed"
export async function fillStatusArray(
programId: Address,
relayData: RelayData[],
fromSlot: number,
blockTag: number | "confirmed" = "confirmed",
destinationChainId: number,
provider: Provider
): Promise<(FillStatus | undefined)[]> {
throw new Error("fillStatusArray: not implemented");
assert(chainIsSvm(destinationChainId), "Destination chain must be an SVM chain");
const chunkSize = 2;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to be 2 or 200? If I read the commit message properly then I think it hints at 200. 2 seems quite low, though we seem to lack any multicall-like read capabilities here, so going for high-parallelism adds the risk of rate-limiting as well :(

As background, the relayer can hit this function with an array of hundreds of relayData objects. If we're querying 2 at a time it's going to take quite a while to resolve that. I also wonder if we might see some nasty overheads given that relayFillStatus() instantiates a new events client for each individual query.

const chunkedRelayData = chunk(relayData, chunkSize);
const results = [];
for (const chunk of chunkedRelayData) {
const chunkResults = await Promise.all(
chunk.map((relayData) => relayFillStatus(programId, relayData, fromSlot, blockTag, destinationChainId, provider))
);
results.push(...chunkResults);
}
return results.flat();
}

/**
Expand Down
24 changes: 23 additions & 1 deletion src/arch/svm/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { BN, BorshEventCoder, Idl } from "@coral-xyz/anchor";
import web3, { address, getProgramDerivedAddress, getU64Encoder, Address, RpcTransport } from "@solana/kit";
import { EventName, SVMEventNames } from "./types";

import { getRelayDataHash } from "../../utils";
import { RelayData } from "../../interfaces";
/**
* Helper to determine if the current RPC network is devnet.
*/
Expand Down Expand Up @@ -79,3 +80,24 @@ export async function getStatePda(programId: Address): Promise<Address> {
});
return statePda;
}

/**
* Returns the fill status PDA for the given relay data.
* @param programId The SpokePool program ID.
* @param relayData The relay data to get the fill status PDA for.
* @param destinationChainId The destination chain ID.
* @returns The PDA for the fill status.
*/
export async function getFillStatusPda(
programId: Address,
relayData: RelayData,
destinationChainId: number
): Promise<Address> {
const relayDataHash = getRelayDataHash(relayData, destinationChainId);
const uint8RelayDataHash = new Uint8Array(Buffer.from(relayDataHash.slice(2), "hex"));
const [fillStatusPda] = await getProgramDerivedAddress({
programAddress: programId,
seeds: ["fills", uint8RelayDataHash],
});
return fillStatusPda;
}
32 changes: 24 additions & 8 deletions src/clients/SpokePoolClient/SVMSpokePoolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import winston from "winston";
import { Address, Rpc, SolanaRpcApiFromTransport, RpcTransport } from "@solana/kit";

import { BigNumber, DepositSearchResult, EventSearchConfig, MakeOptional } from "../../utils";
import { SvmCpiEventsClient, SVMEventNames, getFillDeadline, getTimestampForBlock, getStatePda } from "../../arch/svm";
import {
SvmCpiEventsClient,
SVMEventNames,
getFillDeadline,
getTimestampForBlock,
getStatePda,
relayFillStatus,
fillStatusArray,
} from "../../arch/svm";
import { HubPoolClient } from "../HubPoolClient";
import { knownEventNames, SpokePoolClient, SpokePoolUpdate } from "./SpokePoolClient";
import { RelayData, FillStatus } from "../../interfaces";
Expand Down Expand Up @@ -191,12 +199,14 @@ export class SvmSpokePoolClient extends SpokePoolClient {
* Retrieves the fill status for a given relay data from the SVM chain.
* TODO: Implement SVM state query for fill status.
*/
public relayFillStatus(
_relayData: RelayData,
_slot?: number | "latest", // Use slot instead of blockTag
_destinationChainId?: number
public override relayFillStatus(
relayData: RelayData,
blockTag: number | "confirmed",
destinationChainId?: number
): Promise<FillStatus> {
throw new Error("relayFillStatus not implemented for SVM");
destinationChainId ??= this.chainId;
// @note: deploymentBlock actually refers to the deployment slot. Also, blockTag should be a slot number.
return relayFillStatus(this.programId, relayData, this.deploymentBlock, blockTag, destinationChainId, this.rpc);
}

/**
Expand All @@ -205,7 +215,13 @@ export class SvmSpokePoolClient extends SpokePoolClient {
* @param blockTag The block at which to query the fill status.
* @returns The fill status for each of the given relay data.
*/
public fillStatusArray(_relayData: RelayData[], _blockTag?: number | "latest"): Promise<(FillStatus | undefined)[]> {
throw new Error("fillStatusArray not implemented for SVM");
public fillStatusArray(
relayData: RelayData[],
blockTag?: number | "confirmed",
destinationChainId?: number
): Promise<(FillStatus | undefined)[]> {
// @note: deploymentBlock actually refers to the deployment slot. Also, blockTag should be a slot number.
destinationChainId ??= this.chainId;
return fillStatusArray(this.programId, relayData, this.deploymentBlock, blockTag, destinationChainId, this.rpc);
}
}
7 changes: 5 additions & 2 deletions src/clients/SpokePoolClient/SpokePoolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,10 @@ export abstract class SpokePoolClient extends BaseAbstractClient {
* @param blockTag The block at which to query the fill status.
* @returns The fill status for the given relay data.
*/
public abstract relayFillStatus(relayData: RelayData, blockTag?: number | "latest"): Promise<FillStatus>;
public abstract relayFillStatus(
relayData: RelayData,
blockTag?: number | "latest" | "confirmed"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing confirmed at this interface means that the caller needs to be aware of which chain type they're on, and we then need to validate in each chain-specific implementation that the correct tag was passed in. If we modify the type to be just an optional number then we could infer "chain-specific latest" if the input is undefined. That seems more platform-agnostic, though it means that we internally must decide on processed or confirmed for SVM. Based on @james-a-morris feedback about the delta being only 1 - 2 slots, it seems like we could probably live with confirmed for now. wdyt?

Suggested change
blockTag?: number | "latest" | "confirmed"
blockTag?: number

): Promise<FillStatus>;

/**
* Retrieves the fill status for an array of given relay data.
Expand All @@ -823,6 +826,6 @@ export abstract class SpokePoolClient extends BaseAbstractClient {
*/
public abstract fillStatusArray(
relayData: RelayData[],
blockTag?: number | "latest"
blockTag?: number | "latest" | "confirmed"
): Promise<(FillStatus | undefined)[]>;
}
46 changes: 45 additions & 1 deletion src/utils/SpokeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { MAX_SAFE_DEPOSIT_ID, ZERO_ADDRESS, ZERO_BYTES } from "../constants";
import { Deposit, RelayData } from "../interfaces";
import { toBytes32 } from "./AddressUtils";
import { keccak256 } from "./common";
import { BigNumber } from "./BigNumberUtils";
import { BigNumber, toBN } from "./BigNumberUtils";
import { isMessageEmpty } from "./DepositUtils";
import { chainIsSvm } from "./NetworkUtils";

/**
* Produce the RelayData for a Deposit.
Expand Down Expand Up @@ -43,6 +44,9 @@ export function getRelayDataHash(relayData: RelayData, destinationChainId: numbe
outputToken: toBytes32(relayData.outputToken),
exclusiveRelayer: toBytes32(relayData.exclusiveRelayer),
};
if (chainIsSvm(destinationChainId)) {
return _getRelayDataHashSvm(_relayData, destinationChainId);
}
return keccak256(
ethersUtils.defaultAbiCoder.encode(
[
Expand Down Expand Up @@ -71,6 +75,46 @@ export function getRelayHashFromEvent(e: RelayData & { destinationChainId: numbe
return getRelayDataHash(e, e.destinationChainId);
}

function _getRelayDataHashSvm(relayData: RelayData, destinationChainId: number): string {
const uint8ArrayFromHexString = (hex: string, littleEndian: boolean = false): Uint8Array => {
const buffer = Buffer.from(hex.slice(2), "hex");
if (buffer.length < 32) {
const zeroPad = new Uint8Array(32);
buffer.copy(zeroPad, 32 - buffer.length);
return littleEndian ? zeroPad.reverse() : zeroPad;
}
const result = new Uint8Array(buffer.slice(0, 32));
return littleEndian ? result.reverse() : result;
};
const uint8ArrayFromInt = (num: BigNumber, byteLength: number, littleEndian: boolean = true): Uint8Array => {
const buffer = Buffer.from(num.toHexString().slice(2), "hex");
if (buffer.length < byteLength) {
const zeroPad = new Uint8Array(byteLength);
buffer.copy(zeroPad, byteLength - buffer.length);
return littleEndian ? zeroPad.reverse() : zeroPad;
}
const result = new Uint8Array(buffer.slice(0, byteLength));
return littleEndian ? result.reverse() : result;
};
const contentToHash = Buffer.concat([
uint8ArrayFromHexString(relayData.depositor),
uint8ArrayFromHexString(relayData.recipient),
uint8ArrayFromHexString(relayData.exclusiveRelayer),
uint8ArrayFromHexString(relayData.inputToken),
uint8ArrayFromHexString(relayData.outputToken),
uint8ArrayFromInt(relayData.inputAmount, 8),
uint8ArrayFromInt(relayData.outputAmount, 8),
uint8ArrayFromInt(toBN(relayData.originChainId), 8),
uint8ArrayFromInt(relayData.depositId, 32, false),
uint8ArrayFromInt(toBN(relayData.fillDeadline), 4),
uint8ArrayFromInt(toBN(relayData.exclusivityDeadline), 4),
uint8ArrayFromHexString(getMessageHash(relayData.message)),
uint8ArrayFromInt(toBN(destinationChainId), 8),
]);
const returnHash = keccak256(contentToHash);
return returnHash;
}

export function isUnsafeDepositId(depositId: BigNumber): boolean {
// SpokePool.unsafeDepositV3() produces a uint256 depositId by hashing the msg.sender, depositor and input
// uint256 depositNonce. There is a possibility that this resultant uint256 is less than the maxSafeDepositId (i.e.
Expand Down