Skip to content

Commit

Permalink
feat(svm): svm spoke events client (#899)
Browse files Browse the repository at this point in the history
Signed-off-by: Pablo Maldonado <[email protected]>
  • Loading branch information
md0x authored Mar 5, 2025
1 parent 548ae9c commit 29cc135
Show file tree
Hide file tree
Showing 15 changed files with 1,069 additions and 51 deletions.
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@across-protocol/sdk",
"author": "UMA Team",
"version": "4.1.20",
"version": "4.1.21",
"license": "AGPL-3.0",
"homepage": "https://docs.across.to/reference/sdk",
"files": [
Expand All @@ -12,6 +12,8 @@
"node": ">=20.18.0"
},
"scripts": {
"build-bigint-buffer": "[ -d node_modules/bigint-buffer ] && command -v node-gyp > /dev/null && cd node_modules/bigint-buffer && node-gyp configure && node-gyp build || echo 'Skipping bigint-buffer build: folder or node-gyp not found'",
"postinstall": "yarn build-bigint-buffer",
"start": "yarn typechain && nodemon -e ts,tsx,json,js,jsx --watch ./src --ignore ./dist --exec 'yarn dev'",
"build": "yarn run clean && yarn typechain && yarn run build:cjs & yarn run build:esm & yarn run build:types; wait",
"dev": "yarn run build:cjs & yarn run build:esm & yarn run build:types; wait",
Expand Down Expand Up @@ -101,11 +103,13 @@
"dependencies": {
"@across-protocol/across-token": "^1.0.0",
"@across-protocol/constants": "^3.1.38",
"@across-protocol/contracts": "4.0.2",
"@across-protocol/contracts": "4.0.3",
"@coral-xyz/anchor": "^0.30.1",
"@eth-optimism/sdk": "^3.3.1",
"@ethersproject/bignumber": "^5.7.0",
"@pinata/sdk": "^2.1.0",
"@solana/web3.js": "^2.0.0",
"@solana/kit": "^2.1.0",
"@solana/web3.js": "^1.31.0",
"@types/mocha": "^10.0.1",
"@uma/sdk": "^0.34.10",
"arweave": "^1.14.4",
Expand All @@ -116,6 +120,7 @@
"ethers": "^5.7.2",
"lodash": "^4.17.21",
"lodash.get": "^4.4.2",
"node-gyp": "^11.0.0",
"superstruct": "^0.15.4",
"tslib": "^2.6.2",
"viem": "^2.21.15"
Expand Down Expand Up @@ -161,4 +166,4 @@
"[email protected]": "4.0.4",
"[email protected]": "5.0.1"
}
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * as clients from "./clients";
export * as typechain from "./typechain";
export * as caching from "./caching";
export * as providers from "./providers";
export * as svm from "./svm";
2 changes: 1 addition & 1 deletion src/providers/solana/baseRpcFactories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClusterUrl, createSolanaRpcFromTransport, RpcTransport } from "@solana/web3.js";
import { ClusterUrl, createSolanaRpcFromTransport, RpcTransport } from "@solana/kit";

// This is abstract base class for creating Solana RPC clients and transports.
export abstract class SolanaBaseRpcFactory {
Expand Down
2 changes: 1 addition & 1 deletion src/providers/solana/cachedRpcFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RpcTransport, GetTransactionApi, RpcFromTransport, SolanaRpcApiFromTransport } from "@solana/web3.js";
import { RpcTransport, GetTransactionApi, RpcFromTransport, SolanaRpcApiFromTransport } from "@solana/kit";
import { is, object, optional, string, tuple } from "superstruct";
import { CachingMechanismInterface } from "../../interfaces";
import { SolanaClusterRpcFactory } from "./baseRpcFactories";
Expand Down
2 changes: 1 addition & 1 deletion src/providers/solana/defaultRpcFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createDefaultRpcTransport, RpcTransport } from "@solana/web3.js";
import { createDefaultRpcTransport, RpcTransport } from "@solana/kit";
import { SolanaClusterRpcFactory } from "./baseRpcFactories";

// Exposes default RPC transport for Solana in the SolanaClusterRpcFactory class.
Expand Down
2 changes: 1 addition & 1 deletion src/providers/solana/rateLimitedRpcFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RpcResponse, RpcTransport } from "@solana/web3.js";
import { RpcResponse, RpcTransport } from "@solana/kit";
import { QueueObject, queue } from "async";
import winston, { Logger } from "winston";
import { SolanaClusterRpcFactory } from "./baseRpcFactories";
Expand Down
205 changes: 205 additions & 0 deletions src/svm/eventsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { getDeployedAddress, SvmSpokeIdl } from "@across-protocol/contracts";
import { getSolanaChainId } from "@across-protocol/contracts/dist/src/svm/web3-v1";
import { BorshEventCoder, utils } from "@coral-xyz/anchor";
import web3, {
Address,
Commitment,
GetSignaturesForAddressApi,
GetTransactionApi,
RpcTransport,
Signature,
} from "@solana/kit";
import { EventData, EventName, EventWithData } from "./types";
import { getEventName, parseEventData } from "./utils/events";
import { isDevnet } from "./utils/helpers";

// Utility type to extract the return type for the JSON encoding overload. We only care about the overload where the
// configuration parameter (C) has the optional property 'encoding' set to 'json'.
type ExtractJsonOverload<T> = T extends (signature: infer _S, config: infer C) => infer R
? C extends { encoding?: "json" }
? R
: never
: never;

type GetTransactionReturnType = ExtractJsonOverload<GetTransactionApi["getTransaction"]>;
type GetSignaturesForAddressConfig = Parameters<GetSignaturesForAddressApi["getSignaturesForAddress"]>[1];
type GetSignaturesForAddressTransaction = ReturnType<GetSignaturesForAddressApi["getSignaturesForAddress"]>[number];
type GetSignaturesForAddressApiResponse = readonly GetSignaturesForAddressTransaction[];

export class SvmSpokeEventsClient {
private rpc: web3.Rpc<web3.SolanaRpcApiFromTransport<RpcTransport>>;
private svmSpokeAddress: Address;
private svmSpokeEventAuthority: Address;

/**
* Private constructor. Use the async create() method to instantiate.
*/
private constructor(
rpc: web3.Rpc<web3.SolanaRpcApiFromTransport<RpcTransport>>,
svmSpokeAddress: Address,
eventAuthority: Address
) {
this.rpc = rpc;
this.svmSpokeAddress = svmSpokeAddress;
this.svmSpokeEventAuthority = eventAuthority;
}

/**
* Factory method to asynchronously create an instance of SvmSpokeEventsClient.
*/
public static async create(
rpc: web3.Rpc<web3.SolanaRpcApiFromTransport<RpcTransport>>
): Promise<SvmSpokeEventsClient> {
const isTestnet = await isDevnet(rpc);
const programId = getDeployedAddress("SvmSpoke", getSolanaChainId(isTestnet ? "devnet" : "mainnet").toString());
if (!programId) throw new Error("Program not found");
const svmSpokeAddress = web3.address(programId);
const [svmSpokeEventAuthority] = await web3.getProgramDerivedAddress({
programAddress: svmSpokeAddress,
seeds: ["__event_authority"],
});
return new SvmSpokeEventsClient(rpc, svmSpokeAddress, svmSpokeEventAuthority);
}

/**
* Queries events for the SvmSpoke program filtered by event name.
*
* @param eventName - The name of the event to filter by.
* @param fromSlot - Optional starting slot.
* @param toSlot - Optional ending slot.
* @param options - Options for fetching signatures.
* @returns A promise that resolves to an array of events matching the eventName.
*/
public async queryEvents<T extends EventData>(
eventName: EventName,
fromSlot?: bigint,
toSlot?: bigint,
options: GetSignaturesForAddressConfig = { limit: 1000, commitment: "confirmed" }
): Promise<EventWithData<T>[]> {
const events = await this.queryAllEvents(fromSlot, toSlot, options);
return events.filter((event) => event.name === eventName) as EventWithData<T>[];
}

/**
* Queries all events for a specific program.
*
* @param fromSlot - Optional starting slot.
* @param toSlot - Optional ending slot.
* @param options - Options for fetching signatures.
* @returns A promise that resolves to an array of all events with additional metadata.
*/
private async queryAllEvents(
fromSlot?: bigint,
toSlot?: bigint,
options: GetSignaturesForAddressConfig = { limit: 1000, commitment: "confirmed" }
): Promise<EventWithData<EventData>[]> {
const allSignatures: GetSignaturesForAddressTransaction[] = [];
let hasMoreSignatures = true;
let currentOptions = options;

while (hasMoreSignatures) {
const signatures: GetSignaturesForAddressApiResponse = await this.rpc
.getSignaturesForAddress(this.svmSpokeAddress, currentOptions)
.send();
// Signatures are sorted by slot in descending order.
allSignatures.push(...signatures);

// Update options for the next batch. Set "before" to the last fetched signature.
if (signatures.length > 0) {
currentOptions = { ...currentOptions, before: signatures[signatures.length - 1].signature };
}

if (fromSlot && allSignatures.length > 0 && allSignatures[allSignatures.length - 1].slot < fromSlot) {
hasMoreSignatures = false;
}

hasMoreSignatures = Boolean(
hasMoreSignatures && currentOptions.limit && signatures.length === currentOptions.limit
);
}

const filteredSignatures = allSignatures.filter((signatureTransaction) => {
if (fromSlot && signatureTransaction.slot < fromSlot) return false;
if (toSlot && signatureTransaction.slot > toSlot) return false;
return true;
});

// Fetch events for all signatures in parallel.
const eventsWithSlots = await Promise.all(
filteredSignatures.map(async (signatureTransaction) => {
const events = await this.readEventsFromSignature(signatureTransaction.signature, options.commitment);
return events.map((event) => ({
...event,
confirmationStatus: signatureTransaction.confirmationStatus,
blockTime: signatureTransaction.blockTime,
signature: signatureTransaction.signature,
slot: signatureTransaction.slot,
}));
})
);
return eventsWithSlots.flat();
}

/**
* Reads events from a transaction signature.
*
* @param txSignature - The transaction signature.
* @param commitment - Commitment level.
* @returns A promise that resolves to an array of events.
*/
private async readEventsFromSignature(txSignature: Signature, commitment: Commitment = "confirmed") {
const txResult = await this.rpc
.getTransaction(txSignature, { commitment, maxSupportedTransactionVersion: 0 })
.send();

if (txResult === null) return [];
return this.processEventFromTx(txResult);
}

/**
* Processes events from a transaction.
*
* @param txResult - The transaction result.
* @returns A promise that resolves to an array of events with their data and name.
*/
private processEventFromTx(
txResult: GetTransactionReturnType
): { program: Address; data: EventData; name: EventName }[] {
if (!txResult) return [];
const events: { program: Address; data: EventData; name: EventName }[] = [];

const accountKeys = txResult.transaction.message.accountKeys;
const messageAccountKeys = [...accountKeys];
// Writable accounts come first, then readonly.
// See https://docs.anza.xyz/proposals/versioned-transactions#new-transaction-format
messageAccountKeys.push(...(txResult?.meta?.loadedAddresses?.writable ?? []));
messageAccountKeys.push(...(txResult?.meta?.loadedAddresses?.readonly ?? []));

for (const ixBlock of txResult.meta?.innerInstructions ?? []) {
for (const ix of ixBlock.instructions) {
const ixProgramId = messageAccountKeys[ix.programIdIndex];
const singleIxAccount = ix.accounts.length === 1 ? messageAccountKeys[ix.accounts[0]] : undefined;
if (
ixProgramId !== undefined &&
singleIxAccount !== undefined &&
this.svmSpokeAddress === ixProgramId &&
this.svmSpokeEventAuthority === singleIxAccount
) {
const ixData = utils.bytes.bs58.decode(ix.data);
// Skip the first 8 bytes (assumed header) and encode the rest.
const eventData = utils.bytes.base64.encode(Buffer.from(new Uint8Array(ixData).slice(8)));
const event = new BorshEventCoder(SvmSpokeIdl).decode(eventData);
if (!event?.name) throw new Error("Event name is undefined");
const name = getEventName(event.name);
events.push({
program: this.svmSpokeAddress,
data: parseEventData(event?.data),
name,
});
}
}
}

return events;
}
}
1 change: 1 addition & 0 deletions src/svm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./eventsClient";
47 changes: 47 additions & 0 deletions src/svm/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Signature, Address, UnixTimestamp } from "@solana/kit";
import { SvmSpokeClient } from "@across-protocol/contracts";

export type EventData =
| SvmSpokeClient.BridgedToHubPool
| SvmSpokeClient.TokensBridged
| SvmSpokeClient.ExecutedRelayerRefundRoot
| SvmSpokeClient.RelayedRootBundle
| SvmSpokeClient.PausedDeposits
| SvmSpokeClient.PausedFills
| SvmSpokeClient.SetXDomainAdmin
| SvmSpokeClient.EnabledDepositRoute
| SvmSpokeClient.FilledRelay
| SvmSpokeClient.FundsDeposited
| SvmSpokeClient.EmergencyDeletedRootBundle
| SvmSpokeClient.RequestedSlowFill
| SvmSpokeClient.ClaimedRelayerRefund
| SvmSpokeClient.TransferredOwnership;

export enum SVMEventNames {
FilledRelay = "FilledRelay",
FundsDeposited = "FundsDeposited",
EnabledDepositRoute = "EnabledDepositRoute",
RelayedRootBundle = "RelayedRootBundle",
ExecutedRelayerRefundRoot = "ExecutedRelayerRefundRoot",
BridgedToHubPool = "BridgedToHubPool",
PausedDeposits = "PausedDeposits",
PausedFills = "PausedFills",
SetXDomainAdmin = "SetXDomainAdmin",
EmergencyDeletedRootBundle = "EmergencyDeletedRootBundle",
RequestedSlowFill = "RequestedSlowFill",
ClaimedRelayerRefund = "ClaimedRelayerRefund",
TokensBridged = "TokensBridged",
TransferredOwnership = "TransferredOwnership",
}

export type EventName = keyof typeof SVMEventNames;

export type EventWithData<T extends EventData> = {
confirmationStatus: string | null;
blockTime: UnixTimestamp | null;
signature: Signature;
slot: bigint;
name: EventName;
data: T;
program: Address;
};
46 changes: 46 additions & 0 deletions src/svm/utils/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { BN } from "@coral-xyz/anchor";
import web3 from "@solana/kit";
import { EventName, SVMEventNames } from "../types";

/**
* Parses event data from a transaction.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseEventData(eventData: any): any {
if (!eventData) return eventData;

if (Array.isArray(eventData)) {
return eventData.map(parseEventData);
}

if (typeof eventData === "object") {
if (eventData.constructor.name === "PublicKey") {
return web3.address(eventData.toString());
}
if (BN.isBN(eventData)) {
return BigInt(eventData.toString());
}

// Convert each key from snake_case to camelCase and process the value recursively.
return Object.fromEntries(
Object.entries(eventData).map(([key, value]) => [snakeToCamel(key), parseEventData(value)])
);
}

return eventData;
}

/**
* Converts a snake_case string to camelCase.
*/
function snakeToCamel(s: string): string {
return s.replace(/(_\w)/g, (match) => match[1].toUpperCase());
}

/**
* Gets the event name from a raw name.
*/
export function getEventName(rawName: string): EventName {
if (Object.values(SVMEventNames).some((name) => rawName.includes(name))) return rawName as EventName;
throw new Error(`Unknown event name: ${rawName}`);
}
9 changes: 9 additions & 0 deletions src/svm/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import web3, { RpcTransport } from "@solana/kit";

/**
* Helper to determine if the current RPC network is devnet.
*/
export async function isDevnet(rpc: web3.Rpc<web3.SolanaRpcApiFromTransport<RpcTransport>>): Promise<boolean> {
const genesisHash = await rpc.getGenesisHash().send();
return genesisHash === "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG";
}
2 changes: 1 addition & 1 deletion test/SolanaCachedProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { signature, Commitment, Rpc, SolanaRpcApiFromTransport, RpcTransport } from "@solana/web3.js";
import { signature, Commitment, Rpc, SolanaRpcApiFromTransport, RpcTransport } from "@solana/kit";
import bs58 from "bs58";
import { createHash } from "crypto";
import winston from "winston";
Expand Down
Loading

0 comments on commit 29cc135

Please sign in to comment.