diff --git a/examples/oft-solana/README.md b/examples/oft-solana/README.md index b773a558c..72ae955a4 100644 --- a/examples/oft-solana/README.md +++ b/examples/oft-solana/README.md @@ -47,6 +47,7 @@ - Rust `1.84.1` - Anchor `0.31.1` - Solana CLI `2.2.20` +- Surfpool CLI (required for `pnpm test:anchor`) - Docker `28.3.0` - Node.js `>=20.19.5` - `pnpm` (recommended) - or another package manager of your choice (npm, yarn) @@ -360,6 +361,24 @@ Before deploying, ensure the following: pnpm test ``` +To run the Surfpool-backed Solana tests: + +```bash +pnpm test:anchor +``` + +`pnpm test:anchor` starts a Surfnet forked from mainnet-beta by default. If mainnet-beta state blocks initialization (pre-existing PDAs), set a devnet upstream instead: + +```bash +SURFPOOL_RPC_URL=https://api.devnet.solana.com pnpm test:anchor +``` + +To avoid upstream state entirely, deploy local LayerZero program binaries into Surfnet: + +```bash +SURFPOOL_USE_LOCAL_PROGRAMS=1 pnpm test:anchor +``` + ### Adding other chains To add additional chains to your OFT deployment: diff --git a/examples/oft-solana/package.json b/examples/oft-solana/package.json index afb61f0c5..960870737 100644 --- a/examples/oft-solana/package.json +++ b/examples/oft-solana/package.json @@ -13,9 +13,8 @@ "lint:js": "eslint '**/*.{js,ts,json}' && prettier --check .", "lint:sol": "solhint 'contracts/**/*.sol'", "test": "$npm_execpath run test:forge && $npm_execpath run test:hardhat", - "test:anchor": "$npm_execpath run test:generate-features && OFT_ID=9UovNrJD8pQyBLheeHNayuG1wJSEAoxkmM14vw5gcsTT anchor build --no-idl && $npm_execpath exec ts-mocha -b -p ./tsconfig.json -t 10000000 test/anchor/index.test.ts", + "test:anchor": "OFT_ID=$(node scripts/resolve-oft-id.js) anchor build --no-idl && $npm_execpath exec ts-mocha -b -p ./tsconfig.json -t 10000000 test/anchor/index.test.ts", "test:forge": "forge test", - "test:generate-features": "ts-node scripts/generate-features.ts", "test:hardhat": "hardhat test", "test:scripts": "jest --config jest.config.ts --runInBand --testMatch \"**/*.script.test.ts\"" }, @@ -24,11 +23,6 @@ "ethers": "^5.7.2", "hardhat-deploy": "^0.12.1" }, - "overrides": { - "@solana/web3.js": "^1.98.0", - "ethers": "^5.7.2", - "hardhat-deploy": "^0.12.1" - }, "devDependencies": { "@coral-xyz/anchor": "^0.31.1", "@ethersproject/abi": "^5.7.0", @@ -124,5 +118,10 @@ "ethers": "^5.7.2", "hardhat-deploy": "^0.12.1" } + }, + "overrides": { + "@solana/web3.js": "^1.98.0", + "ethers": "^5.7.2", + "hardhat-deploy": "^0.12.1" } } diff --git a/examples/oft-solana/scripts/generate-features.ts b/examples/oft-solana/scripts/generate-features.ts deleted file mode 100644 index 19f6f901d..000000000 --- a/examples/oft-solana/scripts/generate-features.ts +++ /dev/null @@ -1,92 +0,0 @@ -import fs from 'fs' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import path from 'path' - -interface FeatureInfo { - description: string - id: string - status: string -} - -interface FeaturesResponse { - features: FeatureInfo[] -} - -const execFileAsync = promisify(execFile) - -async function fetchFeatures(rpc: string): Promise { - const { stdout } = await execFileAsync( - 'solana', - ['feature', 'status', '-u', rpc, '--display-all', '--output', 'json-compact'], - { maxBuffer: 10 * 1024 * 1024 } - ) - - return JSON.parse(stdout.trim()) as FeaturesResponse -} - -async function generateFeatures(): Promise { - console.log('Retrieving mainnet feature flags...') - - try { - const rpcEndpoints = [ - 'https://api.mainnet-beta.solana.com', - 'https://solana-rpc.publicnode.com', - 'https://rpc.ankr.com/solana', - ] - - let features: FeaturesResponse | null = null - let lastError: unknown = null - let successfulRpc: string | null = null - - for (const rpc of rpcEndpoints) { - try { - console.log(` Trying ${rpc}...`) - features = await fetchFeatures(rpc) - successfulRpc = rpc - break - } catch (error) { - lastError = error - console.log(` Failed with ${rpc}, trying next...`) - } - } - - if (!features) { - throw lastError || new Error('All RPC endpoints failed') - } - - const inactiveFeatures = features.features.filter((feature) => feature.status === 'inactive') - - console.log(`Found ${inactiveFeatures.length} inactive features`) - - const targetDir = path.join(__dirname, '../target/programs') - const featuresFile = path.join(targetDir, 'features.json') - - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, { recursive: true }) - } - - const featuresData = { - timestamp: new Date().toISOString(), - source: successfulRpc, - totalFeatures: features.features.length, - inactiveFeatures, - inactiveCount: inactiveFeatures.length, - } - - fs.writeFileSync(featuresFile, JSON.stringify(featuresData, null, 2)) - - console.log(`Features data saved to ${featuresFile}`) - console.log(`Cached ${inactiveFeatures.length} inactive features for faster test startup`) - } catch (error) { - console.error('Failed to retrieve features:', error) - process.exit(1) - } -} - -;(async (): Promise => { - await generateFeatures() -})().catch((err: unknown) => { - console.error(err) - process.exit(1) -}) diff --git a/examples/oft-solana/scripts/resolve-oft-id.js b/examples/oft-solana/scripts/resolve-oft-id.js new file mode 100644 index 000000000..e941a4b2b --- /dev/null +++ b/examples/oft-solana/scripts/resolve-oft-id.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +'use strict'; + +// This script is used specifically in the `test:anchor` npm script to resolve +// the OFT program ID and output it to stdout. The test/anchor/constants.ts file +// has its own implementation since it needs the value at TypeScript import time. + +const fs = require('fs'); + +const { Keypair } = require('@solana/web3.js'); + +const keypairPath = 'target/deploy/oft-keypair.json'; +const fallbackId = '9UovNrJD8pQyBLheeHNayuG1wJSEAoxkmM14vw5gcsTT'; + +function resolveOftId() { + try { + if (!fs.existsSync(keypairPath)) { + return fallbackId; + } + + const secret = JSON.parse(fs.readFileSync(keypairPath, 'utf8')); + return Keypair.fromSecretKey(Uint8Array.from(secret)).publicKey.toBase58(); + } catch (error) { + return fallbackId; + } +} + +process.stdout.write(resolveOftId()); diff --git a/examples/oft-solana/test/anchor/constants.ts b/examples/oft-solana/test/anchor/constants.ts index 8d9acf8be..a0c090828 100755 --- a/examples/oft-solana/test/anchor/constants.ts +++ b/examples/oft-solana/test/anchor/constants.ts @@ -1,5 +1,9 @@ +import fs from 'fs' +import path from 'path' + import { publicKey } from '@metaplex-foundation/umi' import { utils } from '@noble/secp256k1' +import { Keypair } from '@solana/web3.js' import { UMI } from '@layerzerolabs/lz-solana-sdk-v2' @@ -8,7 +12,19 @@ export const DST_EID = 50125 export const INVALID_EID = 999999 // Non-existent EID for testing export const TON_EID = 50343 -export const OFT_PROGRAM_ID = publicKey('9UovNrJD8pQyBLheeHNayuG1wJSEAoxkmM14vw5gcsTT') +const DEFAULT_OFT_KEYPAIR = path.resolve(process.cwd(), 'target/deploy/oft-keypair.json') +const OFT_PROGRAM_ID_VALUE = + process.env.OFT_ID ?? readKeypairPublicKey(DEFAULT_OFT_KEYPAIR) ?? '9UovNrJD8pQyBLheeHNayuG1wJSEAoxkmM14vw5gcsTT' +const ENDPOINT_PROGRAM_ID_VALUE = process.env.LZ_ENDPOINT_PROGRAM_ID ?? '76y77prsiCMvXMjuoZ5VRrhG5qYBrUMYTE5WgHqgjEn6' +const ULN_PROGRAM_ID_VALUE = process.env.LZ_ULN_PROGRAM_ID ?? '7a4WjyR8VZ7yZz5XJAKm39BUGn5iT9CKcv2pmG9tdXVH' +const EXECUTOR_PROGRAM_ID_VALUE = process.env.LZ_EXECUTOR_PROGRAM_ID ?? '6doghB248px58JSSwG4qejQ46kFMW4AMj7vzJnWZHNZn' +const PRICEFEED_PROGRAM_ID_VALUE = process.env.LZ_PRICEFEED_PROGRAM_ID ?? '8ahPGPjEbpgGaZx2NV1iG5Shj7TDwvsjkEDcGWjt94TP' +const DVN_PROGRAM_IDS_VALUE = (process.env.LZ_DVN_PROGRAM_IDS ?? 'HtEYV4xB4wvsj5fgTkcfuChYpvGYzgzwvNhgDZQNh7wW') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + +export const OFT_PROGRAM_ID = publicKey(OFT_PROGRAM_ID_VALUE) export const DVN_SIGNERS = new Array(4).fill(0).map(() => utils.randomPrivateKey()) @@ -16,18 +32,24 @@ export const OFT_DECIMALS = 6 export const defaultMultiplierBps = 12500 // 125% -export const simpleMessageLib: UMI.SimpleMessageLibProgram.SimpleMessageLib = - new UMI.SimpleMessageLibProgram.SimpleMessageLib(UMI.SimpleMessageLibProgram.SIMPLE_MESSAGELIB_PROGRAM_ID) - -export const endpoint: UMI.EndpointProgram.Endpoint = new UMI.EndpointProgram.Endpoint( - UMI.EndpointProgram.ENDPOINT_PROGRAM_ID -) -export const uln: UMI.UlnProgram.Uln = new UMI.UlnProgram.Uln(UMI.UlnProgram.ULN_PROGRAM_ID) -export const executor: UMI.ExecutorProgram.Executor = new UMI.ExecutorProgram.Executor( - UMI.ExecutorProgram.EXECUTOR_PROGRAM_ID -) -export const priceFeed: UMI.PriceFeedProgram.PriceFeed = new UMI.PriceFeedProgram.PriceFeed( - UMI.PriceFeedProgram.PRICEFEED_PROGRAM_ID -) - -export const dvns = [publicKey('HtEYV4xB4wvsj5fgTkcfuChYpvGYzgzwvNhgDZQNh7wW')] +export const endpoint: UMI.EndpointProgram.Endpoint = new UMI.EndpointProgram.Endpoint(ENDPOINT_PROGRAM_ID_VALUE) +export const uln: UMI.UlnProgram.Uln = new UMI.UlnProgram.Uln(ULN_PROGRAM_ID_VALUE) +export const executor: UMI.ExecutorProgram.Executor = new UMI.ExecutorProgram.Executor(EXECUTOR_PROGRAM_ID_VALUE) +export const priceFeed: UMI.PriceFeedProgram.PriceFeed = new UMI.PriceFeedProgram.PriceFeed(PRICEFEED_PROGRAM_ID_VALUE) + +export const dvns = DVN_PROGRAM_IDS_VALUE.map((value) => publicKey(value)) + +function readKeypairPublicKey(keypairPath: string): string | undefined { + if (!fs.existsSync(keypairPath)) { + return undefined + } + + try { + const secret = JSON.parse(fs.readFileSync(keypairPath, 'utf-8')) as number[] + const keypair = Keypair.fromSecretKey(Uint8Array.from(secret)) + return keypair.publicKey.toBase58() + } catch (error) { + console.warn(`Failed to read keypair at ${keypairPath}: ${String(error)}`) + return undefined + } +} diff --git a/examples/oft-solana/test/anchor/got-shim.cjs b/examples/oft-solana/test/anchor/got-shim.cjs index be034b3f0..432faef21 100644 --- a/examples/oft-solana/test/anchor/got-shim.cjs +++ b/examples/oft-solana/test/anchor/got-shim.cjs @@ -1,7 +1,59 @@ +const fs = require('fs'); +const path = require('path'); const Module = require('module'); +const { Keypair } = require('@solana/web3.js'); const originalLoad = Module._load; +const ROOT_DIR = path.join(__dirname, '..', '..'); +const KEYPAIR_DIR = path.join(ROOT_DIR, 'target', 'surfnet-programs'); + +const USE_LOCAL_PROGRAMS = process.env.SURFPOOL_USE_LOCAL_PROGRAMS === '1'; +process.env.SURFPOOL_USE_LOCAL_PROGRAMS = USE_LOCAL_PROGRAMS ? '1' : '0'; + +if (USE_LOCAL_PROGRAMS) { + if (!process.env.SURFPOOL_OFFLINE) { + process.env.SURFPOOL_OFFLINE = '1'; + } + if (!fs.existsSync(KEYPAIR_DIR)) { + fs.mkdirSync(KEYPAIR_DIR, { recursive: true }); + } + + const programKeypairs = [ + { name: 'endpoint', envId: 'LZ_ENDPOINT_PROGRAM_ID', envKeypair: 'LZ_ENDPOINT_PROGRAM_KEYPAIR' }, + { name: 'uln', envId: 'LZ_ULN_PROGRAM_ID', envKeypair: 'LZ_ULN_PROGRAM_KEYPAIR' }, + { name: 'executor', envId: 'LZ_EXECUTOR_PROGRAM_ID', envKeypair: 'LZ_EXECUTOR_PROGRAM_KEYPAIR' }, + { name: 'pricefeed', envId: 'LZ_PRICEFEED_PROGRAM_ID', envKeypair: 'LZ_PRICEFEED_PROGRAM_KEYPAIR' }, + { name: 'dvn', envId: 'LZ_DVN_PROGRAM_ID', envKeypair: 'LZ_DVN_PROGRAM_KEYPAIR' }, + ]; + + const dvnIds = []; + programKeypairs.forEach((program) => { + const keypairPath = path.join(KEYPAIR_DIR, `${program.name}-keypair.json`); + + if (!fs.existsSync(keypairPath)) { + const keypair = Keypair.generate(); + fs.writeFileSync(keypairPath, JSON.stringify(Array.from(keypair.secretKey))); + if (!process.env[program.envId]) { + process.env[program.envId] = keypair.publicKey.toBase58(); + } + } else if (!process.env[program.envId]) { + const secret = JSON.parse(fs.readFileSync(keypairPath, 'utf-8')); + const keypair = Keypair.fromSecretKey(Uint8Array.from(secret)); + process.env[program.envId] = keypair.publicKey.toBase58(); + } + + process.env[program.envKeypair] = keypairPath; + if (program.name === 'dvn') { + dvnIds.push(process.env[program.envId]); + } + }); + + if (!process.env.LZ_DVN_PROGRAM_IDS && dvnIds.length) { + process.env.LZ_DVN_PROGRAM_IDS = dvnIds.join(','); + } +} + const gotStub = { default: Object.assign( async () => { diff --git a/examples/oft-solana/test/anchor/index.test.ts b/examples/oft-solana/test/anchor/index.test.ts index 5c04e6dc0..926ae93e5 100644 --- a/examples/oft-solana/test/anchor/index.test.ts +++ b/examples/oft-solana/test/anchor/index.test.ts @@ -13,25 +13,79 @@ import { createNullContext, createSignerFromKeypair, generateSigner, + publicKeyBytes, sol, } from '@metaplex-foundation/umi' import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' -import { Connection } from '@solana/web3.js' +import { Connection, Keypair } from '@solana/web3.js' import axios from 'axios' +import { UMI } from '@layerzerolabs/lz-solana-sdk-v2' import { OftPDA, oft } from '@layerzerolabs/oft-v2-solana-sdk' -import { OFT_PROGRAM_ID } from './constants' +import { DST_EID, OFT_PROGRAM_ID, SRC_EID, dvns, endpoint, executor, priceFeed, uln } from './constants' import { createOftKeySets } from './helpers' import { OftKeySets, TestContext } from './types' -const RPC_PORT = '13033' -const FAUCET_PORT = '13133' -const RPC = `http://localhost:${RPC_PORT}` +type SurfnetProgram = { + name: string + id: string + binary?: string + keypairEnv?: string +} + +const RPC_PORT = env.SURFPOOL_RPC_PORT ?? '13033' +const RPC_HOST = env.SURFPOOL_HOST ?? '127.0.0.1' +const WS_PORT = env.SURFPOOL_WS_PORT ?? `${Number(RPC_PORT) + 1}` +const RPC = `http://${RPC_HOST}:${RPC_PORT}` +const UPSTREAM_RPC_URL = env.SURFPOOL_RPC_URL ?? 'https://api.mainnet-beta.solana.com' +const USE_LOCAL_PROGRAMS = env.SURFPOOL_USE_LOCAL_PROGRAMS === '1' +const SURFPOOL_OFFLINE = env.SURFPOOL_OFFLINE === '1' +const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111' +const EMPTY_ACCOUNT_DATA_HEX = '' +const SURFPOOL_LOG = path.join(__dirname, '../../target/surfpool.log') +const JUNK_KEYPAIR_PATH = path.join(__dirname, '../../junk-id.json') +const OFT_PROGRAM_PATH = path.join(__dirname, '../../target/deploy/oft.so') +const OFT_KEYPAIR_PATH = path.join(__dirname, '../../target/deploy/oft-keypair.json') +const TARGET_PROGRAMS_DIR = path.join(__dirname, '../../target/programs') +const DVN_PROGRAM_IDS = dvns.map((dvn) => dvn.toString()) + +const LAYERZERO_PROGRAMS: SurfnetProgram[] = [ + { + name: 'endpoint', + id: endpoint.programId.toString(), + binary: 'endpoint.so', + keypairEnv: 'LZ_ENDPOINT_PROGRAM_KEYPAIR', + }, + { + name: 'uln', + id: uln.programId.toString(), + binary: 'uln.so', + keypairEnv: 'LZ_ULN_PROGRAM_KEYPAIR', + }, + { + name: 'executor', + id: executor.programId.toString(), + binary: 'executor.so', + keypairEnv: 'LZ_EXECUTOR_PROGRAM_KEYPAIR', + }, + { + name: 'pricefeed', + id: priceFeed.programId.toString(), + binary: 'pricefeed.so', + keypairEnv: 'LZ_PRICEFEED_PROGRAM_KEYPAIR', + }, + ...DVN_PROGRAM_IDS.map((id, index) => ({ + name: DVN_PROGRAM_IDS.length > 1 ? `dvn-${index + 1}` : 'dvn', + id, + binary: 'dvn.so', + keypairEnv: 'LZ_DVN_PROGRAM_KEYPAIR', + })), +] let globalContext: TestContext let globalUmi: Umi | Context -let solanaProcess: ChildProcess +let surfpoolProcess: ChildProcess const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -41,8 +95,8 @@ describe('OFT Solana Tests', function () { before(async function () { console.log('Setting up test environment...') + surfpoolProcess = await startSurfnet() await setupPrograms() - solanaProcess = await startSolanaValidator() globalContext = await createGlobalTestContext() globalUmi = globalContext.umi @@ -57,8 +111,8 @@ describe('OFT Solana Tests', function () { globalContext.umi = globalUmi } await sleep(2000) - if (solanaProcess) { - solanaProcess.kill('SIGKILL') + if (surfpoolProcess) { + surfpoolProcess.kill('SIGKILL') } console.log('Cleanup completed.') }) @@ -97,141 +151,269 @@ export function getGlobalKeys(): OftKeySets { } async function setupPrograms(): Promise { - const programsDir = path.join(__dirname, '../../target/programs') - env.RUST_LOG = 'solana_runtime::message_processor=debug' - fs.mkdirSync(programsDir, { recursive: true }) - - const programs = [ - { name: 'endpoint', id: '76y77prsiCMvXMjuoZ5VRrhG5qYBrUMYTE5WgHqgjEn6' }, - { name: 'simple_messagelib', id: '6GsmxMTHAAiFKfemuM4zBjumTjNSX5CAiw4xSSXM2Toy' }, - { name: 'uln', id: '7a4WjyR8VZ7yZz5XJAKm39BUGn5iT9CKcv2pmG9tdXVH' }, - { name: 'executor', id: '6doghB248px58JSSwG4qejQ46kFMW4AMj7vzJnWZHNZn' }, - { name: 'dvn', id: 'HtEYV4xB4wvsj5fgTkcfuChYpvGYzgzwvNhgDZQNh7wW' }, - { name: 'pricefeed', id: '8ahPGPjEbpgGaZx2NV1iG5Shj7TDwvsjkEDcGWjt94TP' }, - { name: 'blocked_messagelib', id: '2XrYqmhBMPJgDsb4SVbjV1PnJBprurd5bzRCkHwiFCJB' }, - ] + assertFileExists(JUNK_KEYPAIR_PATH, 'junk keypair') + assertFileExists(OFT_PROGRAM_PATH, 'OFT program binary') + assertFileExists(OFT_KEYPAIR_PATH, 'OFT program keypair') - console.log('Downloading LayerZero programs...') - for (const program of programs) { - const programPath = `${programsDir}/${program.name}.so` - if (!fs.existsSync(programPath)) { - console.log(` Downloading ${program.name}...`) - await runCommand('solana', ['program', 'dump', program.id, programPath, '-u', 'devnet'], { - stdio: 'inherit', - }) + if (SURFPOOL_OFFLINE && !USE_LOCAL_PROGRAMS) { + throw new Error('SURFPOOL_OFFLINE requires SURFPOOL_USE_LOCAL_PROGRAMS=1 for local program deployment.') + } + + if (USE_LOCAL_PROGRAMS) { + if (DVN_PROGRAM_IDS.length > 1) { + throw new Error('Local Surfnet mode supports a single DVN program ID. Set LZ_DVN_PROGRAM_IDS accordingly.') + } + await deployLocalLayerZeroPrograms() + } else { + console.log(`Priming LayerZero programs from ${UPSTREAM_RPC_URL}...`) + for (const program of LAYERZERO_PROGRAMS) { + console.log(` Cloning ${program.name}...`) + await cloneProgramAccount(program.id) } + await resetInfrastructureAccounts() } -} -async function startSolanaValidator(): Promise { - const programsDir = path.join(__dirname, '../../target/programs') + const oftProgramId = OFT_PROGRAM_ID.toString() + const hasOftProgram = USE_LOCAL_PROGRAMS + ? await accountExists(oftProgramId) + : await tryCloneProgramAccount(oftProgramId, 'oft') + if (!hasOftProgram) { + await ensureOftProgramAuthority(oftProgramId) + console.log('Deploying OFT program to Surfnet...') + await deployProgram(OFT_KEYPAIR_PATH, OFT_PROGRAM_PATH) + } +} + +async function startSurfnet(): Promise { + assertFileExists(JUNK_KEYPAIR_PATH, 'junk keypair') const args = [ - '--reset', - '--rpc-port', + 'start', + '-p', RPC_PORT, - '--faucet-port', - FAUCET_PORT, - - '--bpf-program', - '76y77prsiCMvXMjuoZ5VRrhG5qYBrUMYTE5WgHqgjEn6', - `${programsDir}/endpoint.so`, - - '--bpf-program', - '6GsmxMTHAAiFKfemuM4zBjumTjNSX5CAiw4xSSXM2Toy', - `${programsDir}/simple_messagelib.so`, - - '--bpf-program', - '7a4WjyR8VZ7yZz5XJAKm39BUGn5iT9CKcv2pmG9tdXVH', - `${programsDir}/uln.so`, - - '--bpf-program', - '6doghB248px58JSSwG4qejQ46kFMW4AMj7vzJnWZHNZn', - `${programsDir}/executor.so`, - - '--bpf-program', - 'HtEYV4xB4wvsj5fgTkcfuChYpvGYzgzwvNhgDZQNh7wW', - `${programsDir}/dvn.so`, - - '--bpf-program', - '8ahPGPjEbpgGaZx2NV1iG5Shj7TDwvsjkEDcGWjt94TP', - `${programsDir}/pricefeed.so`, - - '--bpf-program', - '2XrYqmhBMPJgDsb4SVbjV1PnJBprurd5bzRCkHwiFCJB', - `${programsDir}/blocked_messagelib.so`, - - '--bpf-program', - OFT_PROGRAM_ID, - `${__dirname}/../../target/deploy/oft.so`, + '-w', + WS_PORT, + '-o', + RPC_HOST, + '--no-tui', + '--no-deploy', + '--airdrop-keypair-path', + JUNK_KEYPAIR_PATH, ] - console.log('Loading mainnet feature flags...') - const inactiveFeatures = await loadInactiveFeatures() - inactiveFeatures.forEach((f) => { - args.push('--deactivate-feature', f.id) - }) + if (SURFPOOL_OFFLINE) { + args.push('--offline') + } else { + args.push('-u', UPSTREAM_RPC_URL) + } - console.log('Starting solana-test-validator...') - const logFile = path.join(__dirname, '../../target/solana-test-validator.log') - const logFd = fs.openSync(logFile, 'w') - const validatorProcess = spawn('solana-test-validator', [...args], { + console.log(`Starting surfpool (${SURFPOOL_OFFLINE ? 'offline' : `upstream: ${UPSTREAM_RPC_URL}`})...`) + const logFd = fs.openSync(SURFPOOL_LOG, 'w') + const surfnetProcess = spawn('surfpool', [...args], { stdio: ['ignore', logFd, logFd], }) - let validatorReady = false + let surfnetReady = false for (let i = 0; i < 60; i++) { try { await axios.post(RPC, { jsonrpc: '2.0', id: 1, method: 'getVersion' }, { timeout: 5000 }) - console.log('Solana test validator started.') - validatorReady = true + console.log('Surfnet started.') + surfnetReady = true break } catch (e) { await sleep(1000) - console.log('Waiting for solana to start...') + console.log('Waiting for surfnet to start...') } } - if (!validatorReady) { - validatorProcess.kill('SIGKILL') - throw new Error('Solana test validator failed to start within 60 seconds') + if (!surfnetReady) { + surfnetProcess.kill('SIGKILL') + throw new Error('Surfnet failed to start within 60 seconds') } - return validatorProcess + return surfnetProcess } -interface FeatureInfo { - description: string - id: string - status: string +async function cloneProgramAccount(programId: string): Promise { + try { + await callSurfnetRpc('surfnet_cloneProgramAccount', [programId, programId]) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to clone program ${programId} from ${UPSTREAM_RPC_URL}: ${details}. If these programs are devnet-only, set SURFPOOL_RPC_URL to a devnet RPC.` + ) + } } -interface CachedFeaturesData { - timestamp: string - source: string - totalFeatures: number - inactiveFeatures: FeatureInfo[] - inactiveCount: number +async function deployLocalLayerZeroPrograms(): Promise { + console.log('Deploying local LayerZero programs...') + for (const program of LAYERZERO_PROGRAMS) { + if (!program.binary || !program.keypairEnv) { + continue + } + + const keypairPath = env[program.keypairEnv] + if (!keypairPath) { + throw new Error(`Missing ${program.keypairEnv} for local program ${program.name}`) + } + + const programPath = path.join(TARGET_PROGRAMS_DIR, program.binary) + assertFileExists(programPath, `${program.name} program binary`) + assertFileExists(keypairPath, `${program.name} program keypair`) + + if (await accountExists(program.id)) { + console.log(` ${program.name} already deployed.`) + continue + } + + console.log(` Deploying ${program.name}...`) + await deployProgram(keypairPath, programPath) + } } -async function loadInactiveFeatures(): Promise { - const featuresFile = path.join(__dirname, '../../target/programs/features.json') +async function deployProgram(keypairPath: string, programPath: string): Promise { + await runCommand( + 'solana', + ['program', 'deploy', '--url', RPC, '--keypair', JUNK_KEYPAIR_PATH, '--program-id', keypairPath, programPath], + { stdio: 'inherit' } + ) +} + +async function accountExists(pubkey: string): Promise { + const result = await callSurfnetRpc<{ value: unknown | null }>('getAccountInfo', [pubkey, { encoding: 'base64' }]) + return Boolean(result?.value) +} + +export async function callSurfnetRpc(method: string, params?: unknown): Promise { + const response = await axios.post(RPC, { jsonrpc: '2.0', id: 1, method, params }, { timeout: 30000 }) + + if (response.data?.error) { + const errorMessage = response.data.error?.message ?? JSON.stringify(response.data.error) + throw new Error(`Surfnet RPC ${method} failed: ${errorMessage}`) + } + + return response.data?.result as T +} + +async function resetInfrastructureAccounts(): Promise { + const keySets = createOftKeySets(OFT_PROGRAM_ID) + const oappStores = [keySets.native.oftStore, keySets.adapter.oftStore] + const remoteEids = [DST_EID, SRC_EID] + + const addresses = [ + // Endpoint PDAs + endpoint.pda.setting()[0], + endpoint.eventAuthority, + endpoint.pda.messageLibraryInfo(uln.pda.messageLib()[0])[0], + + // Uln PDAs + uln.pda.messageLib()[0], + uln.pda.setting()[0], + uln.eventAuthority, + + // Executor & PriceFeed + executor.pda.config()[0], + executor.eventAuthority, + priceFeed.pda.priceFeed()[0], + + // DVN PDAs + ...dvns.map((dvn) => new UMI.DvnPDA(dvn).config()[0]), + ...dvns.map((dvn) => new UMI.EventPDA(dvn).eventAuthority()[0]), + + // OApp stores + registries + ...oappStores, + ...oappStores.map((oapp) => endpoint.pda.oappRegistry(oapp)[0]), + + // Pathway config/nonce PDAs + ...oappStores.flatMap((store) => + remoteEids.flatMap((remote) => [ + endpoint.pda.defaultSendLibraryConfig(remote)[0], + endpoint.pda.oappRegistry(store)[0], + endpoint.pda.sendLibraryConfig(store, remote)[0], + endpoint.pda.nonce(store, remote, publicKeyBytes(store))[0], + endpoint.pda.pendingNonce(store, remote, publicKeyBytes(store))[0], + uln.pda.defaultSendConfig(remote)[0], + uln.pda.defaultReceiveConfig(remote)[0], + uln.pda.sendConfig(remote, store)[0], + uln.pda.receiveConfig(remote, store)[0], + ]) + ), + ] + + const unique = dedupeAddresses(addresses) + for (const address of unique) { + // Clear forked PDAs so init instructions can recreate them without "account already in use" errors. + await clearAccount(address.toString()) + } +} + +async function clearAccount(pubkey: string): Promise { + try { + await callSurfnetRpc('surfnet_setAccount', [ + pubkey, + { + data: EMPTY_ACCOUNT_DATA_HEX, + executable: false, + lamports: 0, + owner: SYSTEM_PROGRAM_ID, + rentEpoch: 0, + }, + ]) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to clear account ${pubkey} with surfnet_setAccount: ${details}`) + } +} - if (!fs.existsSync(featuresFile)) { - console.log('Run: pnpm test:generate-features') - process.exit(1) +async function ensureOftProgramAuthority(programId: string): Promise { + const authority = loadKeypairPublicKey(JUNK_KEYPAIR_PATH) + + try { + await callSurfnetRpc('surfnet_setProgramAuthority', [programId, authority]) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + console.warn(`Skipping program authority update for OFT: ${details}`) } +} +async function tryCloneProgramAccount(programId: string, label: string): Promise { try { - const cachedData: CachedFeaturesData = JSON.parse(fs.readFileSync(featuresFile, 'utf-8')) - console.log(`Loaded ${cachedData.inactiveCount} inactive features from cache.`) - return cachedData.inactiveFeatures + await cloneProgramAccount(programId) + return true } catch (error) { - console.error('Failed to read features cache:', error) - process.exit(1) + const details = error instanceof Error ? error.message : String(error) + if (details.includes('not found')) { + console.warn(`Program ${label} (${programId}) not found upstream; deploying locally.`) + return false + } + throw error + } +} + +function assertFileExists(filePath: string, label: string): void { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing ${label} at ${filePath}`) } } +function loadKeypairPublicKey(filePath: string): string { + const secret = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as number[] + const keypair = Keypair.fromSecretKey(Uint8Array.from(secret)) + return keypair.publicKey.toBase58() +} + +function dedupeAddresses(addresses: { toString(): string }[]): { toString(): string }[] { + const seen = new Set() + return addresses.filter((address) => { + const key = address.toString() + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + async function createGlobalTestContext(): Promise { const connection = new Connection(RPC, 'confirmed') const umi = createUmi(connection) diff --git a/examples/oft-solana/test/anchor/suites/layerzero-infrastructure.test.ts b/examples/oft-solana/test/anchor/suites/layerzero-infrastructure.test.ts index 8202e0b50..3d085778a 100644 --- a/examples/oft-solana/test/anchor/suites/layerzero-infrastructure.test.ts +++ b/examples/oft-solana/test/anchor/suites/layerzero-infrastructure.test.ts @@ -4,15 +4,17 @@ import { KeypairSigner, PublicKey, Signer, + TransactionBuilder, Umi, none, publicKey, publicKeyBytes, some, } from '@metaplex-foundation/umi' -import { fromWeb3JsPublicKey } from '@metaplex-foundation/umi-web3js-adapters' +import { fromWeb3JsPublicKey, toWeb3JsPublicKey } from '@metaplex-foundation/umi-web3js-adapters' import { getPublicKey } from '@noble/secp256k1' import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { AccountInfo, Connection } from '@solana/web3.js' import { UMI } from '@layerzerolabs/lz-solana-sdk-v2' @@ -27,18 +29,19 @@ import { endpoint, executor, priceFeed, - simpleMessageLib, uln, } from '../constants' -import { getGlobalContext, getGlobalKeys, getGlobalUmi } from '../index.test' +import { callSurfnetRpc, getGlobalContext, getGlobalKeys, getGlobalUmi } from '../index.test' import { OftKeySets } from '../types' import { sendAndConfirm } from '../utils' describe('LayerZero Infrastructure Setup', function () { + const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111' let umi: Umi | Context let endpointAdmin: Signer let executorInExecutor: KeypairSigner let keys: OftKeySets + let connection: Connection before(function () { const context = getGlobalContext() @@ -46,22 +49,40 @@ describe('LayerZero Infrastructure Setup', function () { endpointAdmin = umi.payer executorInExecutor = context.executor keys = getGlobalKeys() + connection = context.connection }) - it('Init Endpoint', async () => { - await sendAndConfirm( - umi, - [ + it('Init Endpoint', async function () { + const endpointSettings = endpoint.pda.setting()[0] + const existing = await connection.getAccountInfo(toWeb3JsPublicKey(endpointSettings)) + const needsInit = !existing || isClearedAccount(existing, SYSTEM_PROGRAM_ID) + if (!needsInit && existing) { + await ensureEndpointAdmin(endpointSettings, existing, endpointAdmin, connection) + } + const messageLibInfo = endpoint.pda.messageLibraryInfo(uln.pda.messageLib()[0])[0] + const messageLibInfoExisting = await connection.getAccountInfo(toWeb3JsPublicKey(messageLibInfo)) + const shouldRegisterLibrary = + !messageLibInfoExisting || isClearedAccount(messageLibInfoExisting, SYSTEM_PROGRAM_ID) + const initInstructions: TransactionBuilder[] = [] + if (needsInit) { + initInstructions.push( endpoint.initEndpoint(endpointAdmin, { eid: SRC_EID, admin: endpointAdmin.publicKey, - }), + }) + ) + } + if (shouldRegisterLibrary) { + initInstructions.push( endpoint.registerLibrary(endpointAdmin, { messageLibProgram: uln.programId, - }), - endpoint.registerLibrary(endpointAdmin, { - messageLibProgram: simpleMessageLib.programId, - }), + }) + ) + } + await sendAndConfirm( + umi, + [ + ...initInstructions, await endpoint.setDefaultSendLibrary(umi.rpc, endpointAdmin, { messageLibProgram: uln.programId, remote: DST_EID, @@ -97,35 +118,52 @@ describe('LayerZero Infrastructure Setup', function () { ) }) - it('Init Executor', async () => { - await sendAndConfirm( - umi, - [ + it('Init Executor', async function () { + const executorConfig = executor.pda.config()[0] + const existing = await connection.getAccountInfo(toWeb3JsPublicKey(executorConfig)) + const needsInit = !existing || isClearedAccount(existing, SYSTEM_PROGRAM_ID) + if (!needsInit && existing) { + await ensureExecutorOwner(executorConfig, existing, endpointAdmin, connection) + } + const initInstructions: TransactionBuilder[] = [] + if (needsInit) { + initInstructions.push( executor.initExecutor(endpointAdmin, { admins: [endpointAdmin.publicKey], executors: [executorInExecutor.publicKey], - msglibs: [uln.pda.messageLib()[0], simpleMessageLib.pda.messageLib()[0]], + msglibs: [uln.pda.messageLib()[0]], owner: endpointAdmin.publicKey, priceFeed: priceFeed.pda.priceFeed()[0], - }), - executor.setPriceFeed(endpointAdmin, priceFeed.programId), - executor.setDefaultMultiplierBps(endpointAdmin, defaultMultiplierBps), - executor.setDstConfig(endpointAdmin, [ - { - eid: DST_EID, - lzReceiveBaseGas: 10000, - lzComposeBaseGas: 10000, - multiplierBps: some(13000), - floorMarginUsd: some(10000n), - nativeDropCap: BigInt(1e7), - } satisfies UMI.ExecutorProgram.types.DstConfig, - ]), - ], - endpointAdmin + }) + ) + } else { + // Forked executor configs keep mainnet admins; reset to local admin so admin-only updates succeed. + initInstructions.push(executor.setAdmins(endpointAdmin, [endpointAdmin.publicKey])) + } + initInstructions.push( + executor.setPriceFeed(endpointAdmin, priceFeed.programId), + executor.setDefaultMultiplierBps(endpointAdmin, defaultMultiplierBps), + executor.setDstConfig(endpointAdmin, [ + { + eid: DST_EID, + lzReceiveBaseGas: 10000, + lzComposeBaseGas: 10000, + multiplierBps: some(13000), + floorMarginUsd: some(10000n), + nativeDropCap: BigInt(1e7), + } satisfies UMI.ExecutorProgram.types.DstConfig, + ]) ) + await sendAndConfirm(umi, initInstructions, endpointAdmin) }) - it('Init PriceFeed', async () => { + it('Init PriceFeed', async function () { + const priceFeedConfig = priceFeed.pda.priceFeed()[0] + const existing = await connection.getAccountInfo(toWeb3JsPublicKey(priceFeedConfig)) + const needsInit = !existing || isClearedAccount(existing, SYSTEM_PROGRAM_ID) + if (!needsInit && existing) { + await ensurePriceFeedAdmin(priceFeedConfig, existing, endpointAdmin, connection) + } const nativeTokenPriceUsd = BigInt(1e10) const priceRatio = BigInt(1e10) const gasPriceInUnit = BigInt(1e9) @@ -135,42 +173,58 @@ describe('LayerZero Infrastructure Setup', function () { gasPerL2Tx: BigInt(1e6), gasPerL1CalldataByte: 1, } - await sendAndConfirm( - umi, - [ + const initInstructions: TransactionBuilder[] = [] + if (needsInit) { + initInstructions.push( priceFeed.initPriceFeed(endpointAdmin, { admin: endpointAdmin.publicKey, updaters: [endpointAdmin.publicKey], - }), - priceFeed.setSolPrice(endpointAdmin, nativeTokenPriceUsd), - priceFeed.setPrice(endpointAdmin, { - dstEid: DST_EID, - priceRatio, - gasPriceInUnit, - gasPerByte, - modelType: modelType, - }), - ], - endpointAdmin + }) + ) + } + initInstructions.push( + priceFeed.setSolPrice(endpointAdmin, nativeTokenPriceUsd), + priceFeed.setPrice(endpointAdmin, { + dstEid: DST_EID, + priceRatio, + gasPriceInUnit, + gasPerByte, + modelType: modelType, + }) ) + await sendAndConfirm(umi, initInstructions, endpointAdmin) }) - it('Init DVN', async () => { + it('Init DVN', async function () { for (const programId of dvns) { const dvn = new UMI.DVNProgram.DVN(programId) + const config = new UMI.DvnPDA(publicKey(programId)).config()[0] + const existing = await connection.getAccountInfo(toWeb3JsPublicKey(config)) + const needsInit = !existing || isClearedAccount(existing, SYSTEM_PROGRAM_ID) + if (!needsInit && existing) { + await ensureDvnAdmin(config, existing, endpointAdmin, connection) + } await sendAndConfirm( umi, [ - await dvn.initDVN(umi.rpc, endpointAdmin, { - admins: [endpointAdmin.publicKey], - signers: DVN_SIGNERS.map((signer) => getPublicKey(signer, false).subarray(1)), - msglibs: [uln.pda.messageLib()[0]], - quorum: 1, - vid: DST_EID % 30000, - priceFeed: priceFeed.pda.priceFeed()[0], - }), + ...(needsInit + ? [ + await dvn.initDVN(umi.rpc, endpointAdmin, { + admins: [endpointAdmin.publicKey], + signers: DVN_SIGNERS.map((signer) => getPublicKey(signer, false).subarray(1)), + msglibs: [uln.pda.messageLib()[0]], + quorum: 1, + vid: DST_EID % 30000, + priceFeed: priceFeed.pda.priceFeed()[0], + }), + ] + : []), dvn.setDefaultMultiplierBps(endpointAdmin, defaultMultiplierBps), dvn.setPriceFeed(endpointAdmin, priceFeed.programId), + UMI.DVNProgram.instructions.extendDvnConfig( + { programs: dvn.programRepo }, + { admin: endpointAdmin, config } + ).items[0], dvn.setDstConfig(endpointAdmin, [ { eid: DST_EID, @@ -185,23 +239,13 @@ describe('LayerZero Infrastructure Setup', function () { } }) - it('Init SimpleMessageLib', async () => { - await sendAndConfirm( - umi, - [ - simpleMessageLib.initSimpleMessageLib(endpointAdmin, { - admin: endpointAdmin.publicKey, - eid: SRC_EID, - nativeFee: 1e4, - lzTokenFee: 0, - }), - simpleMessageLib.setWhitelistCaller(endpointAdmin, endpointAdmin.publicKey), - ], - endpointAdmin - ) - }) - - it('Init UltraLightNode', async () => { + it('Init UltraLightNode', async function () { + const ulnSettings = uln.pda.setting()[0] + const existing = await connection.getAccountInfo(toWeb3JsPublicKey(ulnSettings)) + const needsInit = !existing || isClearedAccount(existing, SYSTEM_PROGRAM_ID) + if (!needsInit && existing) { + await ensureUlnAdmin(ulnSettings, existing, endpointAdmin, connection) + } const defaultNativeFeeBps = 100 const maxMessageSize = 1024 const requiredDvns = dvns.map((programId) => new UMI.DvnPDA(publicKey(programId)).config()[0]).sort() @@ -225,35 +269,37 @@ describe('LayerZero Infrastructure Setup', function () { maxMessageSize, executor: executor.pda.config()[0], } - await sendAndConfirm( - umi, - [ + const initInstructions: TransactionBuilder[] = [] + if (needsInit) { + initInstructions.push( uln.initUln(endpointAdmin, { admin: endpointAdmin.publicKey, eid: DST_EID, endpointProgram: endpoint.programId, - }), - uln.setTreasury(endpointAdmin, { - admin: endpointAdmin.publicKey, - lzToken: null, - nativeFeeBps: defaultNativeFeeBps, - nativeReceiver: endpointAdmin.publicKey, - }), - await uln.initOrUpdateDefaultConfig(umi.rpc, endpointAdmin, { - executorConfig: some(executorConfig), - receiveUlnConfig: some(receiveUlnConfig), - remote: SRC_EID, - sendUlnConfig: some(sendUlnConfig), - }), - await uln.initOrUpdateDefaultConfig(umi.rpc, endpointAdmin, { - executorConfig: some(executorConfig), - receiveUlnConfig: some(receiveUlnConfig), - remote: DST_EID, - sendUlnConfig: some(sendUlnConfig), - }), - ], - endpointAdmin + }) + ) + } + initInstructions.push( + uln.setTreasury(endpointAdmin, { + admin: endpointAdmin.publicKey, + lzToken: null, + nativeFeeBps: defaultNativeFeeBps, + nativeReceiver: endpointAdmin.publicKey, + }), + await uln.initOrUpdateDefaultConfig(umi.rpc, endpointAdmin, { + executorConfig: some(executorConfig), + receiveUlnConfig: some(receiveUlnConfig), + remote: SRC_EID, + sendUlnConfig: some(sendUlnConfig), + }), + await uln.initOrUpdateDefaultConfig(umi.rpc, endpointAdmin, { + executorConfig: some(executorConfig), + receiveUlnConfig: some(receiveUlnConfig), + remote: DST_EID, + sendUlnConfig: some(sendUlnConfig), + }) ) + await sendAndConfirm(umi, initInstructions, endpointAdmin) await sendAndConfirm( umi, @@ -319,6 +365,215 @@ describe('LayerZero Infrastructure Setup', function () { }) }) +function isClearedAccount(account: AccountInfo, systemProgramId: string): boolean { + return account.owner.toBase58() === systemProgramId +} + +const ENDPOINT_ADMIN_OFFSET = 13 +const ENDPOINT_EID_OFFSET = 8 +const ULN_EID_OFFSET = 8 +const ULN_ADMIN_OFFSET = 77 +const EXECUTOR_OWNER_OFFSET = 9 +const PRICEFEED_ADMIN_OFFSET = 8 +const PRICEFEED_UPDATERS_OFFSET = 40 + +async function ensureEndpointAdmin( + settingsPda: PublicKey, + accountInfo: AccountInfo, + admin: Signer, + connection: Connection +): Promise { + const desiredAdmin = toWeb3JsPublicKey(admin.publicKey) + const desiredBytes = Buffer.from(desiredAdmin.toBytes()) + const data = Buffer.from(accountInfo.data) + const currentBytes = Buffer.from(data.subarray(ENDPOINT_ADMIN_OFFSET, ENDPOINT_ADMIN_OFFSET + 32)) + const currentEid = data.readUInt32LE(ENDPOINT_EID_OFFSET) + if (currentBytes.equals(desiredBytes) && currentEid === SRC_EID) { + return + } + + // Forked settings are owned by mainnet admin; reassign so local tests can set defaults. + desiredBytes.copy(data, ENDPOINT_ADMIN_OFFSET) + if (currentEid !== SRC_EID) { + data.writeUInt32LE(SRC_EID, ENDPOINT_EID_OFFSET) + } + const safeLamports = await connection.getMinimumBalanceForRentExemption(accountInfo.data.length) + await callSurfnetRpc('surfnet_setAccount', [ + settingsPda.toString(), + { + data: data.toString('hex'), + executable: accountInfo.executable, + lamports: safeLamports, + owner: accountInfo.owner.toBase58(), + rentEpoch: 0, + }, + ]) +} + +async function ensureUlnAdmin( + settingsPda: PublicKey, + accountInfo: AccountInfo, + admin: Signer, + connection: Connection +): Promise { + const desiredAdmin = toWeb3JsPublicKey(admin.publicKey) + const desiredBytes = Buffer.from(desiredAdmin.toBytes()) + const currentBytes = Buffer.from(accountInfo.data.subarray(ULN_ADMIN_OFFSET, ULN_ADMIN_OFFSET + 32)) + const data = Buffer.from(accountInfo.data) + const currentEid = data.readUInt32LE(ULN_EID_OFFSET) + if (currentBytes.equals(desiredBytes) && currentEid === DST_EID) { + return + } + + // Forked ULN settings are owned by mainnet admin; reassign for local config updates. + desiredBytes.copy(data, ULN_ADMIN_OFFSET) + if (currentEid !== DST_EID) { + data.writeUInt32LE(DST_EID, ULN_EID_OFFSET) + } + const safeLamports = await connection.getMinimumBalanceForRentExemption(accountInfo.data.length) + await callSurfnetRpc('surfnet_setAccount', [ + settingsPda.toString(), + { + data: data.toString('hex'), + executable: accountInfo.executable, + lamports: safeLamports, + owner: accountInfo.owner.toBase58(), + rentEpoch: 0, + }, + ]) +} + +async function ensureExecutorOwner( + settingsPda: PublicKey, + accountInfo: AccountInfo, + admin: Signer, + connection: Connection +): Promise { + const desiredAdmin = toWeb3JsPublicKey(admin.publicKey) + const desiredBytes = Buffer.from(desiredAdmin.toBytes()) + const currentBytes = Buffer.from(accountInfo.data.subarray(EXECUTOR_OWNER_OFFSET, EXECUTOR_OWNER_OFFSET + 32)) + if (currentBytes.equals(desiredBytes)) { + return + } + + // Forked executor config is owned by mainnet admin; reassign for local config updates. + const data = Buffer.from(accountInfo.data) + desiredBytes.copy(data, EXECUTOR_OWNER_OFFSET) + const safeLamports = await connection.getMinimumBalanceForRentExemption(accountInfo.data.length) + await callSurfnetRpc('surfnet_setAccount', [ + settingsPda.toString(), + { + data: data.toString('hex'), + executable: accountInfo.executable, + lamports: safeLamports, + owner: accountInfo.owner.toBase58(), + rentEpoch: 0, + }, + ]) +} + +async function ensurePriceFeedAdmin( + settingsPda: PublicKey, + accountInfo: AccountInfo, + admin: Signer, + connection: Connection +): Promise { + const desiredAdmin = toWeb3JsPublicKey(admin.publicKey) + const desiredBytes = Buffer.from(desiredAdmin.toBytes()) + const data = Buffer.from(accountInfo.data) + const currentBytes = Buffer.from(data.subarray(PRICEFEED_ADMIN_OFFSET, PRICEFEED_ADMIN_OFFSET + 32)) + let needsUpdate = false + if (!currentBytes.equals(desiredBytes)) { + desiredBytes.copy(data, PRICEFEED_ADMIN_OFFSET) + needsUpdate = true + } + + const updatersLength = data.readUInt32LE(PRICEFEED_UPDATERS_OFFSET) + const updatersStart = PRICEFEED_UPDATERS_OFFSET + 4 + let hasUpdater = false + for (let i = 0; i < updatersLength; i += 1) { + const start = updatersStart + i * 32 + if (data.subarray(start, start + 32).equals(desiredBytes)) { + hasUpdater = true + break + } + } + if (!hasUpdater && updatersLength > 0) { + data.set(desiredBytes, updatersStart) + needsUpdate = true + } + if (!needsUpdate) { + return + } + + // Forked price feed uses mainnet admin/updaters; reset for local price updates. + const safeLamports = await connection.getMinimumBalanceForRentExemption(accountInfo.data.length) + await callSurfnetRpc('surfnet_setAccount', [ + settingsPda.toString(), + { + data: data.toString('hex'), + executable: accountInfo.executable, + lamports: safeLamports, + owner: accountInfo.owner.toBase58(), + rentEpoch: 0, + }, + ]) +} + +async function ensureDvnAdmin( + settingsPda: PublicKey, + accountInfo: AccountInfo, + admin: Signer, + connection: Connection +): Promise { + const serializer = UMI.DVNProgram.accounts.getDvnConfigAccountDataSerializer() + const [state] = serializer.deserialize(accountInfo.data) + const desiredAdmin = admin.publicKey + const desiredVid = DST_EID % 30000 + const desiredSigners = DVN_SIGNERS.map((signer) => getPublicKey(signer, false).subarray(1)) + const quorum = 1 + const needsAdminUpdate = !state.admins.some((current) => current === desiredAdmin) + const needsVidUpdate = state.vid !== desiredVid + const needsSignerUpdate = + state.multisig.quorum !== quorum || + desiredSigners.some((signer) => + state.multisig.signers.every((current) => !Buffer.from(current).equals(Buffer.from(signer))) + ) + if (!needsAdminUpdate && !needsVidUpdate && !needsSignerUpdate) { + return + } + + const nextAdmins = state.admins.length > 0 ? [desiredAdmin, ...state.admins.slice(1)] : [desiredAdmin] + const nextSigners = [...state.multisig.signers] + for (let i = 0; i < Math.min(nextSigners.length, desiredSigners.length); i += 1) { + nextSigners[i] = desiredSigners[i] + } + const nextState = { + ...state, + admins: nextAdmins, + vid: desiredVid, + multisig: { + ...state.multisig, + signers: nextSigners, + quorum, + }, + } + const data = Buffer.from(serializer.serialize(nextState)) + + // Forked DVN config uses mainnet admins/vid/signers; reset so local config updates succeed. + const safeLamports = await connection.getMinimumBalanceForRentExemption(data.length) + await callSurfnetRpc('surfnet_setAccount', [ + settingsPda.toString(), + { + data: data.toString('hex'), + executable: accountInfo.executable, + lamports: safeLamports, + owner: accountInfo.owner.toBase58(), + rentEpoch: 0, + }, + ]) +} + function globalAddress(dvns: PublicKey[], oapps: PublicKey[]): PublicKey[] { const addresses: PublicKey[] = [ publicKey('11111111111111111111111111111111'), diff --git a/examples/oft-solana/test/anchor/utils.ts b/examples/oft-solana/test/anchor/utils.ts index a8a3ee372..bc24a7afb 100755 --- a/examples/oft-solana/test/anchor/utils.ts +++ b/examples/oft-solana/test/anchor/utils.ts @@ -11,6 +11,7 @@ import { WrappedInstruction, createNoopSigner, publicKeyBytes, + some, } from '@metaplex-foundation/umi' import { fromWeb3JsInstruction, @@ -34,6 +35,7 @@ import { Options, PacketSerializer, PacketV1Codec } from '@layerzerolabs/lz-v2-u import { OftPDA, oft } from '@layerzerolabs/oft-v2-solana-sdk' import { DST_EID, DVN_SIGNERS, OFT_DECIMALS, SRC_EID, dvns, endpoint, executor, uln } from './constants' +import { callSurfnetRpc } from './index.test' import { OftKeys, PacketSentEvent, TestContext } from './types' async function signWithECDSA( @@ -167,7 +169,7 @@ export async function verifyByDvn(context: TestContext, packetSentEvent: PacketS umi.payer, { vid: DST_EID % 30000, - instruction: uln.verify(createNoopSigner(requiredDVN), { packetBytes, confirmations: 10 }) + instruction: uln.verify(createNoopSigner(requiredDVN), { packetBytes, confirmations: 1 }) .instruction, expiration, }, @@ -185,6 +187,38 @@ export async function verifyByDvn(context: TestContext, packetSentEvent: PacketS } } +async function ensureConfirmationsReady(context: TestContext, packetBytes: Uint8Array): Promise { + const packet = PacketV1Codec.fromBytes(packetBytes) + const payloadHashBytes = Uint8Array.from(Buffer.from(packet.payloadHash().slice(2), 'hex')) + const headerHashBytes = Uint8Array.from(Buffer.from(packet.headerHash().slice(2), 'hex')) + const serializer = UMI.UlnProgram.accounts.getConfirmationsAccountDataSerializer() + const receiverBytes = Buffer.from(packet.receiver().slice(2), 'hex') + const receiver = fromWeb3JsPublicKey(new web3.PublicKey(receiverBytes)) + const receiveConfigState = await uln.getFinalReceiveConfigState(context.umi.rpc, receiver, packet.srcEid()) + const dvnConfigs = receiveConfigState.requiredDvns.concat(receiveConfigState.optionalDvns) + + for (const dvnConfig of dvnConfigs) { + const [confirmationsPda] = uln.pda.confirmations(headerHashBytes, payloadHashBytes, dvnConfig) + const accountInfo = await context.connection.getAccountInfo(toWeb3JsPublicKey(confirmationsPda)) + if (!accountInfo) { + continue + } + const [state] = serializer.deserialize(accountInfo.data) + const data = Buffer.from(serializer.serialize({ ...state, value: some(1000n) })) + const safeLamports = await context.connection.getMinimumBalanceForRentExemption(data.length) + await callSurfnetRpc('surfnet_setAccount', [ + confirmationsPda.toString(), + { + data: data.toString('hex'), + executable: accountInfo.executable, + lamports: safeLamports, + owner: accountInfo.owner.toBase58(), + rentEpoch: 0, + }, + ]) + } +} + export async function commitVerification( context: TestContext, sender: Uint8Array, @@ -196,6 +230,8 @@ export async function commitVerification( const { umi } = context const expiration = BigInt(Math.floor(new Date().getTime() / 1000 + 120)) + await ensureConfirmationsReady(context, packetBytes) + await new TransactionBuilder([ endpoint.initVerify(umi.payer, { srcEid: SRC_EID,