diff --git a/.eslintignore b/.eslintignore index f319ba238..82dc1645d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,5 @@ scripts/build-bigint-buffer.js + +# Auto-generated SVM artifacts +src/svm/assets +src/svm/clients diff --git a/.gitignore b/.gitignore index c531f7c57..116c5b1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ dist src/utils/abi/contracts/*.json src/utils/abi/typechain +# Auto-generated SVM artifacts (synced from contracts repo via yarn sync-svm-clients) +src/svm/assets +src/svm/clients/* +!src/svm/clients/index.ts + .ledger /target *.tar.gz diff --git a/package.json b/package.json index 1688f4255..84674b363 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@across-protocol/sdk", "author": "UMA Team", - "version": "4.3.136", + "version": "4.4.0-alpha.5", "license": "AGPL-3.0", "homepage": "https://docs.across.to/reference/sdk", "repository": { @@ -22,11 +22,15 @@ "build-bigint-buffer": "node scripts/build-bigint-buffer.js", "postinstall": "node scripts/build-bigint-buffer.js", "start": "yarn typechain && nodemon -e ts,tsx,json,js,jsx --watch ./src --ignore ./dist --exec 'yarn dev'", - "build": "yarn run clean && yarn typechain && yarn dev", + "build": "yarn run clean && yarn typechain && yarn generate-svm-artifacts && yarn dev", + "generate-svm-artifacts": "bash scripts/svm/generateSvmAssets.sh && yarn generate-svm-clients", + "generate-svm-clients": "ts-node scripts/svm/generateSvmClients.ts && ts-node scripts/svm/renameClientsImports.ts", "dev": "concurrently --kill-others-on-fail --names 'cjs,esm,types' --prefix-colors 'blue,magenta,green' 'yarn run build:cjs' 'yarn run build:esm' 'yarn run build:types'", - "build:cjs": "tsc --project tsconfig.build.json --module commonjs --outDir ./dist/cjs --removeComments --verbatimModuleSyntax false && echo > ./dist/cjs/package.json '{\"type\":\"commonjs\"}'", - "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir ./dist/esm && echo > ./dist/esm/package.json '{\"type\":\"module\",\"sideEffects\":false}'", + "build:cjs": "tsc --project tsconfig.build.json --module commonjs --outDir ./dist/cjs --removeComments --verbatimModuleSyntax false && echo > ./dist/cjs/package.json '{\"type\":\"commonjs\"}' && yarn export-svm-idl:cjs", + "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir ./dist/esm && echo > ./dist/esm/package.json '{\"type\":\"module\",\"sideEffects\":false}' && yarn export-svm-idl:esm", "build:types": "tsc --project tsconfig.build.json --module esnext --declarationDir ./dist/types --emitDeclarationOnly --declaration --declarationMap", + "export-svm-idl:cjs": "cp src/svm/assets/idl/*.json dist/cjs/src/svm/assets/idl/", + "export-svm-idl:esm": "cp src/svm/assets/idl/*.json dist/esm/src/svm/assets/idl/", "test": "hardhat test", "test:watch": "hardhat watch test", "test:run:arweave": "npx -y arlocal", @@ -59,13 +63,15 @@ } ], "devDependencies": { + "@codama/nodes-from-anchor": "^1.2.2", + "@codama/renderers-js": "^1.3.0", + "codama": "^1.3.0", "@defi-wonderland/smock": "^2.4.0", "@nomicfoundation/hardhat-chai-matchers": "^1.0.6", "@nomiclabs/hardhat-ethers": "^2.2.1", "@nomiclabs/hardhat-etherscan": "^3.1.7", "@openzeppelin/hardhat-upgrades": "^1.28.0", "@size-limit/preset-small-lib": "^7.0.8", - "@solana/web3.js": "^1.31.0", "@typechain/ethers-v5": "^11.1.1", "@typechain/hardhat": "^6.1.6", "@types/async": "^3.2.24", @@ -113,7 +119,8 @@ "dependencies": { "@across-protocol/constants": "^3.1.100", "@across-protocol/contracts": "5.0.4", - "@coral-xyz/anchor": "^0.30.1", + "@coral-xyz/anchor": "^0.31.1", + "@coral-xyz/borsh": "^0.31.1", "@eth-optimism/sdk": "^3.3.1", "@ethersproject/bignumber": "^5.7.0", "@nktkas/hyperliquid": "^0.25.9", @@ -123,9 +130,14 @@ "@solana-program/token": "^0.9.0", "@solana-program/token-2022": "^0.6.1", "@solana/kit": "^5.4.0", + "@solana/spl-token": "^0.4.14", "@solana/sysvars": "^5.4.0", + "@solana/web3.js": "^1.98.2", + "@solana-program/address-lookup-table": "^0.10.0", "@uma/contracts-node": "^0.4.0", "arweave": "^1.14.4", + "borsh": "0.7.0", + "buffer-layout": "^1.2.2", "async": "^3.2.5", "axios": "^0.27.2", "bs58": "^6.0.0", @@ -168,6 +180,11 @@ "import": "./dist/esm/typechain.js", "types": "./dist/types/typechain.d.ts" }, + "./svm": { + "require": "./dist/cjs/src/svm/index.js", + "import": "./dist/esm/src/svm/index.js", + "types": "./dist/types/src/svm/index.d.ts" + }, "./src/utils/abi/typechain": { "require": "./dist/cjs/src/utils/abi/typechain/index.js", "import": "./dist/esm/src/utils/abi/typechain/index.js", diff --git a/scripts/svm/generateSvmAssets.sh b/scripts/svm/generateSvmAssets.sh new file mode 100755 index 000000000..de0f85b28 --- /dev/null +++ b/scripts/svm/generateSvmAssets.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Generates SVM assets (IDL index + Anchor type re-exports) from @across-protocol/contracts. +# The SDK generates its own artifacts from the installed contracts package. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" +CONTRACTS_PKG="$SDK_ROOT/node_modules/@across-protocol/contracts" + +SVM_ASSETS="$SDK_ROOT/src/svm/assets" +SVM_IDL="$SVM_ASSETS/idl" +IDL_OUTPUT_FILE="$SVM_IDL/index.ts" +TYPES_OUTPUT_FILE="$SVM_ASSETS/index.ts" + +# Source IDL and types from installed contracts package +SOURCE_IDL="$CONTRACTS_PKG/dist/src/svm/assets/idl" +# Anchor types live in dist/src/svm/assets/ (v5+) or dist/target/types/ (v4) +SOURCE_TYPES="$CONTRACTS_PKG/dist/src/svm/assets" +if [ ! -d "$SOURCE_TYPES" ] || [ "$(ls "$SOURCE_TYPES"/*.d.ts 2>/dev/null | grep -v index | wc -l)" -eq 0 ]; then + SOURCE_TYPES="$CONTRACTS_PKG/dist/target/types" +fi + +if [ ! -d "$SOURCE_IDL" ]; then + echo "Error: IDL files not found at $SOURCE_IDL" + echo "Make sure @across-protocol/contracts is installed." + exit 1 +fi + +# Ensure destination directories exist +mkdir -p "$SVM_IDL" +mkdir -p "$SVM_ASSETS" + +# --- Copy IDL JSON files --- +echo "Copying IDL JSON files from contracts package..." +cp "$SOURCE_IDL"/*.json "$SVM_IDL/" + +# --- Generate IDL index.ts --- +echo "Generating IDL index.ts..." +> "$IDL_OUTPUT_FILE" + +{ + echo "// This file has been autogenerated. Do not edit manually." + echo "// Generated by scripts/svm/generateSvmAssets.sh" + echo +} >> "$IDL_OUTPUT_FILE" + +IMPORTS="" +EXPORTS="" + +for file in "$SVM_IDL"/*.json; do + filename=$(basename -- "$file") + name="${filename%.json}" + camelCaseName=$(echo "$name" | awk -F'_' '{ + for (i=1; i<=NF; i++) { + printf toupper(substr($i,1,1)) tolower(substr($i,2)); + } + }') + IMPORTS="${IMPORTS}const ${camelCaseName}Idl = require(\"./${filename}\");\n" + EXPORTS="${EXPORTS} ${camelCaseName}Idl,\n" +done + +printf "$IMPORTS" >> "$IDL_OUTPUT_FILE" + +{ + echo "export {" + printf "$EXPORTS" | sed '$ s/,$//' + echo "};" +} >> "$IDL_OUTPUT_FILE" + +echo "IDL index.ts generated at $IDL_OUTPUT_FILE" + +# --- Generate assets index.ts (Anchor type re-exports) --- +echo "Generating assets index.ts..." +> "$TYPES_OUTPUT_FILE" + +{ + echo "// This file has been autogenerated. Do not edit manually." + echo "// Generated by scripts/svm/generateSvmAssets.sh" + echo + echo "export * from \"./idl\";" +} >> "$TYPES_OUTPUT_FILE" + +# Determine the import path prefix based on which source directory we're using +if echo "$SOURCE_TYPES" | grep -q "dist/src/svm/assets"; then + IMPORT_PREFIX="@across-protocol/contracts/dist/src/svm/assets" +else + IMPORT_PREFIX="@across-protocol/contracts/dist/target/types" +fi + +# Re-export Anchor types from the contracts package +for file in "$SOURCE_TYPES"/*.d.ts; do + filename=$(basename -- "$file") + [ "$filename" = "index.d.ts" ] && continue + name="${filename%.d.ts}" + camelCaseName=$(echo "$name" | awk -F'_' '{ + for (i=1; i<=NF; i++) { + printf toupper(substr($i,1,1)) tolower(substr($i,2)); + } + }') + newName="${camelCaseName}Anchor" + echo "export {${camelCaseName} as ${newName}} from \"${IMPORT_PREFIX}/${name}\";" >> "$TYPES_OUTPUT_FILE" +done + +echo "Assets index.ts generated at $TYPES_OUTPUT_FILE" +echo "Done generating SVM assets." diff --git a/scripts/svm/generateSvmClients.ts b/scripts/svm/generateSvmClients.ts new file mode 100644 index 000000000..ae5bf8df8 --- /dev/null +++ b/scripts/svm/generateSvmClients.ts @@ -0,0 +1,47 @@ +/** + * Generates Codama TypeScript clients from IDL files. + * Reads IDL assets from the locally generated src/svm/assets/idl/ directory + * (populated by generateSvmAssets.sh). + * + * This is the SDK's own generation script, equivalent to the one in the contracts repo. + */ +import { createFromRoot } from "codama"; +import { rootNodeFromAnchor, AnchorIdl } from "@codama/nodes-from-anchor"; +import { renderVisitor as renderJavaScriptVisitor } from "@codama/renderers-js"; +import path from "path"; + +// Read IDL files directly from the generated assets +const idlPath = path.join(__dirname, "..", "..", "src", "svm", "assets", "idl"); +const clientsPath = path.join(__dirname, "..", "..", "src", "svm", "clients"); + +const SvmSpokeIdl = require(path.join(idlPath, "svm_spoke.json")); +const MulticallHandlerIdl = require(path.join(idlPath, "multicall_handler.json")); +const MessageTransmitterIdl = require(path.join(idlPath, "message_transmitter.json")); +const TokenMessengerMinterIdl = require(path.join(idlPath, "token_messenger_minter.json")); +const MessageTransmitterV2Idl = require(path.join(idlPath, "message_transmitter_v2.json")); +const TokenMessengerMinterV2Idl = require(path.join(idlPath, "token_messenger_minter_v2.json")); +const SponsoredCctpSrcPeripheryIdl = require(path.join(idlPath, "sponsored_cctp_src_periphery.json")); + +// Generate clients for each program +let codama = createFromRoot(rootNodeFromAnchor(SvmSpokeIdl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "SvmSpoke"))); + +codama = createFromRoot(rootNodeFromAnchor(MulticallHandlerIdl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "MulticallHandler"))); + +codama = createFromRoot(rootNodeFromAnchor(MessageTransmitterIdl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "MessageTransmitter"))); + +codama = createFromRoot(rootNodeFromAnchor(TokenMessengerMinterIdl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "TokenMessengerMinter"))); + +codama = createFromRoot(rootNodeFromAnchor(MessageTransmitterV2Idl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "MessageTransmitterV2"))); + +codama = createFromRoot(rootNodeFromAnchor(TokenMessengerMinterV2Idl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "TokenMessengerMinterV2"))); + +codama = createFromRoot(rootNodeFromAnchor(SponsoredCctpSrcPeripheryIdl as AnchorIdl)); +codama.accept(renderJavaScriptVisitor(path.join(clientsPath, "SponsoredCctpSrcPeriphery"))); + +console.log("Codama clients generated at", clientsPath); diff --git a/scripts/svm/renameClientsImports.ts b/scripts/svm/renameClientsImports.ts new file mode 100644 index 000000000..0b415682f --- /dev/null +++ b/scripts/svm/renameClientsImports.ts @@ -0,0 +1,27 @@ +/** + * Post-processes generated Codama clients to replace @solana/web3.js imports with @solana/kit. + * This is needed because Codama generates imports for @solana/web3.js by default. + */ +const fs = require("fs"); +const path = require("path"); + +const clientsPath = path.join(__dirname, "..", "..", "src", "svm", "clients"); + +function replaceInFiles(dir: string): void { + const files = fs.readdirSync(dir); + files.forEach((file: string) => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + replaceInFiles(filePath); + } else if (file.endsWith(".ts")) { + const fileContent = fs.readFileSync(filePath, "utf8"); + const updatedContent = fileContent.replace("@solana/web3.js", "@solana/kit"); + fs.writeFileSync(filePath, updatedContent); + } + }); +} + +replaceInFiles(clientsPath); +console.log("Client imports renamed (@solana/web3.js -> @solana/kit)"); diff --git a/src/arch/svm/SpokeUtils.ts b/src/arch/svm/SpokeUtils.ts index 7c2ec6dd3..31bb4e23d 100644 --- a/src/arch/svm/SpokeUtils.ts +++ b/src/arch/svm/SpokeUtils.ts @@ -1,6 +1,5 @@ -import { MessageTransmitterClient, SvmSpokeClient, TokenMessengerMinterClient } from "@across-protocol/contracts"; -import { decodeFillStatusAccount, fetchState } from "@across-protocol/contracts/dist/src/svm/clients/SvmSpoke"; -import { decodeMessageHeader } from "@across-protocol/contracts/dist/src/svm/web3-v1"; +import { MessageTransmitterClient, SvmSpokeClient, TokenMessengerMinterClient } from "../../svm"; +import { decodeMessageHeader } from "../../svm/web3-v1/cctpHelpers"; import { SYSTEM_PROGRAM_ADDRESS } from "@solana-program/system"; import { ASSOCIATED_TOKEN_PROGRAM_ADDRESS, @@ -210,7 +209,7 @@ async function _callGetTimestampForSlotWithRetry( * @returns fill deadline buffer */ export async function getFillDeadline(provider: SVMProvider, statePda: Address): Promise { - const state = await fetchState(provider, statePda); + const state = await SvmSpokeClient.fetchState(provider, statePda); return state.data.fillDeadlineBuffer; } @@ -341,7 +340,7 @@ export async function relayFillStatus( // If the PDA exists, return the stored fill status if (fillStatusAccount.exists) { - const decodedAccountData = decodeFillStatusAccount(fillStatusAccount); + const decodedAccountData = SvmSpokeClient.decodeFillStatusAccount(fillStatusAccount); return decodedAccountData.data.status; } // If the PDA doesn't exist and the deadline hasn't passed yet, the deposit must be unfilled, @@ -1068,7 +1067,7 @@ async function fetchBatchFillStatusFromPdaAccounts( const fillStatuses = pdaAccounts.flat().map((account, index) => { // If the PDA exists, we can fetch the status directly. if (account.exists) { - const decodedAccount = decodeFillStatusAccount(account); + const decodedAccount = SvmSpokeClient.decodeFillStatusAccount(account); return decodedAccount.data.status; } diff --git a/src/arch/svm/eventsClient.ts b/src/arch/svm/eventsClient.ts index 8d856918c..865651c3f 100644 --- a/src/arch/svm/eventsClient.ts +++ b/src/arch/svm/eventsClient.ts @@ -1,6 +1,7 @@ import { Idl } from "@coral-xyz/anchor"; -import { getDeployedAddress, SvmSpokeIdl } from "@across-protocol/contracts"; -import { getSolanaChainId } from "@across-protocol/contracts/dist/src/svm/web3-v1"; +import { getDeployedAddress } from "@across-protocol/contracts"; +import { SvmSpokeIdl } from "../../svm"; +import { getSolanaChainId } from "../../svm/web3-v1/helpers"; import { address, Address, diff --git a/src/arch/svm/types.ts b/src/arch/svm/types.ts index ea1e2c57c..5d1f95731 100644 --- a/src/arch/svm/types.ts +++ b/src/arch/svm/types.ts @@ -1,4 +1,4 @@ -import { SvmSpokeClient } from "@across-protocol/contracts"; +import { SvmSpokeClient } from "../../svm"; import { Address, Rpc, diff --git a/src/arch/svm/utils.ts b/src/arch/svm/utils.ts index 54eacebc2..f5821202a 100644 --- a/src/arch/svm/utils.ts +++ b/src/arch/svm/utils.ts @@ -1,4 +1,4 @@ -import { MessageTransmitterClient, SvmSpokeClient } from "@across-protocol/contracts"; +import { MessageTransmitterClient, SvmSpokeClient } from "../../svm"; import { SpokePool__factory } from "../../typechain"; import { BN, BorshEventCoder, Idl } from "@coral-xyz/anchor"; import { diff --git a/src/clients/mocks/MockSvmCpiEventsClient.ts b/src/clients/mocks/MockSvmCpiEventsClient.ts index 3208c7af2..fd5b6abb5 100644 --- a/src/clients/mocks/MockSvmCpiEventsClient.ts +++ b/src/clients/mocks/MockSvmCpiEventsClient.ts @@ -5,7 +5,7 @@ import { hexlify, arrayify, hexZeroPad } from "ethers/lib/utils"; import { random } from "lodash"; import { Address, UnixTimestamp, signature } from "@solana/kit"; import { Idl } from "@coral-xyz/anchor"; -import { SvmSpokeClient } from "@across-protocol/contracts"; +import { SvmSpokeClient } from "../../svm"; import { CHAIN_IDs } from "@across-protocol/constants"; import { MockSolanaRpcFactory } from "../../providers/mocks"; diff --git a/src/clients/mocks/MockSvmSpokePoolClient.ts b/src/clients/mocks/MockSvmSpokePoolClient.ts index 30fa64de0..c3eada6ed 100644 --- a/src/clients/mocks/MockSvmSpokePoolClient.ts +++ b/src/clients/mocks/MockSvmSpokePoolClient.ts @@ -1,5 +1,5 @@ import winston from "winston"; -import { SvmSpokeClient } from "@across-protocol/contracts"; +import { SvmSpokeClient } from "../../svm"; import { Address } from "@solana/kit"; import { DepositWithBlock, RelayerRefundExecution, SortableEvent, SlowFillLeaf, Log } from "../../interfaces"; import { diff --git a/src/svm/clients/index.ts b/src/svm/clients/index.ts new file mode 100644 index 000000000..e5857a0c7 --- /dev/null +++ b/src/svm/clients/index.ts @@ -0,0 +1,17 @@ +import * as MulticallHandlerClient from "./MulticallHandler"; +import * as SvmSpokeClient from "./SvmSpoke"; +import * as MessageTransmitterClient from "./MessageTransmitter"; +import * as TokenMessengerMinterClient from "./TokenMessengerMinter"; +import * as MessageTransmitterV2Client from "./MessageTransmitterV2"; +import * as TokenMessengerMinterV2Client from "./TokenMessengerMinterV2"; +import * as SponsoredCctpSrcPeripheryClient from "./SponsoredCctpSrcPeriphery"; + +export { + MulticallHandlerClient, + SvmSpokeClient, + MessageTransmitterClient, + TokenMessengerMinterClient, + MessageTransmitterV2Client, + TokenMessengerMinterV2Client, + SponsoredCctpSrcPeripheryClient, +}; diff --git a/src/svm/index.ts b/src/svm/index.ts new file mode 100644 index 000000000..3a0c89849 --- /dev/null +++ b/src/svm/index.ts @@ -0,0 +1,30 @@ +// SVM (Solana) utilities — moved from @across-protocol/contracts +// See ACP-56 for context on this migration. + +// Re-export SVM types +export * from "./types.svm"; + +// web3-v1 (Anchor/web3.js v1) — all utils +export * from "./web3-v1"; + +// web3-v2 (Solana Kit) — re-export non-conflicting names directly, +// alias conflicting ones with V2 suffix to avoid name collision with web3-v1. +export { + readFillEventFromFillStatusPda, + signAndSendTransaction, + createDefaultTransaction, + createLookupTable, + extendLookupTable, +} from "./web3-v2"; +export type { RpcClient } from "./web3-v2"; +export { + readProgramEvents as readProgramEventsV2, + readEvents as readEventsV2, + sendTransactionWithLookupTable as sendTransactionWithLookupTableV2, +} from "./web3-v2"; + +// Auto-generated assets (IDL definitions and Anchor types) +export * from "./assets"; + +// Auto-generated Codama clients +export * from "./clients"; diff --git a/src/svm/types.svm.ts b/src/svm/types.svm.ts new file mode 100644 index 000000000..4d2610e0b --- /dev/null +++ b/src/svm/types.svm.ts @@ -0,0 +1,139 @@ +import { BN } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { BigNumber } from "ethers"; + +/** + * Relayer Refund Interfaces + */ +export interface RelayerRefundLeaf { + isSolana: boolean; + amountToReturn: BigNumber; + chainId: BigNumber; + refundAmounts: BigNumber[]; + leafId: BigNumber; + l2TokenAddress: string; + refundAddresses: string[]; +} + +export interface RelayerRefundLeafSolana { + isSolana: boolean; + amountToReturn: BN; + chainId: BN; + refundAmounts: BN[]; + leafId: BN; + mintPublicKey: PublicKey; + refundAddresses: PublicKey[]; +} + +export type RelayerRefundLeafType = RelayerRefundLeaf | RelayerRefundLeafSolana; + +/** + * Slow Fill Leaf Interface + */ +export interface SlowFillLeaf { + relayData: RelayData; + chainId: BN; + updatedOutputAmount: BN; +} + +/** + * Relay Data Interface + */ +export type RelayData = { + depositor: PublicKey; + recipient: PublicKey; + exclusiveRelayer: PublicKey; + inputToken: PublicKey; + outputToken: PublicKey; + inputAmount: number[]; + outputAmount: BN; + originChainId: BN; + depositId: number[]; + fillDeadline: number; + exclusivityDeadline: number; + message: Buffer; +}; + +/** + * Deposit Data Interfaces + */ +export interface DepositData { + depositor: PublicKey | null; + recipient: PublicKey; + inputToken: PublicKey | null; + outputToken: PublicKey; + inputAmount: BN; + outputAmount: number[]; + destinationChainId: BN; + exclusiveRelayer: PublicKey; + quoteTimestamp: BN; + fillDeadline: BN; + exclusivityParameter: BN; + message: Buffer; +} + +export type DepositDataValues = [ + PublicKey, + PublicKey, + PublicKey, + PublicKey, + BN, + number[], + BN, + PublicKey, + number, + number, + number, + Buffer, +]; + +/** + * Fill Data Interfaces + */ +export type FillDataValues = [number[], RelayData, BN, PublicKey]; + +export type FillDataParams = [number[], RelayData | null, BN | null, PublicKey | null]; + +/** + * Request Slow Fill Data Interfaces + */ +export type RequestSlowFillDataValues = [number[], RelayData]; + +export type RequestSlowFillDataParams = [number[], RelayData | null]; + +/** + * Execute Slow Relay Leaf Data Interfaces + */ +export type ExecuteSlowRelayLeafDataValues = [number[], SlowFillLeaf, number, number[][]]; + +export type ExecuteSlowRelayLeafDataParams = [number[], SlowFillLeaf | null, number | null, number[][] | null]; + +/** + * Across+ Message Interface + */ +export type AcrossPlusMessage = { + handler: PublicKey; + readOnlyLen: number; + valueAmount: BN; + accounts: PublicKey[]; + handlerMessage: Buffer; +}; + +/** + * Event Type Interface + */ +export interface EventType { + program: PublicKey; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + name: string; + slot: number; + confirmationStatus: string; + blockTime: number; + signature: string; +} + +/** + * Supported Networks + */ +export type SupportedNetworks = "mainnet" | "devnet"; diff --git a/src/svm/web3-v1/buffer-layout.d.ts b/src/svm/web3-v1/buffer-layout.d.ts new file mode 100644 index 000000000..6774143e0 --- /dev/null +++ b/src/svm/web3-v1/buffer-layout.d.ts @@ -0,0 +1,11 @@ +declare module "buffer-layout" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export class Layout { + span: number; + property?: string; + decode(b: Buffer, offset?: number): T; + encode(src: T, b: Buffer, offset?: number): number; + getSpan(b?: Buffer, offset?: number): number; + replicate(name: string): this; + } +} diff --git a/src/svm/web3-v1/cctpHelpers.ts b/src/svm/web3-v1/cctpHelpers.ts new file mode 100644 index 000000000..d99d14b87 --- /dev/null +++ b/src/svm/web3-v1/cctpHelpers.ts @@ -0,0 +1,183 @@ +import * as anchor from "@coral-xyz/anchor"; +import { array, object, optional, string, Struct } from "superstruct"; +import { readUInt256BE } from "./relayHashUtils"; + +// Index positions to decode Message Header from +// https://developers.circle.com/cctp/v1/message-format#cctp-v1-message-header +const HEADER_VERSION_INDEX = 0; +const SOURCE_DOMAIN_INDEX = 4; +const DESTINATION_DOMAIN_INDEX = 8; +const NONCE_INDEX = 12; +const HEADER_SENDER_INDEX = 20; +const HEADER_RECIPIENT_INDEX = 52; +const DESTINATION_CALLER_INDEX = 84; +const MESSAGE_BODY_INDEX = 116; + +// Index positions to decode Message Body for TokenMessenger from +// https://developers.circle.com/cctp/v1/message-format#cctp-v1-message-body +const BODY_VERSION_INDEX = 0; +const BURN_TOKEN_INDEX = 4; +const MINT_RECIPIENT_INDEX = 36; +const AMOUNT_INDEX = 68; +const MESSAGE_SENDER_INDEX = 100; + +/** + * Type for the body of a TokenMessenger message. + */ +export type TokenMessengerMessageBody = { + version: number; + burnToken: anchor.web3.PublicKey; + mintRecipient: anchor.web3.PublicKey; + amount: bigint; + messageSender: anchor.web3.PublicKey; +}; + +/** + * Type for the header of a CCTP message. + */ +export type MessageHeader = { + version: number; + sourceDomain: number; + destinationDomain: number; + nonce: bigint; + sender: anchor.web3.PublicKey; + recipient: anchor.web3.PublicKey; + destinationCaller: anchor.web3.PublicKey; + messageBody: Buffer; +}; + +/** + * Decodes a CCTP message into a MessageHeader and TokenMessengerMessageBody. + */ +export const decodeMessageSentData = (message: Buffer) => { + const messageHeader = decodeMessageHeader(message); + + const messageBodyData = message.slice(MESSAGE_BODY_INDEX); + + const messageBody = decodeTokenMessengerMessageBody(messageBodyData); + + return { ...messageHeader, messageBody }; +}; + +/** + * Decodes a CCTP message header. + */ +export const decodeMessageHeader = (data: Buffer): MessageHeader => { + const version = data.readUInt32BE(HEADER_VERSION_INDEX); + const sourceDomain = data.readUInt32BE(SOURCE_DOMAIN_INDEX); + const destinationDomain = data.readUInt32BE(DESTINATION_DOMAIN_INDEX); + const nonce = data.readBigUInt64BE(NONCE_INDEX); + const sender = new anchor.web3.PublicKey(data.slice(HEADER_SENDER_INDEX, HEADER_SENDER_INDEX + 32)); + const recipient = new anchor.web3.PublicKey(data.slice(HEADER_RECIPIENT_INDEX, HEADER_RECIPIENT_INDEX + 32)); + const destinationCaller = new anchor.web3.PublicKey( + data.slice(DESTINATION_CALLER_INDEX, DESTINATION_CALLER_INDEX + 32) + ); + const messageBody = data.slice(MESSAGE_BODY_INDEX); + return { + version, + sourceDomain, + destinationDomain, + nonce, + sender, + recipient, + destinationCaller, + messageBody, + }; +}; + +/** + * Decodes a TokenMessenger message body. + */ +export const decodeTokenMessengerMessageBody = (data: Buffer): TokenMessengerMessageBody => { + const version = data.readUInt32BE(BODY_VERSION_INDEX); + const burnToken = new anchor.web3.PublicKey(data.slice(BURN_TOKEN_INDEX, BURN_TOKEN_INDEX + 32)); + const mintRecipient = new anchor.web3.PublicKey(data.slice(MINT_RECIPIENT_INDEX, MINT_RECIPIENT_INDEX + 32)); + const amount = readUInt256BE(data.slice(AMOUNT_INDEX, AMOUNT_INDEX + 32)); + const messageSender = new anchor.web3.PublicKey(data.slice(MESSAGE_SENDER_INDEX, MESSAGE_SENDER_INDEX + 32)); + return { version, burnToken, mintRecipient, amount, messageSender }; +}; + +/** + * Encodes a MessageHeader into a Buffer. + */ +export const encodeMessageHeader = (header: MessageHeader): Buffer => { + const message = Buffer.alloc(MESSAGE_BODY_INDEX + header.messageBody.length); + + message.writeUInt32BE(header.version, HEADER_VERSION_INDEX); + message.writeUInt32BE(header.sourceDomain, SOURCE_DOMAIN_INDEX); + message.writeUInt32BE(header.destinationDomain, DESTINATION_DOMAIN_INDEX); + message.writeBigUInt64BE(header.nonce, NONCE_INDEX); + header.sender.toBuffer().copy(message, HEADER_SENDER_INDEX); + header.recipient.toBuffer().copy(message, HEADER_RECIPIENT_INDEX); + header.destinationCaller.toBuffer().copy(message, DESTINATION_CALLER_INDEX); + header.messageBody.copy(message, MESSAGE_BODY_INDEX); + + return message; +}; + +/** + * Type for the attestation response from the attestation service. + */ +type AttestationResponse = { + error?: string; + messages: { + attestation: string; + message: string; + eventNonce: string; + }[]; +}; + +/** + * Structure for the attestation response from the attestation service. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AttestationResponseStruct: Struct = object({ + error: optional(string()), + messages: array( + object({ + attestation: string(), + message: string(), + eventNonce: string(), + }) + ), +}); + +/** + * Fetches attestation from attestation service given the txHash. + */ +export const getMessages = async ( + txHash: string, + srcDomain: number, + irisApiUrl: string +): Promise => { + console.log("Fetching attestations and messages for tx...", txHash); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let attestationResponse: any = {}; + while ( + attestationResponse.error || + !attestationResponse.messages || + attestationResponse.messages?.[0]?.attestation === "PENDING" + ) { + const response = await fetch(`${irisApiUrl}/messages/${srcDomain}/${txHash}`); + attestationResponse = await response.json(); + + // Wait 2 seconds to avoid getting rate limited + if ( + attestationResponse.error || + !attestationResponse.messages || + attestationResponse.messages?.[0]?.attestation === "PENDING" + ) { + await new Promise((r) => setTimeout(r, 2000)); + } + } + + // Validate the response structure + try { + AttestationResponseStruct.assert(attestationResponse); + } catch (error) { + console.error("Invalid attestation response structure:", error); + throw new Error("Invalid attestation response structure"); + } + + return attestationResponse; +}; diff --git a/src/svm/web3-v1/cctpV2Helpers.ts b/src/svm/web3-v1/cctpV2Helpers.ts new file mode 100644 index 000000000..72823ccec --- /dev/null +++ b/src/svm/web3-v1/cctpV2Helpers.ts @@ -0,0 +1,252 @@ +import * as anchor from "@coral-xyz/anchor"; +import { array, enums, object, optional, string, union, nullable, Infer, coerce, assert } from "superstruct"; +import { ethers } from "ethers"; +import { readUInt256BE } from "./relayHashUtils"; +import { addressOrBase58ToBytes32 } from "./conversionUtils"; + +// Index positions to decode Message Header from +// https://developers.circle.com/cctp/technical-guide#message-header +const HEADER_VERSION_INDEX = 0; +const SOURCE_DOMAIN_INDEX = 4; +const DESTINATION_DOMAIN_INDEX = 8; +const NONCE_INDEX = 12; +const HEADER_SENDER_INDEX = 44; +const HEADER_RECIPIENT_INDEX = 76; +const DESTINATION_CALLER_INDEX = 108; +const MIN_FINALITY_THRESHOLD_INDEX = 140; +const FINALITY_THRESHOLD_EXECUTED_INDEX = 144; +const MESSAGE_BODY_INDEX = 148; + +// Index positions to decode Message Body for TokenMessengerV2 from +// https://developers.circle.com/cctp/technical-guide#message-body +const BODY_VERSION_INDEX = 0; +const BURN_TOKEN_INDEX = 4; +const MINT_RECIPIENT_INDEX = 36; +const AMOUNT_INDEX = 68; +const MESSAGE_SENDER_INDEX = 100; +const MAX_FEE_INDEX = 132; +const FEE_EXECUTED_INDEX = 164; +const EXPIRATION_BLOCK = 196; +const HOOK_DATA_INDEX = 228; + +export const EVENT_ACCOUNT_WINDOW_SECONDS = 60 * 60 * 24 * 5; // 60 secs * 60 mins * 24 hours * 5 days = 5 days in seconds + +/** + * Type for the body of a TokenMessengerV2 message. + */ +export type TokenMessengerV2MessageBody = { + version: number; + burnToken: anchor.web3.PublicKey; + mintRecipient: anchor.web3.PublicKey; + amount: bigint; + messageSender: anchor.web3.PublicKey; + maxFee: bigint; + feeExecuted: bigint; + expirationBlock: bigint; + hookData: Buffer; +}; + +/** + * Type for the header of a CCTPv2 message. + */ +export type MessageHeaderV2 = { + version: number; + sourceDomain: number; + destinationDomain: number; + nonce: bigint; + sender: anchor.web3.PublicKey; + recipient: anchor.web3.PublicKey; + destinationCaller: anchor.web3.PublicKey; + minFinalityThreshold: number; + finalityThresholdExecuted: number; + messageBody: Buffer; +}; + +/** + * Decodes a CCTPv2 message into a MessageHeaderV2 and TokenMessengerV2MessageBody. + */ +export const decodeMessageSentDataV2 = (message: Buffer) => { + const messageHeader = decodeMessageHeaderV2(message); + + const messageBodyData = message.slice(MESSAGE_BODY_INDEX); + + const messageBody = decodeTokenMessengerV2MessageBody(messageBodyData); + + return { ...messageHeader, messageBody }; +}; + +/** + * Decodes a CCTPv2 message header. + */ +export const decodeMessageHeaderV2 = (data: Buffer): MessageHeaderV2 => { + const version = data.readUInt32BE(HEADER_VERSION_INDEX); + const sourceDomain = data.readUInt32BE(SOURCE_DOMAIN_INDEX); + const destinationDomain = data.readUInt32BE(DESTINATION_DOMAIN_INDEX); + const nonce = readUInt256BE(data.slice(NONCE_INDEX, NONCE_INDEX + 32)); + const sender = new anchor.web3.PublicKey(data.slice(HEADER_SENDER_INDEX, HEADER_SENDER_INDEX + 32)); + const recipient = new anchor.web3.PublicKey(data.slice(HEADER_RECIPIENT_INDEX, HEADER_RECIPIENT_INDEX + 32)); + const destinationCaller = new anchor.web3.PublicKey( + data.slice(DESTINATION_CALLER_INDEX, DESTINATION_CALLER_INDEX + 32) + ); + const minFinalityThreshold = data.readUInt32BE(MIN_FINALITY_THRESHOLD_INDEX); + const finalityThresholdExecuted = data.readUInt32BE(FINALITY_THRESHOLD_EXECUTED_INDEX); + const messageBody = data.slice(MESSAGE_BODY_INDEX); + return { + version, + sourceDomain, + destinationDomain, + nonce, + sender, + recipient, + destinationCaller, + minFinalityThreshold, + finalityThresholdExecuted, + messageBody, + }; +}; + +/** + * Decodes a TokenMessenger message body. + */ +export const decodeTokenMessengerV2MessageBody = (data: Buffer): TokenMessengerV2MessageBody => { + const version = data.readUInt32BE(BODY_VERSION_INDEX); + const burnToken = new anchor.web3.PublicKey(data.slice(BURN_TOKEN_INDEX, BURN_TOKEN_INDEX + 32)); + const mintRecipient = new anchor.web3.PublicKey(data.slice(MINT_RECIPIENT_INDEX, MINT_RECIPIENT_INDEX + 32)); + const amount = readUInt256BE(data.slice(AMOUNT_INDEX, AMOUNT_INDEX + 32)); + const messageSender = new anchor.web3.PublicKey(data.slice(MESSAGE_SENDER_INDEX, MESSAGE_SENDER_INDEX + 32)); + const maxFee = readUInt256BE(data.slice(MAX_FEE_INDEX, MAX_FEE_INDEX + 32)); + const feeExecuted = readUInt256BE(data.slice(FEE_EXECUTED_INDEX, FEE_EXECUTED_INDEX + 32)); + const expirationBlock = readUInt256BE(data.slice(EXPIRATION_BLOCK, EXPIRATION_BLOCK + 32)); + const hookData = data.slice(HOOK_DATA_INDEX); + return { version, burnToken, mintRecipient, amount, messageSender, maxFee, feeExecuted, expirationBlock, hookData }; +}; + +// Below structs defines the types for CCTP attestation API as documented in +// https://developers.circle.com/api-reference/cctp/all/get-messages-v-2 + +// DecodedMessage.decodedMessageBody (V1/V2; some fields V2-only) +export const DecodedMessageBody = object({ + burnToken: string(), + mintRecipient: string(), + amount: string(), + messageSender: string(), + // V2-only + maxFee: optional(string()), + feeExecuted: optional(string()), + expirationBlock: optional(string()), + hookData: optional(string()), +}); + +// DecodedMessage (nullable/empty if decoding fails) +// minFinalityThreshold & finalityThresholdExecuted are V2-only +export const DecodedMessage = object({ + sourceDomain: string(), + destinationDomain: string(), + nonce: string(), + sender: string(), + recipient: string(), + destinationCaller: string(), + minFinalityThreshold: optional(enums(["1000", "2000"])), + finalityThresholdExecuted: optional(enums(["1000", "2000"])), + messageBody: string(), + decodedMessageBody: optional( + coerce(nullable(DecodedMessageBody), union([nullable(DecodedMessageBody), object({})]), (v) => + isEmptyObject(v) ? null : v + ) + ), +}); + +// Each message item +export const AttestationMessage = object({ + message: string(), // "0x" when not available + eventNonce: string(), + attestation: string(), // "PENDING" when not available + decodedMessage: optional( + coerce(nullable(DecodedMessage), union([nullable(DecodedMessage), object({})]), (v) => + isEmptyObject(v) ? null : v + ) + ), + cctpVersion: enums([1, 2]), + status: enums(["complete", "pending_confirmations"]), + // Only present in some delayed cases + delayReason: optional(nullable(enums(["insufficient_fee", "amount_above_max", "insufficient_allowance_available"]))), +}); + +// Top-level 200 response +export const AttestationResponse = object({ + messages: array(AttestationMessage), +}); + +export type TAttestationResponse = Infer; +export type TAttestationMessage = Infer; +export type TDecodedMessage = Infer; +export type TDecodedMessageBody = Infer; + +const isEmptyObject = (v: unknown) => + v != null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0; + +/** + * Fetches attestation from attestation service given the txHash and source message for CCTP V2 token burn. + */ +export async function getV2BurnAttestation( + txSignature: string, + sourceMessageData: Buffer, + irisApiUrl: string +): Promise<{ destinationMessage: Buffer; attestation: Buffer } | null> { + const sourceMessage = decodeMessageSentDataV2(sourceMessageData); + + const attestationResponse = await ( + await fetch(`${irisApiUrl}/v2/messages/${sourceMessage.sourceDomain}/?transactionHash=${txSignature}`) + ).json(); + if (attestationResponse.error) return null; + assert(attestationResponse, AttestationResponse); + + // Return the first attested message that matches the source message. + for (const message of attestationResponse.messages) { + if ( + message.message !== "0x" && + message.attestation !== "PENDING" && + !!message.decodedMessage && + isMatchingV2BurnMessage(sourceMessage, message.decodedMessage) + ) { + return { + destinationMessage: Buffer.from(ethers.utils.arrayify(message.message)), + attestation: Buffer.from(ethers.utils.arrayify(message.attestation)), + }; + } + } + return null; +} + +function isMatchingV2BurnMessage( + sourceMessage: ReturnType, + destinationMessage: TDecodedMessage +): boolean { + if (!destinationMessage.decodedMessageBody) return false; + + return ( + sourceMessage.sourceDomain.toString() === destinationMessage.sourceDomain && + sourceMessage.destinationDomain.toString() === destinationMessage.destinationDomain && + // nonce is only set on destination + addressOrBase58ToBytes32(sourceMessage.sender.toString()) === addressOrBase58ToBytes32(destinationMessage.sender) && + addressOrBase58ToBytes32(sourceMessage.recipient.toString()) === + addressOrBase58ToBytes32(destinationMessage.recipient) && + addressOrBase58ToBytes32(sourceMessage.destinationCaller.toString()) === + addressOrBase58ToBytes32(destinationMessage.destinationCaller) && + sourceMessage.minFinalityThreshold.toString() === destinationMessage.minFinalityThreshold && + // finalityThresholdExecuted is only set on destination + addressOrBase58ToBytes32(sourceMessage.messageBody.burnToken.toString()) === + addressOrBase58ToBytes32(destinationMessage.decodedMessageBody.burnToken) && + addressOrBase58ToBytes32(sourceMessage.messageBody.mintRecipient.toString()) === + addressOrBase58ToBytes32(destinationMessage.decodedMessageBody.mintRecipient) && + sourceMessage.messageBody.amount.toString() === destinationMessage.decodedMessageBody.amount && + addressOrBase58ToBytes32(sourceMessage.messageBody.messageSender.toString()) === + addressOrBase58ToBytes32(destinationMessage.decodedMessageBody.messageSender) && + sourceMessage.messageBody.maxFee.toString() === destinationMessage.decodedMessageBody.maxFee && + // feeExecuted is only set on destination + // expirationBlock is only set on destination + sourceMessage.messageBody.hookData.equals( + Buffer.from(ethers.utils.arrayify(destinationMessage.decodedMessageBody.hookData || "0x")) + ) + ); +} diff --git a/src/svm/web3-v1/coders.ts b/src/svm/web3-v1/coders.ts new file mode 100644 index 000000000..c1d118b2b --- /dev/null +++ b/src/svm/web3-v1/coders.ts @@ -0,0 +1,270 @@ +import { BorshAccountsCoder } from "@coral-xyz/anchor"; +import { IdlCoder } from "@coral-xyz/anchor/dist/cjs/coder/borsh/idl"; +import { IdlTypeDef } from "@coral-xyz/anchor/dist/cjs/idl"; +import * as borsh from "@coral-xyz/borsh"; +import { + CompiledInstruction, + Message, + MessageAccountKeys, + MessageCompiledInstruction, + MessageHeader, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import bs58 from "bs58"; +import { Layout } from "buffer-layout"; +import { AcrossPlusMessage } from "../types.svm"; + +/** + * Extended Anchor accounts coder to handle large account data. + */ +export class LargeAccountsCoder extends BorshAccountsCoder { + // Getter to access the private accountLayouts property from base class. + private getAccountLayouts() { + // Base class has `Map; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, require-await + public async encode(accountName: A, account: T): Promise { + const buffer = Buffer.alloc(10240); // We don't currently need anything above instruction data account reallocation limit. + const layout = this.getAccountLayouts().get(accountName); + if (!layout) { + throw new Error(`Unknown account: ${accountName}`); + } + const len = layout.layout.encode(account, buffer); + const accountData = buffer.slice(0, len); + const discriminator = this.accountDiscriminator(accountName); + return Buffer.concat([discriminator, accountData]); + } +} + +type KeyModeMap = Map; + +/** + * Extended version of legacy CompiledKeys to handle compilation of unsigned transactions. Base implementation is here: + * https://github.com/solana-labs/solana-web3.js/blob/v1.95.3/src/message/compiled-keys.ts + */ +class UnsignedCompiledKeys { + keyModeMap: KeyModeMap; + payer?: PublicKey; + + constructor(keyModeMap: KeyModeMap, payer?: PublicKey) { + this.keyModeMap = keyModeMap; + this.payer = payer; + } + + static compileUnsigned(instructions: TransactionInstruction[], payer?: PublicKey): UnsignedCompiledKeys { + const keyModeMap: KeyModeMap = new Map(); + const getOrInsertDefault = (pubkey: PublicKey): { isWritable: boolean } => { + const address = pubkey.toBase58(); + let keyMode = keyModeMap.get(address); + if (keyMode === undefined) { + keyMode = { + isWritable: false, + }; + keyModeMap.set(address, keyMode); + } + return keyMode; + }; + + if (payer !== undefined) { + const payerKeyMode = getOrInsertDefault(payer); + payerKeyMode.isWritable = true; + } + + for (const ix of instructions) { + getOrInsertDefault(ix.programId); + for (const accountMeta of ix.keys) { + const keyMode = getOrInsertDefault(accountMeta.pubkey); + keyMode.isWritable ||= accountMeta.isWritable; + } + } + + return new UnsignedCompiledKeys(keyModeMap, payer); + } + + getMessageComponents(): [MessageHeader, PublicKey[]] { + const mapEntries = Array.from(this.keyModeMap.entries()); + if (mapEntries.length > 256) throw new Error("Max static account keys length exceeded"); + + const writableNonSigners = mapEntries.filter(([, mode]) => mode.isWritable); + const readonlyNonSigners = mapEntries.filter(([, mode]) => !mode.isWritable); + + const header: MessageHeader = { + numRequiredSignatures: 0, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: readonlyNonSigners.length, + }; + + const staticAccountKeys = [ + ...writableNonSigners.map(([address]) => new PublicKey(address)), + ...readonlyNonSigners.map(([address]) => new PublicKey(address)), + ]; + + return [header, staticAccountKeys]; + } +} + +/** + * Extended version of legacy Message to handle compilation of unsigned transactions. Base implementation is here: + * https://github.com/solana-labs/solana-web3.js/blob/v1.95.3/src/message/legacy.ts + */ +class UnsignedMessage extends Message { + static compileUnsigned(instructions: TransactionInstruction[], payer?: PublicKey): Message { + const compiledKeys = UnsignedCompiledKeys.compileUnsigned(instructions, payer); + const [header, staticAccountKeys] = compiledKeys.getMessageComponents(); + const accountKeys = new MessageAccountKeys(staticAccountKeys); + const compiledInstructions = accountKeys.compileInstructions(instructions).map( + (ix: MessageCompiledInstruction): CompiledInstruction => ({ + programIdIndex: ix.programIdIndex, + accounts: ix.accountKeyIndexes, + data: bs58.encode(ix.data), + }) + ); + return new Message({ + header, + accountKeys: staticAccountKeys, + recentBlockhash: "", // Not used as we are not signing the transaction. + instructions: compiledInstructions, + }); + } +} + +/** + * Helper to encode MulticallHandler transactions. + */ +export class MulticallHandlerCoder { + readonly compiledMessage: Message; + + private readonly layout: Layout; + + constructor(instructions: TransactionInstruction[], payerKey?: PublicKey) { + // Compile transaction message and keys. + this.compiledMessage = UnsignedMessage.compileUnsigned(instructions, payerKey); + + // Setup the layout for the encoder. + const fieldLayouts = [IdlCoder.fieldLayout(MulticallHandlerCoder.coderArg, MulticallHandlerCoder.coderTypes)]; + this.layout = borsh.struct(fieldLayouts); + } + + private static coderArg = { + name: "compiledIxs", + type: { + vec: { + defined: { + name: "compiledIx", + }, + }, + }, + }; + + private static coderTypes: IdlTypeDef[] = [ + { + name: "compiledIx", + type: { + kind: "struct", + fields: [ + { + name: "programIdIndex", + type: "u8", + }, + { + name: "accountKeyIndexes", + type: { + vec: "u8", + }, + }, + { + name: "data", + type: "bytes", + }, + ], + }, + }, + ]; + + get readOnlyLen() { + return ( + this.compiledMessage.header.numReadonlySignedAccounts + this.compiledMessage.header.numReadonlyUnsignedAccounts + ); + } + + get compiledKeyMetas() { + return this.compiledMessage.accountKeys.map((key, index) => { + return { + pubkey: key, + isSigner: this.compiledMessage.isAccountSigner(index), + isWritable: this.compiledMessage.isAccountWritable(index), + }; + }); + } + + encode() { + const buffer = Buffer.alloc(1280); + const len = this.layout.encode({ compiledIxs: this.compiledMessage.compiledInstructions }, buffer); + return buffer.slice(0, len); + } +} + +/** + * Helper to encode Across+ messages. + */ +export class AcrossPlusMessageCoder { + private acrossPlusMessage: AcrossPlusMessage; + + constructor(acrossPlusMessage: AcrossPlusMessage) { + this.acrossPlusMessage = acrossPlusMessage; + } + + private static coderArg = { + name: "message", + type: { + defined: { + name: "acrossPlusMessage", + }, + }, + }; + + private static coderTypes: IdlTypeDef[] = [ + { + name: "acrossPlusMessage", + type: { + kind: "struct", + fields: [ + { + name: "handler", + type: "pubkey", + }, + { + name: "readOnlyLen", + type: "u8", + }, + { + name: "valueAmount", + type: "u64", + }, + { + name: "accounts", + type: { + vec: "pubkey", + }, + }, + { + name: "handlerMessage", + type: "bytes", + }, + ], + }, + }, + ]; + + encode() { + const fieldLayouts = [IdlCoder.fieldLayout(AcrossPlusMessageCoder.coderArg, AcrossPlusMessageCoder.coderTypes)]; + const layout = borsh.struct(fieldLayouts); + const buffer = Buffer.alloc(12800); + const len = layout.encode({ message: this.acrossPlusMessage }, buffer); + return buffer.slice(0, len); + } +} diff --git a/src/svm/web3-v1/constants.ts b/src/svm/web3-v1/constants.ts new file mode 100644 index 000000000..353b530f2 --- /dev/null +++ b/src/svm/web3-v1/constants.ts @@ -0,0 +1,9 @@ +import BN from "bn.js"; + +export const CIRCLE_IRIS_API_URL_DEVNET = "https://iris-api-sandbox.circle.com"; +export const CIRCLE_IRIS_API_URL_MAINNET = "https://iris-api.circle.com"; +export const SOLANA_USDC_MAINNET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +export const SOLANA_USDC_DEVNET = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +export const SEPOLIA_CCTP_MESSAGE_TRANSMITTER_ADDRESS = "0x7865fAfC2db2093669d92c0F33AeEF291086BEFD"; +export const MAINNET_CCTP_MESSAGE_TRANSMITTER_ADDRESS = "0x0a992d191deec32afe36203ad87d7d289a738f81"; +export const SOLANA_SPOKE_STATE_SEED = new BN(0); diff --git a/src/svm/web3-v1/conversionUtils.ts b/src/svm/web3-v1/conversionUtils.ts new file mode 100644 index 000000000..c8e185197 --- /dev/null +++ b/src/svm/web3-v1/conversionUtils.ts @@ -0,0 +1,109 @@ +import { utils as anchorUtils, BN } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { BigNumber, ethers } from "ethers"; + +/** + * Converts an integer to a 32-byte Uint8Array. + */ +export function intToU8Array32(num: number | BN): number[] { + const bigIntValue = BigInt(num instanceof BN ? num.toString() : num); + if (bigIntValue < 0) throw new Error("Input must be a non-negative integer or BN"); + + const hexString = bigIntValue.toString(16).padStart(64, "0"); // 32 bytes = 64 hex chars + const u8Array = Array.from(Buffer.from(hexString, "hex")); + + return u8Array; +} + +/** + * Converts a 32-byte Uint8Array to a bigint. + */ +export function u8Array32ToInt(u8Array: Uint8Array | number[]): bigint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isValidArray = (arr: any): arr is number[] => Array.isArray(arr) && arr.every(Number.isInteger); + + if ((u8Array instanceof Uint8Array || isValidArray(u8Array)) && u8Array.length === 32) { + return Array.from(u8Array).reduce((num, byte) => (num << BigInt(8)) | BigInt(byte), BigInt(0)); + } + + throw new Error("Input must be a Uint8Array or an array of 32 numbers."); +} + +/** + * Converts a 32-byte Uint8Array to a BigNumber. + */ +export function u8Array32ToBigNumber(u8Array: Uint8Array | number[]): BigNumber { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isValidArray = (arr: any): arr is number[] => Array.isArray(arr) && arr.every(Number.isInteger); + if ((u8Array instanceof Uint8Array || isValidArray(u8Array)) && u8Array.length === 32) { + const hexString = "0x" + Buffer.from(u8Array).toString("hex"); + return BigNumber.from(hexString); + } + + throw new Error("Input must be a Uint8Array or an array of 32 numbers."); +} + +/** + * Converts a string to a PublicKey. + */ +export function strPublicKey(publicKey: PublicKey): string { + return new PublicKey(publicKey).toString(); +} + +/** + * Converts an EVM address to a Solana PublicKey. + */ +export const evmAddressToPublicKey = (address: string): PublicKey => { + const bytes32Address = `0x000000000000000000000000${address.replace("0x", "")}`; + return new PublicKey(ethers.utils.arrayify(bytes32Address)); +}; + +/** + * Converts a Solana PublicKey to an EVM address. + */ +export const publicKeyToEvmAddress = (publicKey: PublicKey | string): string => { + // Convert the input to a PublicKey if it's a string + const pubKeyBuffer = typeof publicKey === "string" ? new PublicKey(publicKey).toBuffer() : publicKey.toBuffer(); + + // Extract the last 20 bytes to get the Ethereum address + const addressBuffer = pubKeyBuffer.slice(-20); + + // Convert the buffer to a hex string and prepend '0x' + return `0x${addressBuffer.toString("hex")}`; +}; + +/** + * Converts a base58 string to a bytes32 string. + */ +export const fromBase58ToBytes32 = (input: string): string => { + const decodedBytes = anchorUtils.bytes.bs58.decode(input); + return "0x" + Buffer.from(decodedBytes).toString("hex"); +}; + +/** + * Converts a bytes32 string to an EVM address. + */ +export const fromBytes32ToAddress = (input: string): string => { + const hexString = input.startsWith("0x") ? input.slice(2) : input; + + if (hexString.length !== 64) { + throw new Error("Invalid bytes32 string"); + } + + const address = hexString.slice(-40); + + return "0x" + address; +}; + +/** + * Converts EVM or SVM address to a bytes32 string. + */ +export const addressOrBase58ToBytes32 = (input: string): string => { + if (ethers.utils.isAddress(input)) { + return ethers.utils.hexZeroPad(input, 32); + } else if (ethers.utils.isHexString(input, 32)) { + return input; + } else { + return fromBase58ToBytes32(input); + } +}; diff --git a/src/svm/web3-v1/helpers.ts b/src/svm/web3-v1/helpers.ts new file mode 100644 index 000000000..dc8a6b593 --- /dev/null +++ b/src/svm/web3-v1/helpers.ts @@ -0,0 +1,303 @@ +import { AnchorProvider, BN } from "@coral-xyz/anchor"; +import { BigNumber, ethers } from "ethers"; +import { PublicKey } from "@solana/web3.js"; +import { serialize } from "borsh"; +import { keccak256 } from "ethers/lib/utils"; + +/** + * Returns the chainId for a given solana cluster. + */ +export const getSolanaChainId = (cluster: "devnet" | "mainnet"): BigNumber => { + return BigNumber.from( + BigInt(ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`solana-${cluster}`))) & BigInt("0xFFFFFFFFFFFF") + ); +}; + +/** + * Returns true if the provider is on the devnet cluster. + */ +export const isSolanaDevnet = (provider: AnchorProvider): boolean => { + const solanaRpcEndpoint = provider.connection.rpcEndpoint; + if (solanaRpcEndpoint.includes("devnet")) return true; + else if (solanaRpcEndpoint.includes("mainnet")) return false; + else throw new Error(`Unsupported solanaCluster endpoint: ${solanaRpcEndpoint}`); +}; + +/** + * Generic helper: serialize + keccak256 → 32‑byte Uint8Array + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function deriveSeedHash(schema: any, seedObj: T): Uint8Array { + const serialized = serialize(schema, seedObj); + const hashHex = keccak256(serialized); + return Buffer.from(hashHex.slice(2), "hex"); +} + +/** + * “Absolute‐deadline” deposit data + */ +export class DepositSeedData { + depositor!: Uint8Array; + recipient!: Uint8Array; + inputToken!: Uint8Array; + outputToken!: Uint8Array; + inputAmount!: BN; + outputAmount!: number[]; + destinationChainId!: BN; + exclusiveRelayer!: Uint8Array; + quoteTimestamp!: BN; + fillDeadline!: BN; + exclusivityParameter!: BN; + message!: Uint8Array; + + constructor(fields: { + depositor: Uint8Array; + recipient: Uint8Array; + inputToken: Uint8Array; + outputToken: Uint8Array; + inputAmount: BN; + outputAmount: number[]; + destinationChainId: BN; + exclusiveRelayer: Uint8Array; + quoteTimestamp: BN; + fillDeadline: BN; + exclusivityParameter: BN; + message: Uint8Array; + }) { + Object.assign(this, fields); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const depositSeedSchema: any = new Map([ + [ + DepositSeedData, + { + kind: "struct", + fields: [ + ["depositor", [32]], + ["recipient", [32]], + ["inputToken", [32]], + ["outputToken", [32]], + ["inputAmount", "u64"], + ["outputAmount", [32]], + ["destinationChainId", "u64"], + ["exclusiveRelayer", [32]], + ["quoteTimestamp", "u32"], + ["fillDeadline", "u32"], + ["exclusivityParameter", "u32"], + ["message", ["u8"]], + ], + }, + ], +]); + +/** + * Hash for the standard `deposit(...)` flow + */ +export function getDepositSeedHash(depositData: { + depositor: PublicKey; + recipient: PublicKey; + inputToken: PublicKey; + outputToken: PublicKey; + inputAmount: BN; + outputAmount: number[]; + destinationChainId: BN; + exclusiveRelayer: PublicKey; + quoteTimestamp: BN; + fillDeadline: BN; + exclusivityParameter: BN; + message: Uint8Array; +}): Uint8Array { + const ds = new DepositSeedData({ + depositor: depositData.depositor.toBuffer(), + recipient: depositData.recipient.toBuffer(), + inputToken: depositData.inputToken.toBuffer(), + outputToken: depositData.outputToken.toBuffer(), + inputAmount: depositData.inputAmount, + outputAmount: depositData.outputAmount, + destinationChainId: depositData.destinationChainId, + exclusiveRelayer: depositData.exclusiveRelayer.toBuffer(), + quoteTimestamp: depositData.quoteTimestamp, + fillDeadline: depositData.fillDeadline, + exclusivityParameter: depositData.exclusivityParameter, + message: depositData.message, + }); + + return deriveSeedHash(depositSeedSchema, ds); +} + +/** + * Returns the delegate PDA for `deposit(...)` + */ +export function getDepositPda(depositData: Parameters[0], programId: PublicKey): PublicKey { + const seedHash = getDepositSeedHash(depositData); + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("delegate"), seedHash], programId); + return pda; +} + +/** + * “Offset/now” deposit data + */ +export class DepositNowSeedData { + depositor!: Uint8Array; + recipient!: Uint8Array; + inputToken!: Uint8Array; + outputToken!: Uint8Array; + inputAmount!: BN; + outputAmount!: number[]; + destinationChainId!: BN; + exclusiveRelayer!: Uint8Array; + fillDeadlineOffset!: BN; + exclusivityPeriod!: BN; + message!: Uint8Array; + + constructor(fields: { + depositor: Uint8Array; + recipient: Uint8Array; + inputToken: Uint8Array; + outputToken: Uint8Array; + inputAmount: BN; + outputAmount: number[]; + destinationChainId: BN; + exclusiveRelayer: Uint8Array; + fillDeadlineOffset: BN; + exclusivityPeriod: BN; + message: Uint8Array; + }) { + Object.assign(this, fields); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const depositNowSeedSchema: any = new Map([ + [ + DepositNowSeedData, + { + kind: "struct", + fields: [ + ["depositor", [32]], + ["recipient", [32]], + ["inputToken", [32]], + ["outputToken", [32]], + ["inputAmount", "u64"], + ["outputAmount", [32]], + ["destinationChainId", "u64"], + ["exclusiveRelayer", [32]], + ["fillDeadlineOffset", "u32"], + ["exclusivityPeriod", "u32"], + ["message", ["u8"]], + ], + }, + ], +]); + +/** + * Hash for the `deposit_now(...)` flow + */ +export function getDepositNowSeedHash(depositData: { + depositor: PublicKey; + recipient: PublicKey; + inputToken: PublicKey; + outputToken: PublicKey; + inputAmount: BN; + outputAmount: number[]; + destinationChainId: BN; + exclusiveRelayer: PublicKey; + fillDeadlineOffset: BN; + exclusivityPeriod: BN; + message: Uint8Array; +}): Uint8Array { + const dns = new DepositNowSeedData({ + depositor: depositData.depositor.toBuffer(), + recipient: depositData.recipient.toBuffer(), + inputToken: depositData.inputToken.toBuffer(), + outputToken: depositData.outputToken.toBuffer(), + inputAmount: depositData.inputAmount, + outputAmount: depositData.outputAmount, + destinationChainId: depositData.destinationChainId, + exclusiveRelayer: depositData.exclusiveRelayer.toBuffer(), + fillDeadlineOffset: depositData.fillDeadlineOffset, + exclusivityPeriod: depositData.exclusivityPeriod, + message: depositData.message, + }); + + return deriveSeedHash(depositNowSeedSchema, dns); +} + +/** + * Returns the delegate PDA for `deposit_now(...)` + */ +export function getDepositNowPda( + depositData: Parameters[0], + programId: PublicKey +): PublicKey { + const seedHash = getDepositNowSeedHash(depositData); + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("delegate"), seedHash], programId); + return pda; +} + +/** + * Fill Delegate Seed Data + */ +class FillDelegateSeedData { + relayHash: Uint8Array; + repaymentChainId: BN; + repaymentAddress: Uint8Array; + constructor(fields: { relayHash: Uint8Array; repaymentChainId: BN; repaymentAddress: Uint8Array }) { + this.relayHash = fields.relayHash; + this.repaymentChainId = fields.repaymentChainId; + this.repaymentAddress = fields.repaymentAddress; + } +} + +/** + * Borsh schema for FillDelegateSeedData + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const fillDelegateSeedSchema = new Map([ + [ + FillDelegateSeedData, + { + kind: "struct", + fields: [ + ["relayHash", [32]], + ["repaymentChainId", "u64"], + ["repaymentAddress", [32]], + ], + }, + ], +]); + +/** + * Returns the fill delegate seed hash. + */ + +export function getFillRelayDelegateSeedHash( + relayHash: Uint8Array, + repaymentChainId: BN, + repaymentAddress: PublicKey +): Uint8Array { + const ds = new FillDelegateSeedData({ + relayHash, + repaymentChainId, + repaymentAddress: repaymentAddress.toBuffer(), + }); + + return deriveSeedHash(fillDelegateSeedSchema, ds); +} + +/** + * Returns the fill delegate PDA. + */ +export function getFillRelayDelegatePda( + relayHash: Uint8Array, + repaymentChainId: BN, + repaymentAddress: PublicKey, + programId: PublicKey +): { seedHash: Uint8Array; pda: PublicKey } { + const seedHash = getFillRelayDelegateSeedHash(relayHash, repaymentChainId, repaymentAddress); + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("delegate"), seedHash], programId); + + return { seedHash, pda }; +} diff --git a/src/svm/web3-v1/index.ts b/src/svm/web3-v1/index.ts new file mode 100644 index 000000000..dbbd53044 --- /dev/null +++ b/src/svm/web3-v1/index.ts @@ -0,0 +1,11 @@ +export * from "./relayHashUtils"; +export * from "./instructionParamsUtils"; +export * from "./conversionUtils"; +export * from "./transactionUtils"; +export * from "./solanaProgramUtils"; +export * from "./coders"; +export * from "./programConnectors"; +export * from "./constants"; +export * from "./helpers"; +export * from "./cctpHelpers"; +export * from "./cctpV2Helpers"; diff --git a/src/svm/web3-v1/instructionParamsUtils.ts b/src/svm/web3-v1/instructionParamsUtils.ts new file mode 100644 index 000000000..240a91f02 --- /dev/null +++ b/src/svm/web3-v1/instructionParamsUtils.ts @@ -0,0 +1,200 @@ +import { Keypair, TransactionInstruction, Transaction, sendAndConfirmTransaction, PublicKey } from "@solana/web3.js"; +import { Program, BN } from "@coral-xyz/anchor"; +import { RelayData, SlowFillLeaf, RelayerRefundLeafSolana } from "../types.svm"; +import { SvmSpokeAnchor as SvmSpoke } from "../assets"; +import { LargeAccountsCoder } from "./coders"; + +/** + * Loads execute relayer refund leaf parameters. + */ +export async function loadExecuteRelayerRefundLeafParams( + program: Program, + caller: PublicKey, + rootBundleId: number, + relayerRefundLeaf: RelayerRefundLeafSolana, + proof: number[][] +) { + const maxInstructionParamsFragment = 900; // Should not exceed message size limit when writing to the data account. + + // Close the instruction params account if the caller has used it before. + const [instructionParams] = PublicKey.findProgramAddressSync( + [Buffer.from("instruction_params"), caller.toBuffer()], + program.programId + ); + const accountInfo = await program.provider.connection.getAccountInfo(instructionParams); + if (accountInfo !== null) await program.methods.closeInstructionParams().rpc(); + + const accountCoder = new LargeAccountsCoder(program.idl); + const instructionParamsBytes = await accountCoder.encode("executeRelayerRefundLeafParams", { + rootBundleId, + relayerRefundLeaf, + proof, + }); + + await program.methods.initializeInstructionParams(instructionParamsBytes.length).rpc(); + + for (let i = 0; i < instructionParamsBytes.length; i += maxInstructionParamsFragment) { + const fragment = instructionParamsBytes.slice(i, i + maxInstructionParamsFragment); + await program.methods.writeInstructionParamsFragment(i, fragment).rpc(); + } + return instructionParams; +} + +/** + * Closes the instruction parameters account. + */ +export async function closeInstructionParams(program: Program, signer: Keypair) { + const [instructionParams] = PublicKey.findProgramAddressSync( + [Buffer.from("instruction_params"), signer.publicKey.toBuffer()], + program.programId + ); + const accountInfo = await program.provider.connection.getAccountInfo(instructionParams); + if (accountInfo !== null) { + const closeIx = await program.methods.closeInstructionParams().accounts({ signer: signer.publicKey }).instruction(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await sendAndConfirmTransaction(program.provider.connection as any, new Transaction().add(closeIx), [signer]); + } +} + +/** + * Creates instructions to load fill relay parameters. + */ +export async function createFillRelayParamsInstructions( + program: Program, + signer: PublicKey, + relayData: RelayData, + repaymentChainId: BN, + repaymentAddress: PublicKey +): Promise<{ loadInstructions: TransactionInstruction[]; closeInstruction: TransactionInstruction }> { + const maxInstructionParamsFragment = 900; // Should not exceed message size limit when writing to the data account. + + const accountCoder = new LargeAccountsCoder(program.idl); + const instructionParamsBytes = await accountCoder.encode("fillRelayParams", { + relayData, + repaymentChainId, + repaymentAddress, + }); + + const loadInstructions: TransactionInstruction[] = []; + loadInstructions.push( + await program.methods.initializeInstructionParams(instructionParamsBytes.length).accounts({ signer }).instruction() + ); + + for (let i = 0; i < instructionParamsBytes.length; i += maxInstructionParamsFragment) { + const fragment = instructionParamsBytes.slice(i, i + maxInstructionParamsFragment); + loadInstructions.push( + await program.methods.writeInstructionParamsFragment(i, fragment).accounts({ signer }).instruction() + ); + } + + const closeInstruction = await program.methods.closeInstructionParams().accounts({ signer }).instruction(); + + return { loadInstructions, closeInstruction }; +} + +/** + * Loads fill relay parameters. + */ +export async function loadFillRelayParams( + program: Program, + signer: Keypair, + relayData: RelayData, + repaymentChainId: BN, + repaymentAddress: PublicKey +) { + // Close the instruction params account if the caller has used it before. + await closeInstructionParams(program, signer); + + // Execute load instructions sequentially. + const { loadInstructions } = await createFillRelayParamsInstructions( + program, + signer.publicKey, + relayData, + repaymentChainId, + repaymentAddress + ); + for (let i = 0; i < loadInstructions.length; i += 1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await sendAndConfirmTransaction(program.provider.connection as any, new Transaction().add(loadInstructions[i]), [ + signer, + ]); + } +} + +/** + * Loads request slow fill parameters. + */ +export async function loadRequestSlowFillParams(program: Program, signer: Keypair, relayData: RelayData) { + // Close the instruction params account if the caller has used it before. + await closeInstructionParams(program, signer); + + // Execute load instructions sequentially. + const maxInstructionParamsFragment = 900; // Should not exceed message size limit when writing to the data account. + + const accountCoder = new LargeAccountsCoder(program.idl); + const instructionParamsBytes = await accountCoder.encode("requestSlowFillParams", { relayData }); + + const loadInstructions: TransactionInstruction[] = []; + loadInstructions.push( + await program.methods + .initializeInstructionParams(instructionParamsBytes.length) + .accounts({ signer: signer.publicKey }) + .instruction() + ); + + for (let i = 0; i < instructionParamsBytes.length; i += maxInstructionParamsFragment) { + const fragment = instructionParamsBytes.slice(i, i + maxInstructionParamsFragment); + loadInstructions.push( + await program.methods + .writeInstructionParamsFragment(i, fragment) + .accounts({ signer: signer.publicKey }) + .instruction() + ); + } + + return loadInstructions; +} + +/** + * Loads execute slow relay leaf parameters. + */ +export async function loadExecuteSlowRelayLeafParams( + program: Program, + signer: Keypair, + slowFillLeaf: SlowFillLeaf, + rootBundleId: number, + proof: number[][] +) { + // Close the instruction params account if the caller has used it before. + await closeInstructionParams(program, signer); + + // Execute load instructions sequentially. + const maxInstructionParamsFragment = 900; // Should not exceed message size limit when writing to the data account. + + const accountCoder = new LargeAccountsCoder(program.idl); + const instructionParamsBytes = await accountCoder.encode("executeSlowRelayLeafParams", { + slowFillLeaf, + rootBundleId, + proof, + }); + + const loadInstructions: TransactionInstruction[] = []; + loadInstructions.push( + await program.methods + .initializeInstructionParams(instructionParamsBytes.length) + .accounts({ signer: signer.publicKey }) + .instruction() + ); + + for (let i = 0; i < instructionParamsBytes.length; i += maxInstructionParamsFragment) { + const fragment = instructionParamsBytes.slice(i, i + maxInstructionParamsFragment); + loadInstructions.push( + await program.methods + .writeInstructionParamsFragment(i, fragment) + .accounts({ signer: signer.publicKey }) + .instruction() + ); + } + + return loadInstructions; +} diff --git a/src/svm/web3-v1/programConnectors.ts b/src/svm/web3-v1/programConnectors.ts new file mode 100644 index 000000000..fc915104c --- /dev/null +++ b/src/svm/web3-v1/programConnectors.ts @@ -0,0 +1,82 @@ +import { AnchorProvider, Idl, Program } from "@coral-xyz/anchor"; +import { getDeployedAddress } from "@across-protocol/contracts"; +import { SupportedNetworks } from "../types.svm"; +import { + MessageTransmitterAnchor, + MessageTransmitterIdl, + MulticallHandlerAnchor, + MulticallHandlerIdl, + SvmSpokeAnchor, + SvmSpokeIdl, + TokenMessengerMinterAnchor, + TokenMessengerMinterIdl, + MessageTransmitterV2Anchor, + MessageTransmitterV2Idl, + TokenMessengerMinterV2Anchor, + TokenMessengerMinterV2Idl, + SponsoredCctpSrcPeripheryAnchor, + SponsoredCctpSrcPeripheryIdl, +} from "../assets"; +import { getSolanaChainId, isSolanaDevnet } from "./helpers"; + +type ProgramOptions = { network?: SupportedNetworks; programId?: string }; + +export function getConnectedProgram

(idl: P, provider: AnchorProvider, programId: string) { + idl.address = programId; + return new Program

(idl, provider); +} + +// Resolves the program ID from options or defaults to the deployed address. Prioritizes programId, falls back to +// network, and if network is not defined, determines the network from the provider's RPC URL. Throws an error if +// the program ID cannot be resolved. +function resolveProgramId(programName: string, provider: AnchorProvider, options?: ProgramOptions): string { + const { network, programId } = options ?? {}; + + if (programId) { + return programId; // Prioritize explicitly provided programId + } + + const resolvedNetwork = network ?? (isSolanaDevnet(provider) ? "devnet" : "mainnet"); + const deployedAddress = getDeployedAddress(programName, getSolanaChainId(resolvedNetwork).toString()); + + if (!deployedAddress) { + throw new Error(`${programName} Program ID not found for ${resolvedNetwork}`); + } + + return deployedAddress; +} + +export function getSpokePoolProgram(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("SvmSpoke", provider, options); + return getConnectedProgram(SvmSpokeIdl, provider, id); +} + +export function getMessageTransmitterProgram(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("MessageTransmitter", provider, options); + return getConnectedProgram(MessageTransmitterIdl, provider, id); +} + +export function getTokenMessengerMinterProgram(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("TokenMessengerMinter", provider, options); + return getConnectedProgram(TokenMessengerMinterIdl, provider, id); +} + +export function getMulticallHandlerProgram(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("MulticallHandler", provider, options); + return getConnectedProgram(MulticallHandlerIdl, provider, id); +} + +export function getMessageTransmitterV2Program(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("MessageTransmitterV2", provider, options); + return getConnectedProgram(MessageTransmitterV2Idl, provider, id); +} + +export function getTokenMessengerMinterV2Program(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("TokenMessengerMinterV2", provider, options); + return getConnectedProgram(TokenMessengerMinterV2Idl, provider, id); +} + +export function getSponsoredCctpSrcPeripheryProgram(provider: AnchorProvider, options?: ProgramOptions) { + const id = resolveProgramId("SponsoredCctpSrcPeriphery", provider, options); + return getConnectedProgram(SponsoredCctpSrcPeripheryIdl, provider, id); +} diff --git a/src/svm/web3-v1/relayHashUtils.ts b/src/svm/web3-v1/relayHashUtils.ts new file mode 100644 index 000000000..f2d016886 --- /dev/null +++ b/src/svm/web3-v1/relayHashUtils.ts @@ -0,0 +1,228 @@ +import { BN } from "@coral-xyz/anchor"; +import { ethers } from "ethers"; +import { RelayerRefundLeaf, RelayerRefundLeafSolana, SlowFillLeaf } from "../types.svm"; +import { serialize } from "borsh"; + +/** + * Calculates the relay hash from relay data and chain ID. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function calculateRelayHashUint8Array(relayData: any, chainId: BN): Uint8Array { + const contentToHash = Buffer.concat([ + relayData.depositor.toBuffer(), + relayData.recipient.toBuffer(), + relayData.exclusiveRelayer.toBuffer(), + relayData.inputToken.toBuffer(), + relayData.outputToken.toBuffer(), + Buffer.from(relayData.inputAmount), + relayData.outputAmount.toArrayLike(Buffer, "le", 8), + relayData.originChainId.toArrayLike(Buffer, "le", 8), + Buffer.from(relayData.depositId), + new BN(relayData.fillDeadline).toArrayLike(Buffer, "le", 4), + new BN(relayData.exclusivityDeadline).toArrayLike(Buffer, "le", 4), + hashNonEmptyMessage(relayData.message), // Replace with hash of message, so that relay hash can be recovered from event. + chainId.toArrayLike(Buffer, "le", 8), + ]); + + const relayHash = ethers.utils.keccak256(contentToHash); + const relayHashBuffer = Buffer.from(relayHash.slice(2), "hex"); + return new Uint8Array(relayHashBuffer); +} + +/** + * Calculates the relay event hash from relay event data and chain ID. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function calculateRelayEventHashUint8Array(relayEventData: any, chainId: BN): Uint8Array { + const contentToHash = Buffer.concat([ + relayEventData.depositor.toBuffer(), + relayEventData.recipient.toBuffer(), + relayEventData.exclusiveRelayer.toBuffer(), + relayEventData.inputToken.toBuffer(), + relayEventData.outputToken.toBuffer(), + Buffer.from(relayEventData.inputAmount), + relayEventData.outputAmount.toArrayLike(Buffer, "le", 8), + relayEventData.originChainId.toArrayLike(Buffer, "le", 8), + Buffer.from(relayEventData.depositId), + new BN(relayEventData.fillDeadline).toArrayLike(Buffer, "le", 4), + new BN(relayEventData.exclusivityDeadline).toArrayLike(Buffer, "le", 4), + Buffer.from(relayEventData.messageHash), // Renamed to messageHash in the event data. + chainId.toArrayLike(Buffer, "le", 8), + ]); + + const relayHash = ethers.utils.keccak256(contentToHash); + const relayHashBuffer = Buffer.from(relayHash.slice(2), "hex"); + return new Uint8Array(relayHashBuffer); +} + +/** + * Reads a 256-bit unsigned integer from a buffer. + */ +export const readUInt256BE = (buffer: Buffer): bigint => { + let result = BigInt(0); + for (let i = 0; i < buffer.length; i++) { + result = (result << BigInt(8)) + BigInt(buffer[i]); + } + return result; +}; + +/** + * Hashes a non-empty message using Keccak256. + */ +export function hashNonEmptyMessage(message: Buffer) { + if (message.length > 0) { + const hash = ethers.utils.keccak256(message); + return Uint8Array.from(Buffer.from(hash.slice(2), "hex")); + } + // else return zeroed bytes32 + return new Uint8Array(32); +} + +/** + * Class for relay data. + */ +class RelayData { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(properties: any) { + Object.assign(this, properties); + } +} + +/** + * Schema for relay data. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const relayDataSchema: any = new Map([ + [ + RelayData, + { + kind: "struct", + fields: [ + ["amountToReturn", "u64"], + ["chainId", "u64"], + ["refundAmounts", ["u64"]], + ["leafId", "u32"], + ["mintPublicKey", [32]], + ["refundAddresses", [[32]]], + ], + }, + ], +]); + +/** + * Calculates the relayer refund leaf hash for Solana. + */ +export function calculateRelayerRefundLeafHashUint8Array(relayData: RelayerRefundLeafSolana): string { + const refundAddresses = relayData.refundAddresses.map((address) => address.toBuffer()); + + const data = new RelayData({ + amountToReturn: relayData.amountToReturn, + chainId: relayData.chainId, + refundAmounts: relayData.refundAmounts, + leafId: relayData.leafId, + mintPublicKey: relayData.mintPublicKey.toBuffer(), + refundAddresses: refundAddresses, + }); + + const serializedData = serialize(relayDataSchema, data); + + // SVM leaves require the first 64 bytes to be 0 to ensure EVM leaves can never be played on SVM and vice versa. + const contentToHash = Buffer.concat([Buffer.alloc(64, 0), serializedData]); + + return ethers.utils.keccak256(contentToHash); +} + +/** + * Hash function for relayer refund leaves. + */ +export const relayerRefundHashFn = (input: RelayerRefundLeaf | RelayerRefundLeafSolana) => { + if (!input.isSolana) { + const abiCoder = new ethers.utils.AbiCoder(); + const encodedData = abiCoder.encode( + [ + "tuple( uint256 amountToReturn, uint256 chainId, uint256[] refundAmounts, uint256 leafId, address l2TokenAddress, address[] refundAddresses)", + ], + [ + { + leafId: input.leafId, + chainId: input.chainId, + amountToReturn: input.amountToReturn, + l2TokenAddress: (input as RelayerRefundLeaf).l2TokenAddress, // Type assertion + refundAddresses: (input as RelayerRefundLeaf).refundAddresses, // Type assertion + refundAmounts: (input as RelayerRefundLeaf).refundAmounts, // Type assertion + }, + ] + ); + return ethers.utils.keccak256(encodedData); + } else { + return calculateRelayerRefundLeafHashUint8Array(input as RelayerRefundLeafSolana); + } +}; + +/** + * Class for slow fill data. + */ +class SlowFillData { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(properties: any) { + Object.assign(this, properties); + } +} + +/** + * Schema for slow fill data. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const slowFillDataSchema: any = new Map([ + [ + SlowFillData, + { + kind: "struct", + fields: [ + ["depositor", [32]], + ["recipient", [32]], + ["exclusiveRelayer", [32]], + ["inputToken", [32]], + ["outputToken", [32]], + ["inputAmount", [32]], + ["outputAmount", "u64"], + ["originChainId", "u64"], + ["depositId", [32]], + ["fillDeadline", "u32"], + ["exclusivityDeadline", "u32"], + ["message", ["u8"]], + ["chainId", "u64"], + ["updatedOutputAmount", "u64"], + ], + }, + ], +]); + +/** + * Hash function for slow fill leaves. + */ +export function slowFillHashFn(slowFillLeaf: SlowFillLeaf): string { + const data = new SlowFillData({ + depositor: Uint8Array.from(slowFillLeaf.relayData.depositor.toBuffer()), + recipient: Uint8Array.from(slowFillLeaf.relayData.recipient.toBuffer()), + exclusiveRelayer: Uint8Array.from(slowFillLeaf.relayData.exclusiveRelayer.toBuffer()), + inputToken: Uint8Array.from(slowFillLeaf.relayData.inputToken.toBuffer()), + outputToken: Uint8Array.from(slowFillLeaf.relayData.outputToken.toBuffer()), + inputAmount: slowFillLeaf.relayData.inputAmount, + outputAmount: slowFillLeaf.relayData.outputAmount, + originChainId: slowFillLeaf.relayData.originChainId, + depositId: Uint8Array.from(Buffer.from(slowFillLeaf.relayData.depositId)), + fillDeadline: slowFillLeaf.relayData.fillDeadline, + exclusivityDeadline: slowFillLeaf.relayData.exclusivityDeadline, + message: Uint8Array.from(slowFillLeaf.relayData.message), + chainId: slowFillLeaf.chainId, + updatedOutputAmount: slowFillLeaf.updatedOutputAmount, + }); + + const serializedData = serialize(slowFillDataSchema, data); + + // SVM leaves require the first 64 bytes to be 0 to ensure EVM leaves cannot be played on SVM and vice versa + const contentToHash = Buffer.concat([Buffer.alloc(64, 0), serializedData]); + + return ethers.utils.keccak256(contentToHash); +} diff --git a/src/svm/web3-v1/solanaProgramUtils.ts b/src/svm/web3-v1/solanaProgramUtils.ts new file mode 100644 index 000000000..40619c825 --- /dev/null +++ b/src/svm/web3-v1/solanaProgramUtils.ts @@ -0,0 +1,285 @@ +import { BN, Idl, Program, utils, web3 } from "@coral-xyz/anchor"; +import { + ConfirmedSignatureInfo, + Connection, + Finality, + Logs, + PublicKey, + SignaturesForAddressOptions, +} from "@solana/web3.js"; +import { deserialize } from "borsh"; +import { EventType } from "../types.svm"; +import { publicKeyToEvmAddress, u8Array32ToInt } from "./conversionUtils"; + +/** + * Finds a program address with a given label and optional extra seeds. + */ +export function findProgramAddress(label: string, program: PublicKey, extraSeeds?: string[]) { + const seeds: Buffer[] = [Buffer.from(utils.bytes.utf8.encode(label))]; + if (extraSeeds) { + for (const extraSeed of extraSeeds) { + if (typeof extraSeed === "string") { + seeds.push(Buffer.from(utils.bytes.utf8.encode(extraSeed))); + } else if (Array.isArray(extraSeed)) { + seeds.push(Buffer.from(extraSeed)); + } else if (Buffer.isBuffer(extraSeed)) { + seeds.push(extraSeed); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + seeds.push((extraSeed as any).toBuffer()); + } + } + } + const res = PublicKey.findProgramAddressSync(seeds, program); + return { publicKey: res[0], bump: res[1] }; +} + +/** + * Reads events from a transaction. + */ +export async function readEvents( + connection: Connection, + txSignature: string, + programs: Program[], + commitment: Finality = "confirmed" +) { + const txResult = await connection.getTransaction(txSignature, { commitment, maxSupportedTransactionVersion: 0 }); + + if (txResult === null) return []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return processEventFromTx(txResult as any, programs); +} + +/** + * Processes events from a transaction. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function processEventFromTx(txResult: web3.VersionedTransactionResponse, programs: Program[]) { + const eventAuthorities: Map = new Map(); + for (const program of programs) { + eventAuthorities.set( + program.programId.toString(), + findProgramAddress("__event_authority", program.programId).publicKey + ); + } + + const events = []; + + // Resolve any potential addresses that were passed from address lookup tables. + const messageAccountKeys = txResult.transaction.message.getAccountKeys({ + accountKeysFromLookups: txResult.meta?.loadedAddresses, + }); + + for (const ixBlock of txResult.meta?.innerInstructions ?? []) { + for (const ix of ixBlock.instructions) { + for (const program of programs) { + const ixProgramId = messageAccountKeys.get(ix.programIdIndex); + const singleIxAccount = ix.accounts.length === 1 ? messageAccountKeys.get(ix.accounts[0]) : undefined; + if ( + ixProgramId !== undefined && + singleIxAccount !== undefined && + program.programId.equals(ixProgramId) && + eventAuthorities.get(ixProgramId.toString())?.equals(singleIxAccount) + ) { + const ixData = utils.bytes.bs58.decode(ix.data); + const eventData = utils.bytes.base64.encode(Buffer.from(new Uint8Array(ixData).slice(8))); + const event = program.coder.events.decode(eventData); + events.push({ + program: program.programId, + data: event?.data, + name: event?.name, + }); + } + } + } + } + return events; +} + +/** + * Helper function to wait for an event to be emitted. Should only be used in tests where txSignature is known to emit. + */ +export async function readEventsUntilFound( + connection: Connection, + txSignature: string, + programs: Program[] +) { + const startTime = Date.now(); + let txResult = null; + + while (Date.now() - startTime < 5000) { + // 5 seconds timeout to wait to find the event. + txResult = await connection.getTransaction(txSignature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (txResult !== null) return processEventFromTx(txResult as any, programs); + + await new Promise((resolve) => setTimeout(resolve, 50)); // 50 ms delay between retries. + } + + throw new Error("No event found within 5 seconds"); +} + +/** + * Retrieves a specific event by name from a list of events. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getEvent(events: any[], program: PublicKey, eventName: string) { + for (const event of events) { + if (event.name === eventName && program.toString() === event.program.toString()) { + return event.data; + } + } + throw new Error("Event " + eventName + " not found"); +} + +/** + * Reads all events for a specific program. + */ +export async function readProgramEvents( + connection: Connection, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + program: Program, + finality: Finality = "confirmed", + options: SignaturesForAddressOptions = { limit: 1000 } +): Promise { + const allSignatures: ConfirmedSignatureInfo[] = []; + + // Fetch all signatures in sequential batches + for (;;) { + const signatures = await connection.getSignaturesForAddress(program.programId, options, finality); + allSignatures.push(...signatures); + + // Update options for the next batch. Set before to the last fetched signature. + if (signatures.length > 0) { + options = { ...options, before: signatures[signatures.length - 1].signature }; + } + + if (options.limit && signatures.length < options.limit) break; // Exit early if the number of signatures < limit + } + + // Fetch events for all signatures in parallel + const eventsWithSlots = await Promise.all( + allSignatures.map(async (signature) => { + const events = await readEvents(connection, signature.signature, [program], finality); + return events.map((event) => ({ + ...event, + confirmationStatus: signature.confirmationStatus || "Unknown", + blockTime: signature.blockTime || 0, + signature: signature.signature, + slot: signature.slot, + name: event.name || "Unknown", + })); + }) + ); + + return eventsWithSlots.flat(); // Flatten the array of events & return. +} + +/** + * Subscribes to CPI events for a program. + */ +export function subscribeToCpiEventsForProgram( + connection: Connection, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + program: Program, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (events: any[]) => void +) { + const subscriptionId = connection.onLogs( + new PublicKey(findProgramAddress("__event_authority", program.programId).publicKey.toString()), + async (logs: Logs) => { + callback(await readEvents(connection, logs.signature, [program], "confirmed")); + }, + "confirmed" + ); + + return subscriptionId; +} + +/** + * Class for DepositId. + */ +class DepositId { + value: Uint8Array; // Fixed-length array as Uint8Array + + constructor(properties: { value: Uint8Array }) { + this.value = properties.value; + } +} + +/** + * Borsh schema for deserializing DepositId. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const depositIdSchema: any = new Map( + [[DepositId, { kind: "struct", fields: [["value", [32]]] }]] // Fixed array [u8; 32] +); + +/** + * Parses depositId: checks if only the first 4 bytes are non-zero and returns a u32 or deserializes the full array. + */ +function parseDepositId(value: Uint8Array): string { + const restAreZero = value.slice(4).every((byte) => byte === 0); + + if (restAreZero) { + // Parse the first 4 bytes as a little-endian u32 + const u32Value = new DataView(value.buffer).getUint32(0, true); // true for little-endian + return u32Value.toString(); + } + + // Deserialize the full depositId using the Borsh schema + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const depositId = (deserialize as any)(depositIdSchema, DepositId, Buffer.from(value)) as DepositId; + return new BN(depositId.value).toString(); +} + +/** + * Stringifies a CPI event. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function stringifyCpiEvent(obj: any, eventName: string): any { + if (obj?.constructor?.toString()?.includes("PublicKey")) { + if (obj.toString().startsWith("111111111111")) { + // First 12 bytes are 0 for EVM addresses. + return publicKeyToEvmAddress(obj); + } + return obj.toString(); + } else if (BN.isBN(obj)) { + return obj.toString(); + } else if (typeof obj === "bigint") { + return obj.toString(); + } else if (Array.isArray(obj) && obj.length == 32) { + return Buffer.from(obj).toString("hex"); // Hex representation for fixed-length arrays + } else if (Array.isArray(obj)) { + return obj.map((obj) => stringifyCpiEvent(obj, eventName)); + } else if (obj !== null && typeof obj === "object") { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + if (Array.isArray(value) && value.length === 32) { + if (key === "depositId" || key === "deposit_id") { + // Parse depositId using the helper function + const parsedValue = parseDepositId(new Uint8Array(value)); + return [key, parsedValue]; + } else if ( + eventName.toLowerCase() === "fundsdeposited" && + (key === "outputAmount" || key === "output_amount") + ) { + return [key, u8Array32ToInt(new Uint8Array(value)).toString()]; + } else if ( + (eventName.toLowerCase() === "filledrelay" || eventName.toLowerCase() === "requestedslowfill") && + (key === "inputAmount" || key === "input_amount") + ) { + return [key, u8Array32ToInt(new Uint8Array(value)).toString()]; + } + } + return [key, stringifyCpiEvent(value, eventName)]; + }) + ); + } + return obj; +} diff --git a/src/svm/web3-v1/transactionUtils.ts b/src/svm/web3-v1/transactionUtils.ts new file mode 100644 index 000000000..aaa1bfdff --- /dev/null +++ b/src/svm/web3-v1/transactionUtils.ts @@ -0,0 +1,134 @@ +import { web3 } from "@coral-xyz/anchor"; +import { + AddressLookupTableAccount, + AddressLookupTableProgram, + Connection, + Keypair, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; + +/** + * Sends a transaction using an Address Lookup Table for large numbers of accounts. + */ +export async function sendTransactionWithLookupTable( + connection: Connection, + instructions: TransactionInstruction[], + sender: Keypair, + additionalSigners: Keypair[] = [] +): Promise<{ txSignature: string; lookupTableAddress: PublicKey }> { + // Maximum number of accounts that can be added to Address Lookup Table (ALT) in a single transaction. + const maxExtendedAccounts = 30; + + // Consolidate addresses from all instructions into a single array for the ALT. + const lookupAddresses = Array.from( + new Set( + instructions.flatMap((instruction) => [ + instruction.programId, + ...instruction.keys.map((accountMeta) => accountMeta.pubkey), + ]) + ) + ); + + // Create instructions for creating and extending the ALT. + const [lookupTableInstruction, lookupTableAddress] = await AddressLookupTableProgram.createLookupTable({ + authority: sender.publicKey, + payer: sender.publicKey, + recentSlot: await connection.getSlot(), + }); + + // Submit the ALT creation transaction + await web3.sendAndConfirmTransaction( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connection as any, + new web3.Transaction().add(lookupTableInstruction), + [sender], + { + commitment: "confirmed", + skipPreflight: true, + } + ); + + // Extend the ALT with all accounts making sure not to exceed the maximum number of accounts per transaction. + for (let i = 0; i < lookupAddresses.length; i += maxExtendedAccounts) { + const extendInstruction = AddressLookupTableProgram.extendLookupTable({ + lookupTable: lookupTableAddress, + authority: sender.publicKey, + payer: sender.publicKey, + addresses: lookupAddresses.slice(i, i + maxExtendedAccounts), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await web3.sendAndConfirmTransaction(connection as any, new web3.Transaction().add(extendInstruction), [sender], { + commitment: "confirmed", + skipPreflight: true, + }); + } + + // Wait for slot to advance. LUTs only active after slot advance. + const initialSlot = await connection.getSlot(); + while ((await connection.getSlot()) === initialSlot) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + // Fetch the AddressLookupTableAccount + const lookupTableAccount = (await connection.getAddressLookupTable(lookupTableAddress)).value; + if (lookupTableAccount === null) throw new Error("AddressLookupTableAccount not fetched"); + + // Create the versioned transaction + const versionedTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: sender.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions, + }).compileToV0Message([lookupTableAccount]) + ); + + // Sign and submit the versioned transaction. + versionedTx.sign([sender, ...additionalSigners]); + const txSignature = await connection.sendTransaction(versionedTx); + + // Confirm the versioned transaction + const block = await connection.getLatestBlockhash(); + await connection.confirmTransaction( + { signature: txSignature, blockhash: block.blockhash, lastValidBlockHeight: block.lastValidBlockHeight }, + "confirmed" + ); + + return { txSignature, lookupTableAddress }; +} + +/** + * Sends a transaction using existing Address Lookup Table. + */ +export async function sendTransactionWithExistingLookupTable( + connection: Connection, + instructions: TransactionInstruction[], + lookupTableAccount: AddressLookupTableAccount, + sender: Keypair, + additionalSigners: Keypair[] = [] +): Promise { + // Create the versioned transaction + const versionedTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: sender.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions, + }).compileToV0Message([lookupTableAccount]) + ); + + // Sign and submit the versioned transaction. + versionedTx.sign([sender, ...additionalSigners]); + const txSignature = await connection.sendTransaction(versionedTx); + + // Confirm the versioned transaction + const block = await connection.getLatestBlockhash(); + await connection.confirmTransaction( + { signature: txSignature, blockhash: block.blockhash, lastValidBlockHeight: block.lastValidBlockHeight }, + "confirmed" + ); + + return txSignature; +} diff --git a/src/svm/web3-v2/index.ts b/src/svm/web3-v2/index.ts new file mode 100644 index 000000000..49f77b728 --- /dev/null +++ b/src/svm/web3-v2/index.ts @@ -0,0 +1,3 @@ +export * from "./solanaProgramUtils"; +export * from "./transactionUtils"; +export * from "./types"; diff --git a/src/svm/web3-v2/solanaProgramUtils.ts b/src/svm/web3-v2/solanaProgramUtils.ts new file mode 100644 index 000000000..d728f6bf4 --- /dev/null +++ b/src/svm/web3-v2/solanaProgramUtils.ts @@ -0,0 +1,148 @@ +import { BorshEventCoder, Idl, utils } from "@coral-xyz/anchor"; +import web3, { Address, Commitment, GetSignaturesForAddressApi, GetTransactionApi, Signature } from "@solana/kit"; +import { RpcClient } from "./types"; + +type GetTransactionReturnType = ReturnType; + +type GetSignaturesForAddressConfig = Parameters[1]; + +type GetSignaturesForAddressTransaction = ReturnType[number]; + +/** + * Reads all events for a specific program. + */ +export async function readProgramEvents( + rpc: RpcClient, + program: Address, + anchorIdl: Idl, + finality: Commitment = "confirmed", + options: GetSignaturesForAddressConfig = { limit: 1000 } +) { + const allSignatures: GetSignaturesForAddressTransaction[] = await searchSignaturesUntilLimit(rpc, program, options); + + // Fetch events for all signatures in parallel + const eventsWithSlots = await Promise.all( + allSignatures.map(async (signatureTransaction) => { + const events = await readEvents(rpc, signatureTransaction.signature, program, anchorIdl, finality); + + return events.map((event) => ({ + ...event, + confirmationStatus: signatureTransaction.confirmationStatus || "Unknown", + blockTime: signatureTransaction.blockTime || 0, + signature: signatureTransaction.signature, + slot: signatureTransaction.slot, + name: event.name || "Unknown", + })); + }) + ); + return eventsWithSlots.flat(); +} + +async function searchSignaturesUntilLimit( + client: RpcClient, + program: Address, + options: GetSignaturesForAddressConfig = { limit: 1000 } +): Promise { + const allSignatures: GetSignaturesForAddressTransaction[] = []; + // Fetch all signatures in sequential batches + for (;;) { + const signatures = await client.rpc.getSignaturesForAddress(program, options).send(); + allSignatures.push(...signatures); + + // Update options for the next batch. Set before to the last fetched signature. + if (signatures.length > 0) { + options = { ...options, before: signatures[signatures.length - 1].signature }; + } + + if (options.limit && signatures.length < options.limit) break; // Exit early if the number of signatures < limit + } + return allSignatures; +} + +/** + * Reads events from a transaction. + */ +export async function readEvents( + client: RpcClient, + txSignature: Signature, + programId: Address, + programIdl: Idl, + commitment: Commitment = "confirmed" +) { + const txResult = await client.rpc + .getTransaction(txSignature, { encoding: "json", commitment, maxSupportedTransactionVersion: 0 }) + .send(); + + if (txResult === null) return []; + + // Ensure `version` field is always present in the response. @todo Drop this with next Anchor upgrade. + const txWithVersion = { ...txResult, version: txResult.version ?? 0 }; + return processEventFromTx(txWithVersion, programId, programIdl); +} + +/** + * Processes events from a transaction. + */ +async function processEventFromTx( + txResult: GetTransactionReturnType, + programId: Address, + programIdl: Idl + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise<{ program: Address; data: any; name: string | undefined }[]> { + if (!txResult) return []; + const eventAuthorities: Map = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const events: { program: Address; data: any; name: string | undefined }[] = []; + const [pda] = await web3.getProgramDerivedAddress({ programAddress: programId, seeds: ["__event_authority"] }); + eventAuthorities.set(programId, pda); + + const accountKeys = txResult.transaction.message.accountKeys; + const messageAccountKeys = [...accountKeys]; + // Order matters here. writable accounts must be processed before readonly accounts. + // 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 && + programId == ixProgramId && + eventAuthorities.get(ixProgramId.toString()) == singleIxAccount + ) { + const ixData = utils.bytes.bs58.decode(ix.data); + const eventData = utils.bytes.base64.encode(Buffer.from(new Uint8Array(ixData).slice(8))); + const event = new BorshEventCoder(programIdl).decode(eventData); + events.push({ + program: programId, + data: event?.data, + name: event?.name, + }); + } + } + } + + return events; +} + +/** + * For a given fillStatusPDa & associated spokePool ProgramID, return the fill event. + */ +export async function readFillEventFromFillStatusPda( + client: RpcClient, + fillStatusPda: Address, + programId: Address, + programIdl: Idl + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise<{ event: any; slot: number }> { + const signatures = await searchSignaturesUntilLimit(client, fillStatusPda); + if (signatures.length === 0) return { event: null, slot: 0 }; + + // The first signature will always be PDA creation, and therefore CPI event carrying signature. Any older signatures + // will therefore be either spam or PDA closure signatures and can be ignored when looking for the fill event. + const events = await readEvents(client, signatures[signatures.length - 1].signature, programId, programIdl); + return { event: events[0], slot: Number(signatures[signatures.length - 1].slot) }; +} diff --git a/src/svm/web3-v2/transactionUtils.ts b/src/svm/web3-v2/transactionUtils.ts new file mode 100644 index 000000000..3a3ad55d8 --- /dev/null +++ b/src/svm/web3-v2/transactionUtils.ts @@ -0,0 +1,143 @@ +import { + Address, + AddressesByLookupTableAddress, + appendTransactionMessageInstruction, + appendTransactionMessageInstructions, + assertIsTransactionWithBlockhashLifetime, + assertIsTransactionWithinSizeLimit, + BaseTransactionMessage, + Commitment, + compressTransactionMessageUsingAddressLookupTables as compressTxWithAlt, + createTransactionMessage, + getSignatureFromTransaction, + Instruction, + KeyPairSigner, + pipe, + sendAndConfirmTransactionFactory, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signTransactionMessageWithSigners, + TransactionMessageWithBlockhashLifetime, + TransactionMessageWithFeePayer, + TransactionSigner, +} from "@solana/kit"; + +import { + fetchAddressLookupTable, + findAddressLookupTablePda, + getCreateLookupTableInstructionAsync, + getExtendLookupTableInstruction, +} from "@solana-program/address-lookup-table"; +import { RpcClient } from "./types"; + +/** + * Signs and sends a transaction. + */ +export const signAndSendTransaction = async ( + rpcClient: RpcClient, + transactionMessage: BaseTransactionMessage & TransactionMessageWithFeePayer & TransactionMessageWithBlockhashLifetime, + commitment: Commitment = "confirmed" +) => { + const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); + assertIsTransactionWithBlockhashLifetime(signedTransaction); + assertIsTransactionWithinSizeLimit(signedTransaction); + const signature = getSignatureFromTransaction(signedTransaction); + await sendAndConfirmTransactionFactory(rpcClient)(signedTransaction, { + commitment, + }); + return signature; +}; + +export const createDefaultTransaction = async (rpcClient: RpcClient, signer: TransactionSigner) => { + const { value: latestBlockhash } = await rpcClient.rpc.getLatestBlockhash().send(); + return pipe( + createTransactionMessage({ version: 0 }), + (tx) => setTransactionMessageFeePayerSigner(signer, tx), + (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx) + ); +}; + +/** + * Sends a transaction with an Address Lookup Table. + */ +export async function sendTransactionWithLookupTable( + client: RpcClient, + payer: KeyPairSigner, + instructions: Instruction[], + addressesByLookupTableAddress: AddressesByLookupTableAddress +) { + return pipe( + await createDefaultTransaction(client, payer), + (tx) => appendTransactionMessageInstructions(instructions, tx), + (tx) => compressTxWithAlt(tx, addressesByLookupTableAddress), + (tx) => signTransactionMessageWithSigners(tx), + async (tx) => { + const signedTx = await tx; + assertIsTransactionWithBlockhashLifetime(signedTx); + assertIsTransactionWithinSizeLimit(signedTx); + await sendAndConfirmTransactionFactory(client)(signedTx, { + commitment: "confirmed", + skipPreflight: false, + }); + return getSignatureFromTransaction(signedTx); + } + ); +} + +/** + * Creates an Address Lookup Table. + */ +export async function createLookupTable(client: RpcClient, authority: KeyPairSigner): Promise

{ + const recentSlot = await client.rpc.getSlot({ commitment: "finalized" }).send(); + + const [alt] = await findAddressLookupTablePda({ + authority: authority.address, + recentSlot, + }); + + const createAltIx = await getCreateLookupTableInstructionAsync({ + authority, + recentSlot, + }); + + await pipe( + await createDefaultTransaction(client, authority), + (tx) => appendTransactionMessageInstruction(createAltIx, tx), + (tx) => signAndSendTransaction(client, tx) + ); + + return alt; +} + +/** + * Extends an Address Lookup Table. + */ +export async function extendLookupTable( + client: RpcClient, + authority: KeyPairSigner, + alt: Address, + addresses: Address[] +) { + const extendAltIx = getExtendLookupTableInstruction({ + address: alt, + authority, + payer: authority, + addresses, + }); + + await pipe( + await createDefaultTransaction(client, authority), + (tx) => appendTransactionMessageInstruction(extendAltIx, tx), + (tx) => signAndSendTransaction(client, tx) + ); + + const altAccount = await fetchAddressLookupTable(client.rpc, alt); + + const addressesByLookupTableAddress: AddressesByLookupTableAddress = {}; + addressesByLookupTableAddress[alt] = altAccount.data.addresses; + + // Delay a second here to let lookup table warm up + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return addressesByLookupTableAddress; +} diff --git a/src/svm/web3-v2/types.ts b/src/svm/web3-v2/types.ts new file mode 100644 index 000000000..50677589d --- /dev/null +++ b/src/svm/web3-v2/types.ts @@ -0,0 +1,16 @@ +import { + Rpc, + RpcSubscriptions, + RpcTransport, + SignatureNotificationsApi, + SlotNotificationsApi, + SolanaRpcApiFromTransport, +} from "@solana/kit"; + +/** + * A client for the Solana RPC. + */ +export type RpcClient = { + rpc: Rpc>; + rpcSubscriptions: RpcSubscriptions; +}; diff --git a/tsconfig.json b/tsconfig.json index be2c8fb6a..f47ab44b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "include": ["src", "types", "./test-utils.ts",], "compilerOptions": { "module": "CommonJS", - "target": "ES5", + "target": "ES2020", "lib": ["dom", "esnext"], // allow bundling of json files "resolveJsonModule": true, diff --git a/yarn.lock b/yarn.lock index 5106b24e4..c1c82da3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -198,42 +198,110 @@ dependencies: regenerator-runtime "^0.14.0" +"@codama/cli@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@codama/cli/-/cli-1.5.0.tgz#074866ba3e2cfbc009185a6dc0ec307583bc59e4" + integrity sha512-+q62IvEA6o7ji/mcnGgAvyPWyiFx3cojVGrFNG8NSm0zFXrBk1lT3n/qg+2Ag8C8aHwno9boXgDTxV+P5VCDYw== + dependencies: + "@codama/nodes" "1.5.1" + "@codama/visitors" "1.5.1" + "@codama/visitors-core" "1.5.1" + commander "^14.0.2" + picocolors "^1.1.1" + prompts "^2.4.2" + +"@codama/errors@1.5.1", "@codama/errors@^1.4.4": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@codama/errors/-/errors-1.5.1.tgz#66c93ea8a9d81c1f13debb19cc2f1af63c2a8c81" + integrity sha512-kdLk/OSLBt03DoViRU1Xr0M7NZ7J/CSqaXV8fooF9qMRGPRJdgUeW2VkCGlLXDQSaIALrls3HkHmKRKbqqjSOA== + dependencies: + "@codama/node-types" "1.5.1" + commander "^14.0.2" + picocolors "^1.1.1" + +"@codama/node-types@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@codama/node-types/-/node-types-1.5.1.tgz#f44208f04e1a588f023829cd8b67e01a353cb1ae" + integrity sha512-jMGz93MSszb1iXAAyWWa0i7RQbLxGihLKRZ+zr9aBsjaFFmhXhONfTFeSXzbEfc05cajpd/gW2QI7xmQHlUDKQ== + +"@codama/nodes-from-anchor@^1.2.2": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@codama/nodes-from-anchor/-/nodes-from-anchor-1.4.0.tgz#513360f47b5d956198e8f4469be9ec2a261c0ea1" + integrity sha512-RAn2I1SI8LPvFOFFOeG5AK4wtXc/olcGa7tebHH30zeY3t0cYbi2Y86hDck4pFC57KQWmKQFAOwvdCj5zAVaEA== + dependencies: + "@codama/errors" "1.5.1" + "@codama/nodes" "1.5.1" + "@codama/visitors" "1.5.1" + "@noble/hashes" "^2.0.1" + "@solana/codecs" "^5.3.0" + +"@codama/nodes@1.5.1", "@codama/nodes@^1.4.4": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@codama/nodes/-/nodes-1.5.1.tgz#b7dc48c49182905999e8f9249e9813dcc05f05ba" + integrity sha512-6fIoH5Cfa5dFUE1fRxymZloeNg02klOT4fHsWwQavkkRWkoySgiti//w0j1itiZj6j5O+usujrwsZUJqSFjnhQ== + dependencies: + "@codama/errors" "1.5.1" + "@codama/node-types" "1.5.1" + +"@codama/renderers-core@^1.3.4": + version "1.3.6" + resolved "https://registry.yarnpkg.com/@codama/renderers-core/-/renderers-core-1.3.6.tgz#2f48905423e5f5445b3f661e30189a44753ee073" + integrity sha512-m3yAmhrObnagyC7d8g9bZxyLC5YMpttLagRE0aAKD4zlDDh23o3zV7TxSYCh2nRCg5ObceflgvXdauIHUm/6Xg== + dependencies: + "@codama/errors" "1.5.1" + "@codama/nodes" "1.5.1" + "@codama/visitors-core" "1.5.1" + +"@codama/renderers-js@^1.3.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@codama/renderers-js/-/renderers-js-1.7.0.tgz#508037700767732d370c535995e0ccb13f3f2010" + integrity sha512-WwKkSkNPdUBVWjGmkG+RNXyZ5K/4ji8UZQGzowDNTrqktUrqPsBThOkc7Zpmv+TpCapxrfjj0Txpo+0q5FjKGw== + dependencies: + "@codama/errors" "^1.4.4" + "@codama/nodes" "^1.4.4" + "@codama/renderers-core" "^1.3.4" + "@codama/visitors-core" "^1.4.4" + "@solana/codecs-strings" "^6.0.0" + prettier "^3.8.1" + semver "^7.7.3" + +"@codama/validators@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@codama/validators/-/validators-1.5.1.tgz#ec952b78c9b32d787f34d7fe8bed9ec2361ebcd1" + integrity sha512-aUXl39AMa091CBWpYiK2XCXP/uyKOOtAT399TzRld3z8dIH9E0fGyu4ocP+IhQKXWXDPsh7V3qPmqsdyevOPcQ== + dependencies: + "@codama/errors" "1.5.1" + "@codama/nodes" "1.5.1" + "@codama/visitors-core" "1.5.1" + +"@codama/visitors-core@1.5.1", "@codama/visitors-core@^1.4.4": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@codama/visitors-core/-/visitors-core-1.5.1.tgz#179a31bc632095b46ca70db84106732bf05671ab" + integrity sha512-JotrDJLI7OfPNHulu4KtPfUDF/FYMC3RgEnv9lu47Fiiy0upbGAw1NorgBuoreyJ9Uj0GZyHt7Q5rjrCoa1U0g== + dependencies: + "@codama/errors" "1.5.1" + "@codama/nodes" "1.5.1" + json-stable-stringify "^1.3.0" + +"@codama/visitors@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@codama/visitors/-/visitors-1.5.1.tgz#fc9739ef3fcc924ae5cd15252e6fd8f471d10f5f" + integrity sha512-8WcGP1tJKtqBfZ4mJsBRPjZ/H6+SPLWmiUoDTXRrVePQE4X4Yb04o6BoX2Uc3heZbfEc0rXdM1w8HTFvXBX4/A== + dependencies: + "@codama/errors" "1.5.1" + "@codama/nodes" "1.5.1" + "@codama/visitors-core" "1.5.1" + "@colors/colors@1.6.0", "@colors/colors@^1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== -"@coral-xyz/anchor-errors@^0.30.1": - version "0.30.1" - resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.30.1.tgz#bdfd3a353131345244546876eb4afc0e125bec30" - integrity sha512-9Mkradf5yS5xiLWrl9WrpjqOrAV+/W2RQHDlbnAZBivoGpOs1ECjoDCkVk4aRG8ZdiFiB8zQEVlxf+8fKkmSfQ== - "@coral-xyz/anchor-errors@^0.31.1": version "0.31.1" resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.31.1.tgz#d635cbac2533973ae6bfb5d3ba1de89ce5aece2d" integrity sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ== -"@coral-xyz/anchor@^0.30.1": - version "0.30.1" - resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.30.1.tgz#17f3e9134c28cd0ea83574c6bab4e410bcecec5d" - integrity sha512-gDXFoF5oHgpriXAaLpxyWBHdCs8Awgf/gLHIo6crv7Aqm937CNdY+x+6hoj7QR5vaJV7MxWSQ0NGFzL3kPbWEQ== - dependencies: - "@coral-xyz/anchor-errors" "^0.30.1" - "@coral-xyz/borsh" "^0.30.1" - "@noble/hashes" "^1.3.1" - "@solana/web3.js" "^1.68.0" - bn.js "^5.1.2" - bs58 "^4.0.1" - buffer-layout "^1.2.2" - camelcase "^6.3.0" - cross-fetch "^3.1.5" - crypto-hash "^1.3.0" - eventemitter3 "^4.0.7" - pako "^2.0.3" - snake-case "^3.0.4" - superstruct "^0.15.4" - toml "^3.0.0" - "@coral-xyz/anchor@^0.31.1": version "0.31.1" resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.31.1.tgz#0fdeebf45a3cb2e47e8ebbb815ca98542152962c" @@ -253,14 +321,6 @@ superstruct "^0.15.4" toml "^3.0.0" -"@coral-xyz/borsh@^0.30.1": - version "0.30.1" - resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.30.1.tgz#869d8833abe65685c72e9199b8688477a4f6b0e3" - integrity sha512-aaxswpPrCFKl8vZTbxLssA2RvwX2zmKLlRCIktJOwW+VpVwYtXRtlWiIP+c2pPRKneiTiWCN2GEMSH9j1zTlWQ== - dependencies: - bn.js "^5.1.2" - buffer-layout "^1.2.0" - "@coral-xyz/borsh@^0.31.1": version "0.31.1" resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.31.1.tgz#5328e1e0921b75d7f4a62dd3f61885a938bc7241" @@ -1795,7 +1855,7 @@ color "^5.0.2" text-hex "1.0.x" -"@solana-program/address-lookup-table@0.10.0": +"@solana-program/address-lookup-table@0.10.0", "@solana-program/address-lookup-table@^0.10.0": version "0.10.0" resolved "https://registry.yarnpkg.com/@solana-program/address-lookup-table/-/address-lookup-table-0.10.0.tgz#a7e9dd0a37e9a960396ec608d756ef5d44e485c6" integrity sha512-lcp+IYwoFBODhg8vXsh5vpxweLxpSKqjAu8P1LyqQxgk2yqwYmJGA79YKa+lZvsQjP/c0rzIZYWIGxFMMes2zA== @@ -1874,6 +1934,13 @@ dependencies: "@solana/errors" "5.4.0" +"@solana/codecs-core@6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@solana/codecs-core/-/codecs-core-6.6.0.tgz#9c043eb35df626ccfeaeb5aac7dabdeebdce410e" + integrity sha512-sjVgIDnOp5ZTnrv7p1bq6UXm1uOTa8vVvm+tHdHiaBkYcCrcUx9XwAlODfpEW8GBXihdq7dYs6xwj+80jzjmeA== + dependencies: + "@solana/errors" "6.6.0" + "@solana/codecs-data-structures@5.4.0": version "5.4.0" resolved "https://registry.yarnpkg.com/@solana/codecs-data-structures/-/codecs-data-structures-5.4.0.tgz#014e30fee700a001bae2fbbb97339d48b1840d0b" @@ -1883,7 +1950,7 @@ "@solana/codecs-numbers" "5.4.0" "@solana/errors" "5.4.0" -"@solana/codecs-numbers@5.4.0", "@solana/codecs-numbers@^2.1.0": +"@solana/codecs-numbers@5.4.0", "@solana/codecs-numbers@6.6.0", "@solana/codecs-numbers@^2.1.0": version "5.4.0" resolved "https://registry.yarnpkg.com/@solana/codecs-numbers/-/codecs-numbers-5.4.0.tgz#c4b841e26b2b81e4d293cf6758dcf5c2c64d0de0" integrity sha512-z6LMkY+kXWx1alrvIDSAxexY5QLhsso638CjM7XI1u6dB7drTLWKhifyjnm1vOQc1VPVFmbYxTgKKpds8TY8tg== @@ -1900,7 +1967,16 @@ "@solana/codecs-numbers" "5.4.0" "@solana/errors" "5.4.0" -"@solana/codecs@2.0.0-rc.1", "@solana/codecs@5.4.0": +"@solana/codecs-strings@^6.0.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@solana/codecs-strings/-/codecs-strings-6.6.0.tgz#f1b0c30794a6df052d3e4a5c0984e1e6fd8dd9b1" + integrity sha512-YK1IzJyymuiKsEdYXqswt+CaZMJ8YcTwsQrUd4KfdUKUo1o1Bz3HxzTeuFfMqn0K+Yv+U5V7JVhO90gzJIMB2g== + dependencies: + "@solana/codecs-core" "6.6.0" + "@solana/codecs-numbers" "6.6.0" + "@solana/errors" "6.6.0" + +"@solana/codecs@2.0.0-rc.1", "@solana/codecs@5.4.0", "@solana/codecs@^5.3.0": version "5.4.0" resolved "https://registry.yarnpkg.com/@solana/codecs/-/codecs-5.4.0.tgz#57a42e9f07505a84d59a35c6ff2409af114f2e50" integrity sha512-IbDCUvNX0MrkQahxiXj9rHzkd/fYfp1F2nTJkHGH8v+vPfD+YPjl007ZBM38EnCeXj/Xn+hxqBBivPvIHP29dA== @@ -1919,6 +1995,14 @@ chalk "5.6.2" commander "14.0.2" +"@solana/errors@6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@solana/errors/-/errors-6.6.0.tgz#86118fed6bc27f20b7a00069d80aeb6379044e9d" + integrity sha512-8MlqxF3NWWT+nzvq08/7uPyx3u7zOGBR7ZmYvczWxM37pPcBmGEgsruWqw120Zk2Z1spzqOzXd/uTbXBxanH4Q== + dependencies: + chalk "5.6.2" + commander "14.0.3" + "@solana/fast-stable-stringify@5.4.0": version "5.4.0" resolved "https://registry.yarnpkg.com/@solana/fast-stable-stringify/-/fast-stable-stringify-5.4.0.tgz#a57c4d7dacd8c3906f569aceb6ec10c3843fd4fb" @@ -2199,7 +2283,7 @@ dependencies: "@solana/codecs" "2.0.0-rc.1" -"@solana/spl-token@0.4.14": +"@solana/spl-token@0.4.14", "@solana/spl-token@^0.4.14": version "0.4.14" resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.4.14.tgz#b86bc8a17f50e9680137b585eca5f5eb9d55c025" integrity sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA== @@ -2315,17 +2399,17 @@ rpc-websockets "^9.0.2" superstruct "^2.0.2" -"@solana/web3.js@^1.31.0", "@solana/web3.js@^1.68.0": - version "1.95.4" - resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.4.tgz#771603f60d75cf7556ad867e1fd2efae32f9ad09" - integrity sha512-sdewnNEA42ZSMxqkzdwEWi6fDgzwtJHaQa5ndUGEJYtoOnM6X5cvPmjoTUp7/k7bRrVAxfBgDnvQQHD6yhlLYw== +"@solana/web3.js@^1.98.2": + version "1.98.4" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.4.tgz#df51d78be9d865181ec5138b4e699d48e6895bbe" + integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== dependencies: "@babel/runtime" "^7.25.0" "@noble/curves" "^1.4.2" "@noble/hashes" "^1.4.0" "@solana/buffer-layout" "^4.0.1" + "@solana/codecs-numbers" "^2.1.0" agentkeepalive "^4.5.0" - bigint-buffer "^1.1.5" bn.js "^5.2.1" borsh "^0.7.0" bs58 "^4.0.1" @@ -3710,7 +3794,7 @@ bn.js@^5.1.2, bn.js@^5.2.0, bn.js@^5.2.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -borsh@^0.7.0: +borsh@0.7.0, borsh@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/borsh/-/borsh-0.7.0.tgz#6e9560d719d86d90dc589bca60ffc8a6c51fec2a" integrity sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA== @@ -4321,6 +4405,17 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +codama@^1.3.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/codama/-/codama-1.5.1.tgz#bcfd0e65d4368e77d8ec58493e1d86e5eef0c1c2" + integrity sha512-kZbYgesIxwGNZ1JsYIWFzAsLtBuLZy/S1pCxJZgYaE13NJwDzi+bsEYqRSOUQ9ISN7FJR3SCyAE+vgzvcJpg2A== + dependencies: + "@codama/cli" "1.5.0" + "@codama/errors" "1.5.1" + "@codama/nodes" "1.5.1" + "@codama/validators" "1.5.1" + "@codama/visitors" "1.5.1" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -4424,6 +4519,11 @@ commander@14.0.2: resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.2.tgz#b71fd37fe4069e4c3c7c13925252ada4eba14e8e" integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== +commander@14.0.3, commander@^14.0.2: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + commander@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.0.tgz#f244fc74a92343514e56229f16ef5c5e22ced5e9" @@ -4571,11 +4671,6 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= -crypto-hash@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/crypto-hash/-/crypto-hash-1.3.0.tgz#b402cb08f4529e9f4f09346c3e275942f845e247" - integrity sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== - crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" @@ -4837,14 +4932,6 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dot-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" - integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - dotenv@*, dotenv@^16.0.0: version "16.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411" @@ -7400,6 +7487,17 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= +json-stable-stringify@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70" + integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + json-stream-stringify@^3.1.4: version "3.1.6" resolved "https://registry.yarnpkg.com/json-stream-stringify/-/json-stream-stringify-3.1.6.tgz#ebe32193876fb99d4ec9f612389a8d8e2b5d54d4" @@ -7438,6 +7536,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + jsonparse@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" @@ -7494,6 +7597,11 @@ kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + knex@^0.95.6: version "0.95.15" resolved "https://registry.yarnpkg.com/knex/-/knex-0.95.15.tgz#39d7e7110a6e2ad7de5d673d2dea94143015e0e7" @@ -7781,13 +7889,6 @@ loupe@^2.3.6: dependencies: get-func-name "^2.0.1" -lower-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" - integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== - dependencies: - tslib "^2.0.3" - "lru-cache@7.10.1 - 7.13.1": version "7.13.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.13.1.tgz#267a81fbd0881327c46a81c5922606a2cfe336c4" @@ -8419,14 +8520,6 @@ nise@^5.1.4: just-extend "^4.0.2" path-to-regexp "^1.7.0" -no-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" - integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== - dependencies: - lower-case "^2.0.2" - tslib "^2.0.3" - node-abi@^3.3.0: version "3.54.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.54.0.tgz#f6386f7548817acac6434c6cba02999c9aebcc69" @@ -8977,7 +9070,7 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picocolors@^1.1.0: +picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -9052,6 +9145,11 @@ prettier@^3.0.3: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.3.tgz#432a51f7ba422d1469096c0fdc28e235db8f9643" integrity sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg== +prettier@^3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== + prng-well1024a@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/prng-well1024a/-/prng-well1024a-1.0.1.tgz#05e8ed923e4ea2b3f78af5ee94f056b4d5cfab24" @@ -9092,6 +9190,14 @@ promise@^8.0.0: dependencies: asap "~2.0.6" +prompts@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + proper-lockfile@^4.1.1: version "4.1.2" resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" @@ -9638,6 +9744,11 @@ semver@^7.6.0, semver@^7.6.2, semver@^7.7.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== +semver@^7.7.3: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + sentencer@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/sentencer/-/sentencer-0.2.1.tgz#88a1f4767c14bb8cd148b07822e13b8b55897956" @@ -9782,6 +9893,11 @@ sinon@^16.0.0: nise "^5.1.4" supports-color "^7.2.0" +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + size-limit@^7.0.8: version "7.0.8" resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-7.0.8.tgz#bf0c656da9e6bc3b8eb5b8a5a634df4634309811" @@ -9851,14 +9967,6 @@ smartweave@^0.4.46: sentencer "^0.2.1" yargs "^17.5.1" -snake-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" - integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - socks-proxy-agent@^6.0.0: version "6.2.1" resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz#2687a31f9d7185e38d530bef1944fe1f1496d6ce" @@ -10605,16 +10713,16 @@ tslib@^1.11.1, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3, tslib@^2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.0, tslib@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsort@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tsort/-/tsort-0.0.1.tgz#e2280f5e817f8bf4275657fd0f9aebd44f5a2786"