Skip to content

feat(epic): add SVM client #985

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 42 commits into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8b3ed44
chore: create skeleton for svm spoke client (#965)
james-a-morris Apr 4, 2025
6b1b65a
feat(svm): Initial SpokeUtils skeleton (#960)
pxrl Apr 8, 2025
1b6939c
fix: epic branch lint (#972)
melisaguevara Apr 8, 2025
dff7f94
improve(Address): Support new EVM address from Base58 encoding
pxrl Apr 8, 2025
60ecbce
Add svm/base16 counterpart
pxrl Apr 8, 2025
58f376c
lint
pxrl Apr 8, 2025
1a79e88
Merge remote-tracking branch 'origin/master'
pxrl Apr 9, 2025
b8aedad
Add test
pxrl Apr 9, 2025
eb9ae20
lint
pxrl Apr 9, 2025
fc4c167
Merge remote-tracking branch 'origin/pxrl/base58EVMAddress' into epic…
pxrl Apr 10, 2025
27ce8ec
chore: Unify svm w/ arch/svm (#977)
pxrl Apr 10, 2025
57d86cd
refactor(sdk): transition SVM events client to use blocks instead of …
james-a-morris Apr 14, 2025
06e400d
feat(svmSpokeUtils): get fill deadline buffer implementation (#978)
melisaguevara Apr 23, 2025
c9ee25e
improve: allow SVM events client to take in non-spoke IDLs (#982)
bmzig Apr 23, 2025
3e20d7e
improve(svmEventsClient): enable pda events querying (#989)
melisaguevara Apr 24, 2025
c5efe4a
feat: create svm spoke _update (#976)
james-a-morris Apr 28, 2025
4f9b54f
improve: add tests for SVM (#1007)
james-a-morris Apr 30, 2025
eec28b8
feat(SvmSpokePoolClient): relayFillStatus and fillStatusArray impleme…
melisaguevara May 7, 2025
549fee8
fix: svm spoke client missing rpc (#1023)
md0x May 7, 2025
5ac115a
feat(SvmSpokeUtils): implement findFillEvent (#998)
melisaguevara May 7, 2025
ef8b147
chore: reuse SVMProvider type (#1025)
melisaguevara May 8, 2025
f7f5163
feat(svm): integration tests with test validator (#1015)
md0x May 8, 2025
0146d3c
feat: Solana relayFeeCalculator and gasPriceOracle (#980)
bmzig May 8, 2025
5bb6a2f
improve: SVM getTimestampForSlot (#1026)
melisaguevara May 8, 2025
68280e9
chore: resync epic branch with master (#1029)
bmzig May 9, 2025
bfd4550
Revert "chore: resync epic branch with master (#1029)"
bmzig May 9, 2025
4c1d608
Merge branch 'master' into epic/svm-client
bmzig May 9, 2025
9b3bb77
feat: add request slow fill and fill functions and event tests (#1028)
md0x May 14, 2025
9d93433
feat(svm-eventsClient): add methods to find deposit and fills from si…
gsteenkamp89 May 14, 2025
5582642
feat(svm): add svm test node hook at root level (#1037)
md0x May 15, 2025
fdbf818
chore: Create release v4.1.63-beta.1 (#1040)
gsteenkamp89 May 15, 2025
da60fb8
feat: expose `getNativeGasCosts` in `svmQuery` (#1039)
dohaki May 15, 2025
feebf2a
chore: bump 4.1.63-beta.2 (#1041)
dohaki May 15, 2025
4041826
improve: generalize block finder interface for EVM (#1038)
bmzig May 15, 2025
4151ac4
feat(sdk): implement Solana-optimized deposit finder (#983)
james-a-morris May 15, 2025
de781d1
chore: bump to next minor version (#1045)
james-a-morris May 15, 2025
94d2ff6
Merge branch 'master' into epic/svm-client
james-a-morris May 15, 2025
e46123f
use named imports only for solana/kit (#1046)
gsteenkamp89 May 16, 2025
8426ced
feat: Mock SVMSpokePoolClient (#1010)
melisaguevara May 16, 2025
809cfba
chore(release): v4.1.63-beta.3 (#1047)
gsteenkamp89 May 16, 2025
11b39d2
feat: add tests for fill related functions of the svm spoke pool clie…
melisaguevara May 16, 2025
1b4dfb1
updates
bmzig May 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# The following is a similar implementation to the Solana Developers GitHub Action:
# https://github.com/solana-developers/github-actions/blob/main/extract-versions/action.yaml
# It is adapted to extract only the Solana version from a remote repository (across-protocol/contracts).
- name: Extract Solana version from across‑protocol/contracts
id: extract-versions
run: |
set -euo pipefail

REPO="across-protocol/contracts"
REF="main"
WORKDIR="/tmp/contracts"
mkdir -p "$WORKDIR"

curl -sSfL "https://raw.githubusercontent.com/${REPO}/${REF}/Cargo.lock" \
-o "${WORKDIR}/Cargo.lock" || true

cd "$WORKDIR"

if [[ -n "${OVERRIDE_SOLANA_VERSION:-}" ]]; then
SOLANA_VERSION="${OVERRIDE_SOLANA_VERSION}"
else
if [[ -f Cargo.lock ]]; then
SOLANA_VERSION=$(grep -A2 'name = "solana-program"' Cargo.lock \
| grep 'version' | head -n1 | cut -d'"' -f2 || true)
fi
fi

SOLANA_VERSION=$(echo "$SOLANA_VERSION" \
| grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+')
echo "SOLANA_VERSION=$SOLANA_VERSION" | tee -a "$GITHUB_ENV"
echo "solana_version=$SOLANA_VERSION" >> "$GITHUB_OUTPUT"

- uses: solana-developers/github-actions/[email protected]
with:
solana_version: ${{ steps.extract-versions.outputs.solana_version }}
- uses: actions/setup-node@v3
with:
node-version: 20
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dist
/coverage
/artifacts
/typechain-types
.ledger

# production
/build
Expand Down
1 change: 1 addition & 0 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const config: HardhatUserConfig = {
},
mocha: {
timeout: 100000,
require: ["./test/Solana.setup.ts"],
},
watcher: {
test: {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@across-protocol/sdk",
"author": "UMA Team",
"version": "4.1.63",
"version": "4.2.0",
"license": "AGPL-3.0",
"homepage": "https://docs.across.to/reference/sdk",
"files": [
Expand Down Expand Up @@ -111,6 +111,8 @@
"@ethersproject/bignumber": "^5.7.0",
"@pinata/sdk": "^2.1.0",
"@solana/kit": "^2.1.0",
"@solana-program/system": "^0.7.0",
"@solana-program/token-2022": "^0.4.0",
"@solana/web3.js": "^1.31.0",
"@types/mocha": "^10.0.1",
"@uma/sdk": "^0.34.10",
Expand Down
209 changes: 209 additions & 0 deletions src/arch/evm/BlockUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import assert from "assert";
import { Provider, Block as EthersBlock } from "@ethersproject/abstract-provider";
import { clamp, sortedIndexBy } from "lodash";
import { chainIsOPStack, getNetworkName } from "../../utils/NetworkUtils";
import { isDefined } from "../../utils/TypeGuards";
import {
BlockFinder,
type Block,
type BlockFinderOpts as Opts,
type BlockTimeAverage,
type BlockFinderHints,
} from "../../utils/BlockFinder";
import { getCurrentTime } from "../../utils/TimeUtils";
import { CHAIN_IDs } from "../../constants";

// Extension of the EthersBlock type which implements `Block`.
interface EVMBlock extends Block, EthersBlock {}

// Archive requests typically commence at 128 blocks past the head of the chain.
// Round down to 120 blocks to avoid slipping into archive territory.
const defaultBlockRange = 120;

// Default offset to the high block number. This is subtracted from the block number of the high block
// when it is queried from the network, rather than having been specified by the caller. This is useful
// since the supplied Provider instance may be backed by multiple RPC providers, which can lead to some
// providers running slower than others and taking time to synchronise on the latest block.
const defaultHighBlockOffset = 10;

// Retain computations for 15 minutes.
const cacheTTL = 60 * 15;
const now = getCurrentTime(); // Seed the cache with initial values.
const blockTimes: { [chainId: number]: BlockTimeAverage } = {
[CHAIN_IDs.INK]: { average: 1, timestamp: now, blockRange: 1 },
[CHAIN_IDs.LINEA]: { average: 3, timestamp: now, blockRange: 1 },
[CHAIN_IDs.MAINNET]: { average: 12.5, timestamp: now, blockRange: 1 },
[CHAIN_IDs.OPTIMISM]: { average: 2, timestamp: now, blockRange: 1 },
[CHAIN_IDs.UNICHAIN]: { average: 1, timestamp: now, blockRange: 1 },
};

/**
* @description Compute the average block time over a block range.
* @returns Average number of seconds per block.
*/
export async function averageBlockTime(
provider: Provider,
{ highBlock, highBlockOffset, blockRange }: Opts = {}
): Promise<Pick<BlockTimeAverage, "average" | "blockRange">> {
// Does not block for StaticJsonRpcProvider.
const { chainId } = await provider.getNetwork();

// OP stack chains inherit Optimism block times, but can be overridden.
const cache = blockTimes[chainId] ?? (chainIsOPStack(chainId) ? blockTimes[CHAIN_IDs.OPTIMISM] : undefined);

const now = getCurrentTime();
if (isDefined(cache) && now < cache.timestamp + cacheTTL) {
return { average: cache.average, blockRange: cache.blockRange };
}

// If the caller was not specific about highBlock, resolve it via the RPC provider. Subtract an offset
// to account for various RPC provider sync issues that might occur when querting the latest block.
if (!isDefined(highBlock)) {
highBlock = await provider.getBlockNumber();
highBlock -= highBlockOffset ?? defaultHighBlockOffset;
}
blockRange ??= defaultBlockRange;

const earliestBlockNumber = highBlock - blockRange;
const [firstBlock, lastBlock] = await Promise.all([
provider.getBlock(earliestBlockNumber),
provider.getBlock(highBlock),
]);
[firstBlock, lastBlock].forEach((block: Block | undefined) => {
if (!isDefined(block?.timestamp)) {
const network = getNetworkName(chainId);
const blockNumber = block === firstBlock ? earliestBlockNumber : highBlock;
throw new Error(`BlockFinder: Failed to fetch block ${blockNumber} on ${network}`);
}
});

const average = (lastBlock.timestamp - firstBlock.timestamp) / blockRange;
blockTimes[chainId] = { timestamp: now, average, blockRange };

return { average, blockRange };
}

async function estimateBlocksElapsed(seconds: number, cushionPercentage = 0.0, provider: Provider): Promise<number> {
const cushionMultiplier = cushionPercentage + 1.0;
const { average } = await averageBlockTime(provider);
return Math.floor((seconds * cushionMultiplier) / average);
}

export class EVMBlockFinder extends BlockFinder<EVMBlock> {
constructor(
private readonly provider: Provider,
private readonly blocks: EVMBlock[] = []
) {
super();
}

/**
* @notice Gets the latest block whose timestamp is <= the provided timestamp.
* @param number Timestamp timestamp to search.
* @param hints Optional low and high block to bound the search space.
*/
public async getBlockForTimestamp(timestamp: number | string, hints: BlockFinderHints = {}): Promise<EVMBlock> {
timestamp = Number(timestamp);
assert(timestamp !== undefined && timestamp !== null, "timestamp must be provided");
// If the last block we have stored is too early, grab the latest block.
if (this.blocks.length === 0 || this.blocks[this.blocks.length - 1].timestamp < timestamp) {
const block = await this.getLatestBlock();
if (timestamp >= block.timestamp) return block;
}

// Prime the BlockFinder cache with any supplied hints.
// If the hint is accurate, then this will bypass the subsequent estimation.
await Promise.all(
Object.values(hints)
.filter((blockNumber) => isDefined(blockNumber))
.map((blockNumber) => this.getBlock(blockNumber))
);

// Check the first block. If it's greater than our timestamp, we need to find an earlier block.
if (this.blocks[0].timestamp > timestamp) {
const initialBlock = this.blocks[0];
// We use a 2x cushion to reduce the number of iterations in the following loop and increase the chance
// that the first block we find sets a floor for the target timestamp. The loop converges on the correct block
// slower than the following incremental search performed by `findBlock`, so we want to minimize the number of
// loop iterations in favor of searching more blocks over the `findBlock` search.
const cushion = 1;
const incrementDistance = Math.max(
// Ensure the increment block distance is _at least_ a single block to prevent an infinite loop.
await estimateBlocksElapsed(initialBlock.timestamp - timestamp, cushion, this.provider),
1
);

// Search backwards by a constant increment until we find a block before the timestamp or hit block 0.
for (let multiplier = 1; ; multiplier++) {
const distance = multiplier * incrementDistance;
const blockNumber = Math.max(0, initialBlock.number - distance);
const block = await this.getBlock(blockNumber);
if (block.timestamp <= timestamp) break; // Found an earlier block.
assert(blockNumber > 0, "timestamp is before block 0"); // Block 0 was not earlier than this timestamp. The row.
}
}

// Find the index where the block would be inserted and use that as the end block (since it is >= the timestamp).
const index = sortedIndexBy(this.blocks, { timestamp } as Block, "timestamp");
return this.findBlock(this.blocks[index - 1], this.blocks[index], timestamp);
}

// Grabs the most recent block and caches it.
private async getLatestBlock() {
const block = await this.provider.getBlock("latest");
const index = sortedIndexBy(this.blocks, block, "number");
if (this.blocks[index]?.number !== block.number) this.blocks.splice(index, 0, block);
return this.blocks[index];
}

// Grabs the block for a particular number and caches it.
private async getBlock(number: number) {
let index = sortedIndexBy(this.blocks, { number } as Block, "number");
if (this.blocks[index]?.number === number) return this.blocks[index]; // Return early if block already exists.
const block = await this.provider.getBlock(number);

// Recompute the index after the async call since the state of this.blocks could have changed!
index = sortedIndexBy(this.blocks, { number } as Block, "number");

// Rerun this check to avoid duplicate insertion.
if (this.blocks[index]?.number === number) return this.blocks[index];
this.blocks.splice(index, 0, block); // A simple insert at index.
return block;
}

// Return the latest block, between startBlock and endBlock, whose timestamp is <= timestamp.
// Effectively, this is an interpolation search algorithm to minimize block requests.
// Note: startBlock and endBlock _must_ be different blocks.
private async findBlock(_startBlock: EVMBlock, _endBlock: EVMBlock, timestamp: number): Promise<EVMBlock> {
const [startBlock, endBlock] = [_startBlock, _endBlock];
// In the case of equality, the endBlock is expected to be passed as the one whose timestamp === the requested
// timestamp.
if (endBlock.timestamp === timestamp) return endBlock;

// If there's no equality, but the blocks are adjacent, return the startBlock, since we want the returned block's
// timestamp to be <= the requested timestamp.
if (endBlock.number === startBlock.number + 1) return startBlock;

assert(endBlock.number !== startBlock.number, "startBlock cannot equal endBlock");
assert(
timestamp < endBlock.timestamp && timestamp > startBlock.timestamp,
"timestamp not in between start and end blocks"
);

// Interpolating the timestamp we're searching for to block numbers.
const totalTimeDifference = endBlock.timestamp - startBlock.timestamp;
const totalBlockDistance = endBlock.number - startBlock.number;
const blockPercentile = (timestamp - startBlock.timestamp) / totalTimeDifference;
const estimatedBlock = startBlock.number + Math.round(blockPercentile * totalBlockDistance);

// Clamp ensures the estimated block is strictly greater than the start block and strictly less than the end block.
const newBlock = await this.getBlock(clamp(estimatedBlock, startBlock.number + 1, endBlock.number - 1));

// Depending on whether the new block is below or above the timestamp, narrow the search space accordingly.
if (newBlock.timestamp < timestamp) {
return this.findBlock(newBlock, endBlock, timestamp);
} else {
return this.findBlock(startBlock, newBlock, timestamp);
}
}
}
10 changes: 5 additions & 5 deletions src/arch/evm/SpokeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export async function findDepositBlock(
export async function relayFillStatus(
spokePool: Contract,
relayData: RelayData,
blockTag?: number | "latest",
blockTag: BlockTag = "latest",
destinationChainId?: number
): Promise<FillStatus> {
destinationChainId ??= await spokePool.chainId();
Expand Down Expand Up @@ -290,20 +290,20 @@ export async function findFillEvent(
if (!blockNumber) return undefined;

// We can hardcode this to 0 to instruct paginatedEventQuery to make a single request for the same block number.
const maxBlockLookBack = 0;
const [fromBlock, toBlock] = [blockNumber, blockNumber];
const maxLookBack = 0;
const [from, to] = [blockNumber, blockNumber];

const query = (
await Promise.all([
paginatedEventQuery(
spokePool,
spokePool.filters.FilledRelay(null, null, null, null, null, relayData.originChainId, relayData.depositId),
{ fromBlock, toBlock, maxBlockLookBack }
{ from, to, maxLookBack }
),
paginatedEventQuery(
spokePool,
spokePool.filters.FilledV3Relay(null, null, null, null, null, relayData.originChainId, relayData.depositId),
{ fromBlock, toBlock, maxBlockLookBack }
{ from, to, maxLookBack }
),
])
).flat();
Expand Down
1 change: 1 addition & 0 deletions src/arch/evm/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./SpokeUtils";
export * from "./BlockUtils";
Loading