Skip to content

feat(svmSpokeUtils): svm relayFillStatus implementation #984

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

124 changes: 94 additions & 30 deletions src/arch/svm/SpokeUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Rpc, SolanaRpcApi } from "@solana/kit";
import { Rpc, SolanaRpcApi, Address } from "@solana/kit";
import { getDeployedBlockNumber } from "@across-protocol/contracts";
import { fetchState } from "@across-protocol/contracts/dist/src/svm/clients/SvmSpoke";

import { Deposit, FillStatus, FillWithBlock, RelayData } from "../../interfaces";
import { BigNumber, isUnsafeDepositId } from "../../utils";
import { isUnsafeDepositId, BigNumber } from "../../utils";
import { getFillStatusPda } from "./utils";
import { SvmSpokeEventsClient } from "./eventsClient";
import { SVMEventNames } from "./types";

type Provider = Rpc<SolanaRpcApi>;

Expand Down Expand Up @@ -46,18 +52,14 @@ export async function getTimestampForBlock(provider: Provider, blockNumber: numb
}

/**
* Return maximum of fill deadline buffer at start and end of block range.
* @param spokePool SpokePool contract instance
* @param startBlock start block
* @param endBlock end block
* @returns maximum of fill deadline buffer at start and end block
* Returns the current fill deadline buffer.
* @param provider SVM Provider instance
* @param statePda Spoke Pool's State PDA
* @returns fill deadline buffer
*/
export function getMaxFillDeadlineInRange(
_spokePool: unknown,
_startBlock: number,
_endBlock: number
): Promise<number> {
throw new Error("getMaxFillDeadlineInRange: not implemented");
export async function getFillDeadline(provider: Provider, statePda: Address): Promise<number> {
const state = await fetchState(provider, statePda);
return state.data.fillDeadlineBuffer;
}

/**
Expand Down Expand Up @@ -109,7 +111,6 @@ export async function getSlotForBlock(
return midSlot;
}
} while (lowSlot <= highSlot);

return undefined;
}

Expand All @@ -127,27 +128,90 @@ export function findDepositBlock(
}

/**
* Find the amount filled for a deposit at a particular block.
* @param spokePool SpokePool contract instance.
* Find the fill status for a deposit at a particular block.
* @param programId SpokePool program address.
* @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 destinationChainId Destination chain ID.
* @param provider Solana RPC provider instance.
* @param svmEventsClient SvmSpokeEventsClient instance.
* @returns The fill status for the specified deposit at the requested block (or latest).
*/
export function relayFillStatus(
_spokePool: unknown,
_relayData: RelayData,
_blockTag?: number | "latest",
_destinationChainId?: number
export async function relayFillStatus(
programId: Address,
relayData: RelayData,
blockTag: number | "latest",
Copy link
Member

Choose a reason for hiding this comment

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

Nit: is latest the correct term in our case? We'd probably be looking for the most recently "processed" or "confirmed" block

destinationChainId: number,
provider: Provider,
svmEventsClient: SvmSpokeEventsClient
): Promise<FillStatus> {
throw new Error("relayFillStatus: not implemented");
}
// Get fill status PDA using relayData
const fillStatusPda = await getFillStatusPda(programId, relayData, destinationChainId);

// Get fill and requested slow fill events from fillStatusPda
// TODO: modify this to use svmEventsClient once we can instantiate it with dynamic addresses.
const fillPdaSignatures = await provider
.getSignaturesForAddress(fillStatusPda, {
limit: 1000,
Copy link
Member

Choose a reason for hiding this comment

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

OOC should we allow the user to pass this value? It's possible for small ranges (like in the indexer) we want to pass a smaller value for faster read times

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can add it as an optional param and I'll also set the default to a lower value since we'd expect these pdas to have only a few events. Good call.

commitment: "confirmed",
})
.send();

const eventsWithSlots = await Promise.all(
fillPdaSignatures.map(async (signatureTransaction) => {
const events = await svmEventsClient.readEventsFromSignature(signatureTransaction.signature);
return events.map((event) => ({
...event,
confirmationStatus: signatureTransaction.confirmationStatus,
blockTime: signatureTransaction.blockTime,
signature: signatureTransaction.signature,
slot: signatureTransaction.slot,
}));
})
);
Comment on lines +153 to +172
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I copied this from the SvmSpokeEventsClient but it is temporary and doesn't cover the case where a pda has +1000 events (which should be rare though). I aim to replace it once this PR goes in and we can use the events client for other addresses besides the spoke pool: #982

Copy link
Member

Choose a reason for hiding this comment

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

What is the likelihood we will be using 1000+ signatures of lookback?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call. We'd expect these pdas to have only a few events, I'll set a lower value.


// Translate blockTag from block to slot to use for filtering events
// This should also be temporary, it can be removed once we can use svmEventsClient with dynamic addresses.
let toSlot: bigint | undefined;
if (blockTag === "latest") {
toSlot = await provider.getSlot().send();
} else {
const lowSlot = getDeployedBlockNumber("SvmSpoke", destinationChainId);
toSlot = await getSlotForBlock(provider, BigInt(blockTag), BigInt(lowSlot));
if (!toSlot) {
throw new Error(`Unable to resolve slot for block ${blockTag}`);
}
}

export function fillStatusArray(
_spokePool: unknown,
_relayData: RelayData[],
_blockTag = "processed"
): Promise<(FillStatus | undefined)[]> {
throw new Error("fillStatusArray: not implemented");
// Filter events by slot and event name
const relevantEvents = eventsWithSlots
.flat()
.filter(
(event) =>
event.slot <= toSlot! &&
[SVMEventNames.FilledRelay, SVMEventNames.RequestedSlowFill].includes(event.name as SVMEventNames)
);

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 only 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.
Comment on lines +204 to +206
Copy link
Member

Choose a reason for hiding this comment

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

Do we have corresponding tests for this?

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}`);
}
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/arch/svm/eventsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class SvmSpokeEventsClient {
* @param commitment - Commitment level.
* @returns A promise that resolves to an array of events.
*/
private async readEventsFromSignature(txSignature: Signature, commitment: Commitment = "confirmed") {
public async readEventsFromSignature(txSignature: Signature, commitment: Commitment = "confirmed") {
const txResult = await this.rpc
.getTransaction(txSignature, { commitment, maxSupportedTransactionVersion: 0 })
.send();
Expand All @@ -175,7 +175,7 @@ export class SvmSpokeEventsClient {
* @param txResult - The transaction result.
* @returns A promise that resolves to an array of events with their data and name.
*/
private processEventFromTx(
public processEventFromTx(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These changes are also temporary and might not be needed with the new version of the svm events client.

Copy link
Member

Choose a reason for hiding this comment

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

+1

txResult: GetTransactionReturnType
): { program: Address; data: EventData; name: EventName }[] {
if (!txResult) return [];
Expand Down
37 changes: 34 additions & 3 deletions src/arch/svm/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BN, BorshEventCoder, Idl } from "@coral-xyz/anchor";
import web3, { address, isAddress, RpcTransport } from "@solana/kit";
import { BigNumber, isUint8Array, SvmAddress } from "../../utils";
import web3, { address, getProgramDerivedAddress, getU64Encoder, Address, isAddress, RpcTransport } from "@solana/kit";
import { BigNumber, getRelayDataHash, isUint8Array, SvmAddress } from "../../utils";

import { EventName, EventData, SVMEventNames } from "./types";
import { FillType } from "../../interfaces";
import { FillType, RelayData } from "../../interfaces";

/**
* Helper to determine if the current RPC network is devnet.
Expand Down Expand Up @@ -132,3 +133,33 @@ export function unwrapEventData(
// Return primitives as is
return data;
}

/**
* Returns the PDA for the State account.
* @param programId The SpokePool program ID.
* @param extraSeed An optional extra seed. Defaults to 0.
* @returns The PDA for the State account.
*/
export async function getStatePda(programId: Address, extraSeed = 0): Promise<Address> {
const seedEncoder = getU64Encoder();
const encodedExtraSeed = seedEncoder.encode(extraSeed);
const [statePda] = await getProgramDerivedAddress({
programAddress: programId,
seeds: ["state", encodedExtraSeed],
});
return statePda;
}

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;
}
Comment on lines +153 to +165
Copy link
Member

Choose a reason for hiding this comment

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

OOC is there a way we can use the function you created to derive the address? I.e. could this just be a wrapper over getStatePda with the specified address/extraSeed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

getStatePda and getFillStatusPda use different labels which is the first item in the 'seeds' array. We could have a function wrapping getProgramDerivedAddress that takes the seeds but I don't think there's much value on it.

45 changes: 30 additions & 15 deletions src/clients/SpokePoolClient/SVMSpokePoolClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Rpc, RpcTransport, SolanaRpcApiFromTransport } from "@solana/kit";
import winston from "winston";
import { SVMEventNames, SvmSpokeEventsClient, unwrapEventData } from "../../arch/svm";
import { Address, Rpc, SolanaRpcApiFromTransport, RpcTransport } from "@solana/kit";
import {
SvmSpokeEventsClient,
SVMEventNames,
getFillDeadline,
getTimestampForBlock,
getStatePda,
unwrapEventData,
relayFillStatus,
} from "../../arch/svm";
import { FillStatus, RelayData, SortableEvent } from "../../interfaces";
import {
BigNumber,
Expand Down Expand Up @@ -29,8 +37,10 @@ export class SvmSpokePoolClient extends SpokePoolClient {
chainId: number,
deploymentSlot: bigint, // Using slot instead of block number for SVM
eventSearchConfig: MakeOptional<EventSearchConfig, "toBlock">,
protected svmEventsClient: SvmSpokeEventsClient,
protected rpc: Rpc<SolanaRpcApiFromTransport<RpcTransport>>
public programId: Address,
protected statePda: Address,
public svmEventsClient: SvmSpokeEventsClient,
public rpc: Rpc<SolanaRpcApiFromTransport<RpcTransport>>
) {
// Convert deploymentSlot to number for base class, might need refinement
super(logger, hubPoolClient, chainId, Number(deploymentSlot), eventSearchConfig);
Expand All @@ -48,12 +58,16 @@ export class SvmSpokePoolClient extends SpokePoolClient {
rpc: Rpc<SolanaRpcApiFromTransport<RpcTransport>>
): Promise<SvmSpokePoolClient> {
const svmEventsClient = await SvmSpokeEventsClient.create(rpc);
const programId = svmEventsClient.getSvmSpokeAddress();
const statePda = await getStatePda(programId);
return new SvmSpokePoolClient(
logger,
hubPoolClient,
chainId,
deploymentSlot,
eventSearchConfig,
programId,
statePda,
svmEventsClient,
rpc
);
Expand Down Expand Up @@ -154,18 +168,18 @@ export class SvmSpokePoolClient extends SpokePoolClient {
}

/**
* Retrieves the maximum fill deadline buffer.
* TODO: Implement SVM equivalent, perhaps reading from a config account.
* Retrieves the fill deadline buffer fetched from the State PDA.
* @note This function assumes that fill deadline buffer is a constant value in svm environments.
*/
public getMaxFillDeadlineInRange(_startSlot: number, _endSlot: number): Promise<number> {
throw new Error("getMaxFillDeadlineInRange not implemented for SVM");
public override getMaxFillDeadlineInRange(_startSlot: number, _endSlot: number): Promise<number> {
return getFillDeadline(this.rpc, this.statePda);
}

/**
* Retrieves the timestamp for a given SVM slot number.
*/
public getTimestampForBlock(_blockNumber: number): Promise<number> {
throw new Error("getTimestampForBlock not implemented for SVM");
public override getTimestampForBlock(blockNumber: number): Promise<number> {
return getTimestampForBlock(this.rpc, blockNumber);
}

/**
Expand All @@ -187,12 +201,13 @@ 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 | "latest",
destinationChainId?: number
): Promise<FillStatus> {
throw new Error("relayFillStatus not implemented for SVM");
destinationChainId ??= this.chainId;
return relayFillStatus(this.programId, relayData, blockTag, destinationChainId, this.rpc, this.svmEventsClient);
}

/**
Expand Down
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 => {
Copy link
Member

Choose a reason for hiding this comment

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

OOC - the endianness of the data feels very low level for TS. How often will endianness be a consideration and an we abstract this away from the caller?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to set the endianness to replicate the relay hash calculation doing in the Solana program itself but it is already abstracted from the caller. I mean, these helper functions are intended to be used exclusively within _getRelayDataHashSvm so the caller has to provide only the relayData.

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