Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ jobs:
run: yarn lint-rust
- name: Test rust
run: cargo test -p svm-spoke --lib
- name: Test Gateway hash vectors (no private dependency)
run: yarn test-svm-gateway-vectors
- name: Regenerate constants.json
run: yarn generate-constants-json && yarn prettier --write generated/constants.json
- name: Regenerate deployed-addresses
Expand Down Expand Up @@ -142,6 +144,7 @@ jobs:
- 'scripts/svm/**'
- 'src/svm/**'
- 'test/svm/**'
- 'test/svm-gateway/**'
- name: Download SVM artifacts
uses: actions/download-artifact@v4
with:
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ HubPool on L1 owns all L2 SpokePools. Admin functions are relayed cross-chain vi

Located in `contracts/periphery/mintburn/`. A modular framework for executing cross-chain sponsored token flows using mint-burn bridge integrations (CCTP, LayerZero OFT). Off-chain signers authorize transfer parameters via signed quotes; source periphery contracts validate quotes and initiate bridge transfers, while destination handlers receive bridged tokens and execute on-chain actions (swaps, HyperCore transfers, or arbitrary multicalls). Bridge-specific peripheries live in `sponsored-cctp/` and `sponsored-oft/` subdirectories.

### SVM Across V5 Gateway integration

`svm_spoke` is a direct Gateway adapter for V5 source deposits and destination fills. External fills transfer tokens;
in-place fills check the shared vault, leaving downstream delivery to the committed Gateway path. See the
[adapter spec](programs/svm-spoke/V5_ADAPTER_SPEC.md#pda-and-token-invariants) for authoritative delivery/security rules
and the [integration guide](test/svm-gateway/README.md) for real-Gateway test execution and coverage.

### Deployments

Canonical deployed addresses are generated into `broadcast/deployed-addresses.json`, with `broadcast/deployed-addresses.md` as the readable companion. `deployments/legacy-addresses.json` is still included for legacy Hardhat deployments. In Foundry scripts, use `script/utils/DeploymentUtils.sol` lookup helpers such as `getDeployedAddress()` and `getSpokePoolDeploymentInfo()`.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"test-evm": "yarn test-evm-foundry",
"test-evm-foundry": "FOUNDRY_PROFILE=local-test forge test",
"test-svm": "IS_TEST=true yarn build-svm && yarn generate-svm-artifacts && yarn generate-svm-test-idls && anchor test --skip-build",
"test-svm-gateway": "ts-node scripts/svm/testRealGateway.ts",
"test-svm-gateway-vectors": "NODE_OPTIONS=--no-experimental-strip-types ts-mocha -p tsconfig.json -t 10000 test/svm-gateway/PathVectors.ts",
"test-svm-solana-verify": "IS_TEST=true yarn build-svm-solana-verify && yarn generate-svm-artifacts && yarn generate-svm-test-idls && anchor test --skip-build",
"test": "yarn test-evm && yarn test-svm",
"test-verified": "yarn test-evm && yarn test-svm-solana-verify",
Expand Down
38 changes: 37 additions & 1 deletion programs/svm-spoke/V5_ADAPTER_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,21 @@ Gateway-vault delivery validates that same live vault in place and its amount, r
self-transfer or approval. This asserts available balance rather than debiting it. A step root may be reused across
source deposits, but canonical builders must either allow at most one in-place fill before a post-fill floor and
full-balance terminal consumption, or enforce a cumulative floor covering every in-place fill recorded before that
consumption. A fixed minimum for one fill does not prove aggregate delivery.
consumption. The committed terminal outcome must be acceptable to every deposit matching the root. A fixed minimum
for one fill does not prove aggregate delivery.

The obligation covers **actual JIT output amounts for all allowed executions**, not just the sum of committed
`min_output_amount` values or the amounts in a sampled quote. Two fills can each accept output `2X` against a shared
balance of `2X`; a later floor of `2X` and full drain still deliver only half the `4X` recorded obligation. Keep
aggregate paths disabled unless their permitted relay/JIT choices and cumulative delivery are authenticated together.
Swap/action/planner paths require an equivalent proportional or aggregate postcondition on the committed final
outcome. A successful path with missing or short consumption retains its recorded fills; atomicity only rolls back a
transaction that actually fails. Failed transaction logs may contain attempted fill events; consumers must check
transaction success before accepting them.

This delivery obligation is shared with EVM. API builders and relayers must port the existing policy and applicable
conformance cases before enabling SVM routes. This test lane alone does not enable a production route or require
new aggregation support.

Fill-status expiry reclaim is permissionless and closes back to the submitter-scoped payer PDA, replenishing its
standing float. Only that submitter may withdraw the float to itself. Partial withdrawals remain subject to Solana's
Expand Down Expand Up @@ -176,3 +190,25 @@ Golden values in `fixtures/v5_adapter_v1.json` are independently re-derived from
catch byte-width, packing, and endianness drift. These are cross-language self-consistency vectors, not an invocation
of the EVM adapter. The JIT digest layout matches `AcrossDepositDelegateAdapter`, while SVM deposit identity
necessarily uses a 32-byte executor program ID instead of EVM's 20-byte caller address.

## Real Gateway conformance

`yarn test-svm-gateway` builds the actual Gateway and prefunded adapter at
[`457cf693`](https://github.com/across-protocol/solana-v5/commit/457cf693d09765c8e7e9ab33d23f84cba0999afe)
and runs a separate validator alongside this checkout's test-feature SpokePool. The normal Anchor suite retains its
mock. The foreign programs have their own Anchor version; there is no cross-repository Rust dependency.

At this pin, Gateway `remaining_accounts` is only an account lookup pool. Each `ADAPTER_CALL` must name its entire
callee account list after the dispatch signer: the fixed state/event/program prefix and all branch accounts. A
committed zero-key writable injected slot identifies each fill-status/payer account; its actual key prefixes that
command's JIT payload and is independently authenticated by the SpokePool's PDA derivations. Prefunded calls similarly
inject the witness-derived credit and its payer before the adapter's payer-claim payload. Supplying an account only in
the outer pool does not forward it. Committed signer metas and duplicate dispatch metas are rejected.

The integration suite derives relays from actual origin deposit events, binds `dst_step_id` to a destination root,
exercises both siblings and separately funded root reuse, and distinguishes safe consumption from deliberately
accepted unsafe primitives. Its aggregate examples prove or disprove delivery for concrete executions; they do not
authenticate all JIT variants of an aggregate production route. The canonical reference constructor only emits the
single-fill template. `fixtures/v5_gateway_path.json` additionally pins Borsh consumption-tape bytes, path hashes,
sorted sibling roots and witnesses across TypeScript, Rust and Solidity. Its placeholder keys are hashing fixtures,
not deployed token accounts. See [the lane guide](../../test/svm-gateway/README.md) for execution and companion docs.
15 changes: 15 additions & 0 deletions programs/svm-spoke/fixtures/v5_gateway_path.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"gatewayCommit": "457cf693d09765c8e7e9ab33d23f84cba0999afe",
"chainId": 420,
"executor": "0x1eb6bdb6c483ad6e0d22ef22f9e874b14721fe0fa978038dc0da56d201337afd",
"mint": "0x1111111111111111111111111111111111111111111111111111111111111111",
"recipient": "0x2222222222222222222222222222222222222222222222222222222222222222",
"minimum": "500000",
"message": "0x0200000000110200000028000000111111111111111111111111111111111111111111111111111111111111111120a10700000000004a00000011111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222ffffffffffffffff1027",
"salt": "0x3333333333333333333333333333333333333333333333333333333333333333",
"siblingSalt": "0x4444444444444444444444444444444444444444444444444444444444444444",
"pathId": "0x5fa7c50b60c851dd77b3a72bd58227e2a8f727ac5f3995a601fee21b6b770e53",
"siblingPathId": "0xdda7db23b707cffe6005b0549c85b73c4d1f9527f015dd6426b8acb8b0f69f64",
"stepRoot": "0x7a8101439c582145b305012d7533cec61241bceaebaefb93be5373d2fbecc9f4",
"witness": "0x89ae4bc75915265a3f10e926c3894a29534f1d6362ee8959cb0e5be00f3527fd7a8101439c582145b305012d7533cec61241bceaebaefb93be5373d2fbecc9f4"
}
33 changes: 33 additions & 0 deletions programs/svm-spoke/src/v5/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ fn adapter_discriminator_matches_gateway_abi() {
assert_eq!(GATEWAY_ADAPTER_EXECUTE_V5_DISCRIMINATOR, crate::instruction::AdapterExecuteAcrossV5::DISCRIMINATOR,);
}

#[test]
fn gateway_path_root_and_witness_match_cross_vm_fixture() {
let fixture: Value = serde_json::from_str(include_str!("../../fixtures/v5_gateway_path.json")).unwrap();
let mut chain_word = [0u8; 32];
chain_word[24..].copy_from_slice(&fixture["chainId"].as_u64().unwrap().to_be_bytes());
let message_hash = keccak::hash(&bytes(&fixture, "/message")).to_bytes();
let path_hash = |salt: &str| {
keccak::hashv(&[
&chain_word,
&bytes(&fixture, salt),
&bytes(&fixture, "/executor"),
&message_hash,
])
.to_bytes()
};
let a = path_hash("/salt");
let b = path_hash("/siblingSalt");
assert_eq!(a, array::<32>(&fixture, "/pathId"));
assert_eq!(b, array::<32>(&fixture, "/siblingPathId"));
let pair = |a: [u8; 32], b: [u8; 32]| {
if a < b {
keccak::hashv(&[&a, &b])
} else {
keccak::hashv(&[&b, &a])
}
.to_bytes()
};
let root = pair(a, b);
assert_eq!(root, array::<32>(&fixture, "/stepRoot"));
assert_eq!(pair(b, a), root);
assert_eq!([crate::constants::V5_MAGIC_PREFIX.as_slice(), &root].concat(), bytes(&fixture, "/witness"));
}

#[test]
fn v1_wire_and_gateway_dispatch_match_golden_fixture() {
let fixture = fixture();
Expand Down
180 changes: 180 additions & 0 deletions scripts/svm/testRealGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Separate validator: the ordinary Anchor suite installs mock_gateway at the
// same address. Build both foreign programs from an immutable source checkout.
import { spawn, spawnSync } from "child_process";
import { mkdirSync, mkdtempSync, readFileSync, openSync, closeSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createServer } from "net";
import { Keypair } from "@solana/web3.js";
import { GATEWAY, PREFUNDED, GATEWAY_COMMIT } from "../../test/svm-gateway/reference";

function run(command: string, args: string[], cwd = process.cwd()) {
const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${command} failed (${result.status})`);
}
async function freePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
async function main() {
const work = mkdtempSync(path.join(tmpdir(), "acp184-gateway-"));
const checkout = process.env.SVM_GATEWAY_CHECKOUT || path.join(work, "solana-v5");
if (!process.env.SVM_GATEWAY_CHECKOUT) {
run("git", ["clone", "https://github.com/across-protocol/solana-v5.git", checkout]);
run("git", ["checkout", "--detach", GATEWAY_COMMIT], checkout);
}
const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: checkout, encoding: "utf8" });
const dirty = spawnSync("git", ["status", "--porcelain", "--untracked-files=normal"], {
cwd: checkout,
encoding: "utf8",
});
if (head.status !== 0 || head.stdout.trim() !== GATEWAY_COMMIT || dirty.status !== 0 || dirty.stdout.trim()) {
throw new Error(`Gateway checkout must be clean at ${GATEWAY_COMMIT}`);
}
const foreignAnchor = process.env.SVM_GATEWAY_ANCHOR || "anchor";
const gatewayIdlDir = path.join(work, "idl");
mkdirSync(gatewayIdlDir);
for (const name of ["gateway", "prefunded_adapter"]) {
run(foreignAnchor, ["build", "--program-name", name, "--ignore-keys", "--no-idl"], checkout);
run(
foreignAnchor,
["idl", "build", "--program-name", name, "--out", path.join(gatewayIdlDir, `${name}.json`)],
checkout
);
}
// IS_TEST is not sufficient for local Anchor builds: pass the feature explicitly.
const spokeAnchor = process.env.SVM_SPOKE_ANCHOR || "anchor";
run("cargo", [
"build-sbf",
"--tools-version",
"v1.52",
"--manifest-path",
"programs/svm-spoke/Cargo.toml",
"--sbf-out-dir",
"target/deploy",
"--features",
"test",
]);
for (const directory of ["target/idl", "target/types"]) mkdirSync(directory, { recursive: true });
run(spokeAnchor, [
"idl",
"build",
"--program-name",
"svm_spoke",
"--out",
"target/idl/svm_spoke.json",
"--out-ts",
"target/types/svm_spoke.ts",
"--",
"--features",
"test",
]);

const spokeId = JSON.parse(readFileSync("target/idl/svm_spoke.json", "utf8")).address;
const walletPath = path.resolve("test/svm/keys/localnet-wallet.json");
const wallet = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(walletPath, "utf8"))));
const rpcPort = await freePort();
const faucetPort = await freePort();
const url = `http://127.0.0.1:${rpcPort}`;
const logPath = path.join(work, "validator.log");
const log = openSync(logPath, "w");
const validator = spawn(
"solana-test-validator",
[
"--ledger",
path.join(work, "ledger"),
"--bind-address",
"127.0.0.1",
"--rpc-port",
String(rpcPort),
"--faucet-port",
String(faucetPort),
"--gossip-port",
String(await freePort()),
"--quiet",
"--mint",
wallet.publicKey.toBase58(),
"--upgradeable-program",
GATEWAY.toBase58(),
path.join(checkout, "target/deploy/gateway.so"),
wallet.publicKey.toBase58(),
"--upgradeable-program",
PREFUNDED.toBase58(),
path.join(checkout, "target/deploy/prefunded_adapter.so"),
wallet.publicKey.toBase58(),
"--upgradeable-program",
spokeId,
path.resolve("target/deploy/svm_spoke.so"),
wallet.publicKey.toBase58(),
],
{ stdio: ["ignore", log, log] }
);
let spawnFailure: Error | undefined;
validator.once("error", (error) => {
spawnFailure = error;
});
const closed = new Promise<void>((resolve) => validator.once("close", () => resolve()));
const stop = () => validator.kill("SIGTERM");
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
try {
let ready = false;
for (let attempt = 0; attempt < 120; attempt++) {
if (spawnFailure) throw spawnFailure;
if (validator.exitCode !== null || validator.signalCode !== null)
throw new Error(`Validator exited; see ${logPath}`);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }),
});
if (((await response.json()) as { result?: string }).result === "ok") {
ready = true;
break;
}
} catch {
/* RPC starts after genesis setup. */
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
if (!ready) throw new Error(`Validator did not start; see ${logPath}`);
console.log(`Real Gateway ${GATEWAY_COMMIT}; validator logs: ${logPath}`);
const tests = spawnSync(
"yarn",
[
"ts-mocha",
"--bail",
"-p",
"tsconfig.json",
"-t",
"1000000",
"test/svm-gateway/PathVectors.ts",
"test/svm-gateway/RealGateway.ts",
],
{
stdio: "inherit",
env: {
...process.env,
ANCHOR_PROVIDER_URL: url,
ANCHOR_WALLET: walletPath,
SVM_GATEWAY_IDL_DIR: gatewayIdlDir,
NODE_OPTIONS: "--no-experimental-strip-types",
},
}
);
if (tests.error || tests.status !== 0) throw new Error(`Real-Gateway tests failed; see ${logPath}`);
} finally {
stop();
await closed;
closeSync(log);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
21 changes: 21 additions & 0 deletions test/evm/foundry/local/SvmSpokeV5Vectors.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pragma solidity ^0.8.0;

import { Test } from "forge-std/Test.sol";
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import { Hashes } from "@openzeppelin/contracts/utils/cryptography/Hashes.sol";

/// @notice EVM-side conformance checks for the SVM V5 adapter's cross-VM hash and signature fixtures.
contract SvmSpokeV5VectorsTest is Test {
Expand Down Expand Up @@ -58,6 +59,26 @@ contract SvmSpokeV5VectorsTest is Test {
assertEq(bytes8(sha256("global:adapter_execute_across_v5")), bytes8(discriminator));
}

function testGatewayPathAndStepRootVector() public view {
string memory pathFixture = vm.readFile("programs/svm-spoke/fixtures/v5_gateway_path.json");
bytes32 messageHash = keccak256(vm.parseJsonBytes(pathFixture, ".message"));
uint256 chainId = vm.parseJsonUint(pathFixture, ".chainId");
bytes32 executor = vm.parseJsonBytes32(pathFixture, ".executor");
bytes32 a = keccak256(abi.encode(chainId, vm.parseJsonBytes32(pathFixture, ".salt"), executor, messageHash));
bytes32 b = keccak256(
abi.encode(chainId, vm.parseJsonBytes32(pathFixture, ".siblingSalt"), executor, messageHash)
);
assertEq(a, vm.parseJsonBytes32(pathFixture, ".pathId"));
assertEq(b, vm.parseJsonBytes32(pathFixture, ".siblingPathId"));
bytes32 root = Hashes.commutativeKeccak256(a, b);
assertEq(root, vm.parseJsonBytes32(pathFixture, ".stepRoot"));
assertEq(Hashes.commutativeKeccak256(b, a), root);
assertEq(
abi.encodePacked(keccak256("AcrossV5MessagePrefix.V1"), root),
vm.parseJsonBytes(pathFixture, ".witness")
);
}

function _jitDigest(bytes32 domain) internal view returns (bytes32) {
return
keccak256(
Expand Down
Loading
Loading