diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2aa779f50..5b9377005 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -99,6 +99,8 @@ jobs: - name: Lint rust shell: bash run: yarn lint-rust + - name: Test rust + run: cargo test -p svm-spoke --lib - name: Regenerate constants.json run: yarn generate-constants-json && yarn prettier --write generated/constants.json - name: Regenerate deployed-addresses diff --git a/Cargo.lock b/Cargo.lock index a0c4603df..d615f193b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,6 +731,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.8.1" @@ -2629,7 +2635,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "hex", + "libsecp256k1 0.7.2", "multicall-handler", + "serde_json", "solana-security-txt", ] diff --git a/programs/svm-spoke/Cargo.toml b/programs/svm-spoke/Cargo.toml index 114e36bce..c23b3d4a9 100644 --- a/programs/svm-spoke/Cargo.toml +++ b/programs/svm-spoke/Cargo.toml @@ -22,4 +22,9 @@ test = [] anchor-lang = { version = "0.31.1", features = ["init-if-needed","event-cpi"]} anchor-spl = "0.31.1" multicall-handler = { path = "../multicall-handler" } -solana-security-txt = "1.1.1" \ No newline at end of file +solana-security-txt = "1.1.1" + +[dev-dependencies] +hex = "0.4.3" +libsecp256k1 = "0.7.2" +serde_json = "1.0" diff --git a/programs/svm-spoke/V5_ADAPTER_SPEC.md b/programs/svm-spoke/V5_ADAPTER_SPEC.md new file mode 100644 index 000000000..812bcfc71 --- /dev/null +++ b/programs/svm-spoke/V5_ADAPTER_SPEC.md @@ -0,0 +1,117 @@ +# SVM SpokePool V5 adapter specification (wire version 1) + +This document freezes the compatibility surface for the Gateway-facing `svm_spoke` V5 adapter. It intentionally +describes foundations only: wire version 1 does not become callable until the source and destination behavior steps +land. + +## Dispatch ABI and accounts + +The frozen dispatch target for the later behavior steps is the single +`adapter_execute_across_v5(ctx_values, input, jit_data)` entrypoint, whose Anchor discriminator is the first eight +bytes of `sha256("global:adapter_execute_across_v5")`. Gateway program +`34trBszXuqhRjWaMxXWsunJNmyUsBvDNPxAwTzbPTm4p` serializes: + +```text +discriminator[8] +|| step_id[32] || path_id[32] || submitter[32] +|| input_len:u32_le || input +|| jit_len:u32_le || jit_data +``` + +The common fixed Anchor accounts, in order, are: + +1. `dispatch_authority`: read-only signer, PDA + `["dispatch_authority", svm_spoke::ID]` under Gateway; +2. `state`: read-only `svm_spoke` state PDA; +3. `event_authority`: read-only `["__event_authority"]` PDA under `svm_spoke`; +4. `program`: read-only executable `svm_spoke::ID` account used by Anchor event CPI. + +All token, mint, token-program, vault, delegate, fill-status, payer, ATA-program, and system-program accounts are +branch-specific remaining accounts. The implementation derives every expected key and searches by key; caller order +does not authenticate an account. Accounts that can lose lamports or whose data/token amount can change must also be +writable at the transaction level. + +## Committed input and JIT wire + +`input` is strict Borsh with no trailing bytes: + +```text +V5AdapterInput { + version: u8 = 1, + mode: enum { Deposit = 0(AcrossDepositInput), Fill = 1(V5FillInput) } +} +``` + +`AcrossDepositInput` nests the canonical deposit fields under `deposit_params: AcrossDepositParams`, matching the EVM +adapter's type boundary. Borsh serializes that fixed struct inline, so the nesting adds no bytes. All Rust fields +serialize in declaration order. Integers use Borsh little-endian encoding. Pubkeys and `[u8; 32]` are raw 32-byte +values. Vectors use a `u32_le` length. `input_amount_mode` is `Literal = 0` or +`InputVaultBalance = 1 { bips: u16_le }`; `bips` must not exceed 10,000. The resolved SVM token amount is `u64`, while +cross-VM uint256 values remain 32-byte big-endian EVM words. The leading version byte is checked before the mode body +is decoded, so any unsupported version reports `UnsupportedVersion` even when its body is not compatible with v1. + +Gateway token vaults are shared per mint rather than isolated per execution. `InputVaultBalance` therefore resolves +against shared live state, and the continuing tape must leave no residual balance or stale approval that a later +permissionless execution could consume. Gateway does not currently enforce this net-zero settlement invariant. + +Unlike the EVM `inputAmountParam`, SVM wire v1 has no set-call-value flag. Native SOL must first be wrapped by the +ordinary Gateway `WRAP_SOL` command into its canonical WSOL vault; the deposit then consumes WSOL through the same +token path as any SPL input. Direct lamport deposit from this adapter is outside wire v1. + +Deposit JIT uses the EVM-aligned name `AcrossDepositJitParams` and is present exactly when the committed 20-byte +authority is nonzero and at least one modification is permitted. It is the fixed 129 bytes +`new_output_amount[32] || new_exclusive_relayer[32] || signature[65]`. A zero authority requires both permission +booleans false and empty `jit_data`; it never means permissionless modification. This intentionally diverges from +the EVM `AcrossDepositDelegateAdapter`, which permits authority-less JIT when a permission flag is set. Route builders +must not emit that EVM-only rule shape for SVM. Fill mode always decodes `jit_data` as `V5FillJit`. Malformed enum tags, +invalid Borsh booleans or lengths, unsupported versions, missing required JIT, and trailing bytes fail closed. + +## Hashes and signatures + +Canonical EVM integer encoding below means a 32-byte big-endian uint256 word: + +```text +synthetic_nonce = keccak256(submitter[32] || path_id[32] || uint256(deposit_nonce:u64)) +deposit_id = keccak256(executor_program_id[32] || depositor[32] || synthetic_nonce) + +name_hash = keccak256("ACXV.AcrossDepositDelegateAdapter.V1") +domain = keccak256(name_hash || gateway_program_id[32]) +digest = keccak256( + domain || path_id || uint256(deposit_nonce:u64) || new_output_amount[32] || new_exclusive_relayer[32] +) +``` + +The configured executor is Gateway in version 1, but deposit identity deliberately takes `executor_program_id` while +the signature domain always takes `gateway_program_id`. Signatures are secp256k1 `r[32] || s[32] || v[1]`, accept +only `v` 27 or 28, require low `s`, recover an uncompressed public key, and compare the last 20 bytes of its Keccak +hash with the committed authority. ERC-1271, Ed25519, EIP-2098, high-`s`, and `v` 0/1 encodings are unsupported. + +## PDA and token invariants + +- Source delegate: `["v5_source_delegate"]` under `svm_spoke`; a preceding ordinary Gateway `APPROVE` may grant any + allowance at least the resolved amount, including `u64::MAX`. `svm_spoke` later pulls exactly the resolved amount. +- External fill delegate: `["v5_fill_delegate"]` under `svm_spoke`; sufficient allowance is accepted and the exact + JIT `output_amount` is pulled. +- Gateway vault authority: `["vault_authority"]` under Gateway. A Gateway vault is the canonical ATA of this authority, + the mint, and the mint's token program. +- Fill status: the existing `["fills", relay_hash]` PDA under `svm_spoke`, preserving the standard replay namespace. +- Fill payer float: `["v5_fill_payer", submitter]` under `svm_spoke`. The data-less, system-owned PDA manually pays + fill-status rent with `invoke_signed`; it is not a forwarded transaction signer. + +An external delivery targets the canonical ATA of committed `recipient`, output mint, and token program. A canonical +Gateway-vault delivery validates that same live vault in place and its amount, records the fill, and performs no token +self-transfer or approval. The continuing atomic tape must consume the output. + +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; a nonzero remainder must be rent-exempt, and +`u64::MAX` means withdraw the live balance. V5 fills emit the existing `FilledRelay` schema and derive the relay hash +from the supplied standard `RelayData` and the configured SVM chain ID. Adapter mode requires an empty callback +message; the relay witness remains exactly `V5_MAGIC_PREFIX || step_id`. + +Transfer-fee mints are excluded until debit/delivery delta semantics are defined. Transfer hooks remain disabled +unless validator tests prove the complete hook-account set and the Gateway-to-Spoke CPI depth for that mint. + +Golden values in `fixtures/v5_adapter_v1.json` are independently re-derived from Rust, TypeScript, and Solidity to +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. diff --git a/programs/svm-spoke/fixtures/v5_adapter_v1.json b/programs/svm-spoke/fixtures/v5_adapter_v1.json new file mode 100644 index 000000000..7b02fd5b9 --- /dev/null +++ b/programs/svm-spoke/fixtures/v5_adapter_v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "programs": { + "gateway": "34trBszXuqhRjWaMxXWsunJNmyUsBvDNPxAwTzbPTm4p", + "gatewayBytes": "0x1eb6bdb6c483ad6e0d22ef22f9e874b14721fe0fa978038dc0da56d201337afd", + "svmSpoke": "DLv3NggMiSaef97YCkew5xKUHDh13tVGZ7tydt3ZeAru", + "svmSpokeBytes": "0xb7664086de37ee70821c10445b162f2c7ec8795bd0800c1462949e2328d1dd5a" + }, + "context": { + "stepId": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pathId": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "submitter": "0x1111111111111111111111111111111111111111111111111111111111111111", + "borsh": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111111111111111111111111111111111111111111111111111111111111111" + }, + "deposit": { + "depositor": "0x2222222222222222222222222222222222222222222222222222222222222222", + "recipient": "0x3333333333333333333333333333333333333333333333333333333333333333", + "inputToken": "0x4444444444444444444444444444444444444444444444444444444444444444", + "outputToken": "0x5555555555555555555555555555555555555555555555555555555555555555", + "inputAmount": "123456789", + "outputAmount": "0x000000000000000000000000000000000000000000000000000000003ade68b1", + "destinationChainId": "10", + "exclusiveRelayer": "0x6666666666666666666666666666666666666666666666666666666666666666", + "depositNonce": "72623859790382856", + "quoteTimestamp": 1700000000, + "fillDeadline": 1700003600, + "exclusivityParameter": 300, + "dstStepId": "0x7777777777777777777777777777777777777777777777777777777777777777", + "inputAmountMode": { "discriminant": 1, "bips": 9750 }, + "syntheticNonce": "0x92ecdb528d6aa8899a892e24114411e86cc79a81f6e8b77e9ebc96f7c5a184ff", + "depositId": "0x48ae46119d5a506c6631ead56d5ba2abb3be5119f8da5d0837c343050a6789e6" + }, + "jit": { + "authority": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "nameHash": "0x175bcc73211bd512c12efdaae2b1162ec659fc84230de0a1e28f01ef2a718233", + "domain": "0x1701a2abc908d37c1da054beecc5d079267528ce4cbb951426da25f02acf430d", + "newOutputAmount": "0x000000000000000000000000000000000000000000000000000000003b9ac9ff", + "newExclusiveRelayer": "0x8888888888888888888888888888888888888888888888888888888888888888", + "digest": "0xf76ca8256c0bc879e0bc1071b2d6e23f6bc1b0b7c2a8d661174b1326cc988a45", + "signature": "0x71a0f40cf5a11bda73a26cefd7a7b818b1632be2fd9845aabf3e094421d6b96b51de6d353a148d0d7f4a67778f6ac5d94886b912a365e556b025b2611408165a1c", + "highSSignature": "0x71a0f40cf5a11bda73a26cefd7a7b818b1632be2fd9845aabf3e094421d6b96bae2192cac5eb72f280b5988870953a25722823d40be2bae50facac2bbc2e2ae71b" + }, + "fill": { + "minOutputAmount": "950000000", + "outputAmount": "960000000", + "originChainId": "1", + "exclusivityDeadline": 1700000300, + "repaymentChainId": "1", + "repaymentAddress": "0x9999999999999999999999999999999999999999999999999999999999999999", + "witness": "0x89ae4bc75915265a3f10e926c3894a29534f1d6362ee8959cb0e5be00f3527fdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "wire": { + "depositInput": "0x0100222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444555555555555555555555555555555555555555555555555555555555555555515cd5b0700000000000000000000000000000000000000000000000000000000000000003ade68b10a000000000000006666666666666666666666666666666666666666666666666666666666666666080706050403020100f1536510ff53652c0100007777777777777777777777777777777777777777777777777777777777777777011626f39fd6e51aad88f6f4ce6ab8827279cfffb922660101", + "depositJit": "0x000000000000000000000000000000000000000000000000000000003b9ac9ff888888888888888888888888888888888888888888888888888888888888888871a0f40cf5a11bda73a26cefd7a7b818b1632be2fd9845aabf3e094421d6b96b51de6d353a148d0d7f4a67778f6ac5d94886b912a365e556b025b2611408165a1c", + "fillInput": "0x01013333333333333333333333333333333333333333333333333333333333333333555555555555555555555555555555555555555555555555555555555555555580d99f380000000000000000", + "fillJit": "0x2222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333388888888888888888888888888888888888888888888888888888888888888884444444444444444444444444444444444444444444444444444444444444444555555555555555555555555555555555555555555555555555555555555555500000000000000000000000000000000000000000000000000000000075bcd150070383900000000010000000000000048ae46119d5a506c6631ead56d5ba2abb3be5119f8da5d0837c343050a6789e610ff53652cf253654000000089ae4bc75915265a3f10e926c3894a29534f1d6362ee8959cb0e5be00f3527fdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01000000000000009999999999999999999999999999999999999999999999999999999999999999" + }, + "dispatch": { + "discriminator": "0x17a145949680addc", + "data": "0x17a145949680addcaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb11111111111111111111111111111111111111111111111111111111111111111f0100000100222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444555555555555555555555555555555555555555555555555555555555555555515cd5b0700000000000000000000000000000000000000000000000000000000000000003ade68b10a000000000000006666666666666666666666666666666666666666666666666666666666666666080706050403020100f1536510ff53652c0100007777777777777777777777777777777777777777777777777777777777777777011626f39fd6e51aad88f6f4ce6ab8827279cfffb92266010181000000000000000000000000000000000000000000000000000000000000003b9ac9ff888888888888888888888888888888888888888888888888888888888888888871a0f40cf5a11bda73a26cefd7a7b818b1632be2fd9845aabf3e094421d6b96b51de6d353a148d0d7f4a67778f6ac5d94886b912a365e556b025b2611408165a1c" + }, + "pdas": { + "dispatchAuthority": { "address": "28uSGvM8TVkVkAq9LBZASDPEi5t6RFbVekXECbDMqrWs", "bump": 253 }, + "gatewayVaultAuthority": { "address": "D3rtRbTdrBx8x8wp6xmQcUeS6seSt9nBB7DUG5S86HHb", "bump": 254 }, + "sourceDelegate": { "address": "CVh7qLq3yviU8iGuBmmgipJpfy95p9B81e2YTWDaxCbb", "bump": 255 }, + "fillDelegate": { "address": "D27f3mVXRL6N3bgja49UWLQu7kt57sy1aZYy7ZEwdxn1", "bump": 252 }, + "fillPayer": { "address": "Dpq2pxBDexzTiaQ35qoyyH1rF5siNyrSJhrdGMbWdBnF", "bump": 250 }, + "fillStatus": { "address": "2bdk4WnigBmjqwHtp1HQ5DHySvZ9QirEw9c8tWMXkfma", "bump": 255 } + } +} diff --git a/programs/svm-spoke/src/constants.rs b/programs/svm-spoke/src/constants.rs index d5089b452..cd58e6fe8 100644 --- a/programs/svm-spoke/src/constants.rs +++ b/programs/svm-spoke/src/constants.rs @@ -9,6 +9,20 @@ pub const MESSAGE_TRANSMITTER_PROGRAM_ID: Pubkey = pubkey!("CCTPmbSD7gX1bxKPAmg7 pub const MAX_EXCLUSIVITY_PERIOD_SECONDS: u32 = 31_536_000; pub const ZERO_DEPOSIT_ID: [u8; 32] = [0u8; 32]; +pub const BIPS_DENOMINATOR: u16 = 10_000; + +pub const V5_ADAPTER_WIRE_VERSION: u8 = 1; +pub const V5_SOURCE_DELEGATE_SEED: &[u8] = b"v5_source_delegate"; +pub const V5_FILL_DELEGATE_SEED: &[u8] = b"v5_fill_delegate"; +pub const V5_FILL_PAYER_SEED: &[u8] = b"v5_fill_payer"; +pub const FILL_STATUS_SEED: &[u8] = b"fills"; + +// Mirrored from the Gateway dispatch ABI. Keep these local: Gateway and svm-spoke intentionally use different +// Anchor versions and must not acquire a cross-repository Rust dependency. +pub const GATEWAY_PROGRAM_ID: Pubkey = pubkey!("34trBszXuqhRjWaMxXWsunJNmyUsBvDNPxAwTzbPTm4p"); +pub const GATEWAY_DISPATCH_AUTHORITY_SEED: &[u8] = b"dispatch_authority"; +pub const GATEWAY_VAULT_AUTHORITY_SEED: &[u8] = b"vault_authority"; +pub const GATEWAY_ADAPTER_EXECUTE_V5_PREIMAGE: &[u8] = b"global:adapter_execute_across_v5"; // Magic prefix tagging a deposit message as an Across V5 witness: `message = V5_MAGIC_PREFIX || stepId`, where // stepId is the Merkle root of the Gateway execution allowed to consume the deposit. V5-tagged deposits are only diff --git a/programs/svm-spoke/src/error.rs b/programs/svm-spoke/src/error.rs index dfd4d45f3..eb7b661ec 100644 --- a/programs/svm-spoke/src/error.rs +++ b/programs/svm-spoke/src/error.rs @@ -80,6 +80,31 @@ pub enum SvmError { InvalidDelegatePda, } +// Across V5 adapter specific errors. +#[error_code(offset = 7000)] +pub enum V5Error { + #[msg("Malformed Across V5 wire data!")] + InvalidWireFormat, + #[msg("Unsupported Across V5 wire version!")] + UnsupportedVersion, + #[msg("Invalid Across V5 parameter modification rules!")] + InvalidParamModificationRules, + #[msg("Invalid Across V5 parameter modification signature!")] + InvalidParamModificationSignature, + #[msg("Missing required Across V5 account!")] + MissingAccount, + #[msg("Invalid Across V5 Gateway dispatch authority!")] + InvalidDispatchAuthority, + #[msg("Across V5 account must be writable!")] + InvalidAccountMutability, + #[msg("Resolved Across V5 input amount is below the committed floor!")] + ResolvedInputAmountBelowCommitted, + #[msg("Across V5 delegate allowance is insufficient!")] + InsufficientDelegateAllowance, + #[msg("Across V5 parameter modification is not an improvement!")] + ParamModificationNotAnImprovement, +} + // CCTP specific errors. #[error_code] pub enum CallDataError { diff --git a/programs/svm-spoke/src/instructions/fill.rs b/programs/svm-spoke/src/instructions/fill.rs index be15039b0..98293f9aa 100644 --- a/programs/svm-spoke/src/instructions/fill.rs +++ b/programs/svm-spoke/src/instructions/fill.rs @@ -6,7 +6,7 @@ use anchor_spl::{ use crate::{ common::RelayData, - constants::DISCRIMINATOR_SIZE, + constants::{DISCRIMINATOR_SIZE, FILL_STATUS_SEED}, constraints::is_relay_hash_valid, error::{CommonError, SvmError}, event::{FillType, FilledRelay, RelayExecutionEventInfo}, @@ -67,7 +67,7 @@ pub struct FillRelay<'info> { init_if_needed, payer = signer, space = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE, - seeds = [b"fills", relay_hash.as_ref()], + seeds = [FILL_STATUS_SEED, relay_hash.as_ref()], bump, constraint = is_relay_hash_valid( &relay_hash, diff --git a/programs/svm-spoke/src/instructions/slow_fill.rs b/programs/svm-spoke/src/instructions/slow_fill.rs index 5f79e2b5e..8bd45d83f 100644 --- a/programs/svm-spoke/src/instructions/slow_fill.rs +++ b/programs/svm-spoke/src/instructions/slow_fill.rs @@ -4,7 +4,7 @@ use anchor_spl::token_interface::{transfer_checked, Mint, TokenAccount, TokenInt use crate::event::{FillType, FilledRelay, RelayExecutionEventInfo, RequestedSlowFill}; use crate::{ common::RelayData, - constants::DISCRIMINATOR_SIZE, + constants::{DISCRIMINATOR_SIZE, FILL_STATUS_SEED}, constraints::is_relay_hash_valid, error::{CommonError, SvmError}, state::{ExecuteSlowRelayLeafParams, FillStatus, FillStatusAccount, RequestSlowFillParams, RootBundle, State}, @@ -33,7 +33,7 @@ pub struct RequestSlowFill<'info> { init_if_needed, payer = signer, space = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE, - seeds = [b"fills", _relay_hash.as_ref()], + seeds = [FILL_STATUS_SEED, _relay_hash.as_ref()], bump, constraint = is_relay_hash_valid( &_relay_hash, @@ -164,7 +164,7 @@ pub struct ExecuteSlowRelayLeaf<'info> { #[account( mut, - seeds = [b"fills", _relay_hash.as_ref()], + seeds = [FILL_STATUS_SEED, _relay_hash.as_ref()], bump, // Make sure caller provided relay_hash used in PDA seeds is valid. constraint = is_relay_hash_valid( diff --git a/programs/svm-spoke/src/lib.rs b/programs/svm-spoke/src/lib.rs index e4524fefb..5a6b373f3 100644 --- a/programs/svm-spoke/src/lib.rs +++ b/programs/svm-spoke/src/lib.rs @@ -36,6 +36,7 @@ pub mod event; mod instructions; mod state; pub mod utils; +pub mod v5; use common::*; use instructions::*; diff --git a/programs/svm-spoke/src/v5.rs b/programs/svm-spoke/src/v5.rs new file mode 100644 index 000000000..d49f5f6c8 --- /dev/null +++ b/programs/svm-spoke/src/v5.rs @@ -0,0 +1,609 @@ +//! Frozen wire, cryptographic, and PDA foundations for the Gateway-facing V5 adapter. +//! +//! This module deliberately contains no live deposit or fill entrypoint. The versioned types and helpers are the +//! Step 1 compatibility boundary consumed by the later behavior steps. + +use anchor_lang::{ + prelude::*, + solana_program::{hash::hash, keccak, secp256k1_recover::secp256k1_recover}, +}; + +use crate::{ + common::RelayData, + constants::{ + BIPS_DENOMINATOR, FILL_STATUS_SEED, GATEWAY_ADAPTER_EXECUTE_V5_PREIMAGE, GATEWAY_DISPATCH_AUTHORITY_SEED, + GATEWAY_PROGRAM_ID, GATEWAY_VAULT_AUTHORITY_SEED, V5_ADAPTER_WIRE_VERSION, V5_FILL_DELEGATE_SEED, + V5_FILL_PAYER_SEED, V5_SOURCE_DELEGATE_SEED, + }, + error::V5Error, + ID, +}; + +pub const V5_SIGNATURE_LEN: usize = 65; +pub const V5_PARAM_MODIFICATION_NAME: &[u8] = b"ACXV.AcrossDepositDelegateAdapter.V1"; +const SECP256K1_HALF_ORDER: [u8; 32] = [ + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x5d, 0x57, 0x6e, + 0x73, 0x57, 0xa4, 0x50, 0x1d, 0xdf, 0xe9, 0x2f, 0x46, 0x68, 0x1b, 0x20, 0xa0, +]; + +/// Gateway-attested values prepended to every adapter call. Field order and widths mirror Gateway `CtxValues`. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy)] +pub struct V5GatewayContext { + pub step_id: [u8; 32], + pub path_id: [u8; 32], + pub submitter: Pubkey, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct V5AdapterInput { + pub version: u8, + pub mode: V5AdapterMode, +} + +/// Borsh enum discriminants are frozen as Deposit=0 and Fill=1. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub enum V5AdapterMode { + Deposit(AcrossDepositInput), + Fill(V5FillInput), +} + +/// Literal uses the committed `input_amount`. Balance-relative mode resolves `bips` of the canonical Gateway input +/// vault's live token amount, rounded down, and later enforces the committed amount as a floor. Gateway vaults are +/// shared per mint, not isolated per execution, so the continuing tape must leave no residual balance; Gateway does +/// not currently enforce that net-zero settlement invariant. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy)] +pub enum V5InputAmountMode { + Literal, + InputVaultBalance { bips: u16 }, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy)] +pub struct V5DepositModificationRules { + pub authority: [u8; 20], + pub allow_output_amount: bool, + pub allow_exclusive_relayer: bool, +} + +impl V5DepositModificationRules { + pub fn validate(&self) -> Result<()> { + let has_authority = self.authority != [0u8; 20]; + let has_permission = self.allow_output_amount || self.allow_exclusive_relayer; + // SVM v1 intentionally requires an authority for any JIT permission. The EVM adapter permits authority-less + // JIT when permission bits are set, so route builders must not reuse that EVM-only shape for SVM. + require!(has_authority == has_permission, V5Error::InvalidParamModificationRules); + Ok(()) + } + + pub fn jit_enabled(&self) -> bool { + self.authority != [0u8; 20] + } +} + +/// Canonical Across deposit fields. `output_amount` remains an EVM uint256 word; SVM-native amounts and non-EVM +/// chain IDs are width-bounded to u64. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct AcrossDepositParams { + pub depositor: Pubkey, + pub recipient: Pubkey, + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: [u8; 32], + pub destination_chain_id: u64, + pub exclusive_relayer: Pubkey, + pub deposit_nonce: u64, + pub quote_timestamp: u32, + pub fill_deadline: u32, + pub exclusivity_parameter: u32, +} + +/// Path-committed source-deposit input, shaped like the EVM `AcrossDepositInput` compatibility surface. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct AcrossDepositInput { + pub deposit_params: AcrossDepositParams, + pub dst_step_id: [u8; 32], + pub input_amount_mode: V5InputAmountMode, + pub modification_rules: V5DepositModificationRules, +} + +/// JIT payload for Deposit mode. The signature is fixed `r[32] || s[32] || v[1]` rather than a length-prefixed vec. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct AcrossDepositJitParams { + pub new_output_amount: [u8; 32], + pub new_exclusive_relayer: Pubkey, + pub signature: [u8; V5_SIGNATURE_LEN], +} + +/// Destination acceptance bounds. Adapter mode requires `message` to be empty; retaining the field keeps the +/// semantic V5FillInput shape explicit and makes a non-empty callback fail closed. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct V5FillInput { + pub recipient: Pubkey, + pub output_token: Pubkey, + pub min_output_amount: u64, + pub message: Vec, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct V5FillJit { + pub relay_data: RelayData, + pub repayment_chain_id: u64, + pub repayment_address: Pubkey, +} + +fn decode_strict(data: &[u8]) -> Result { + let mut remaining = data; + let decoded = T::deserialize(&mut remaining).map_err(|_| error!(V5Error::InvalidWireFormat))?; + require!(remaining.is_empty(), V5Error::InvalidWireFormat); + Ok(decoded) +} + +pub fn decode_v5_adapter_input(data: &[u8]) -> Result { + let (&version, body) = data.split_first().ok_or_else(|| error!(V5Error::InvalidWireFormat))?; + require_eq!(version, V5_ADAPTER_WIRE_VERSION, V5Error::UnsupportedVersion); + let input = V5AdapterInput { version, mode: decode_strict(body)? }; + match &input.mode { + V5AdapterMode::Deposit(deposit) => { + if let V5InputAmountMode::InputVaultBalance { bips } = deposit.input_amount_mode { + require!(bips <= BIPS_DENOMINATOR, V5Error::InvalidWireFormat); + } + deposit.modification_rules.validate()?; + } + V5AdapterMode::Fill(fill) => require!(fill.message.is_empty(), V5Error::InvalidWireFormat), + } + Ok(input) +} + +pub fn decode_v5_deposit_jit(deposit: &AcrossDepositInput, data: &[u8]) -> Result> { + deposit.modification_rules.validate()?; + if deposit.modification_rules.jit_enabled() { + Ok(Some(decode_strict(data)?)) + } else { + require!(data.is_empty(), V5Error::InvalidWireFormat); + Ok(None) + } +} + +pub fn decode_v5_fill_jit(data: &[u8]) -> Result { + decode_strict(data) +} + +pub fn resolve_v5_input_amount(mode: V5InputAmountMode, committed_amount: u64, vault_balance: u64) -> Result { + let amount = match mode { + V5InputAmountMode::Literal => committed_amount, + V5InputAmountMode::InputVaultBalance { bips } => { + require!(bips <= BIPS_DENOMINATOR, V5Error::InvalidWireFormat); + (u128::from(vault_balance) * u128::from(bips) / u128::from(BIPS_DENOMINATOR)) as u64 + } + }; + require!(amount >= committed_amount, V5Error::ResolvedInputAmountBelowCommitted); + Ok(amount) +} + +pub fn require_v5_delegate_allowance(allowance: u64, amount: u64) -> Result<()> { + require!(allowance >= amount, V5Error::InsufficientDelegateAllowance); + Ok(()) +} + +fn u64_to_evm_uint(value: u64) -> [u8; 32] { + let mut word = [0u8; 32]; + word[24..].copy_from_slice(&value.to_be_bytes()); + word +} + +/// `syntheticNonce = keccak256(submitter || pathId || uint256(depositNonce))` then +/// `depositId = keccak256(executorProgramId || depositor || syntheticNonce)`. +pub fn derive_v5_deposit_id( + executor_program_id: &Pubkey, + submitter: &Pubkey, + path_id: &[u8; 32], + depositor: &Pubkey, + deposit_nonce: u64, +) -> [u8; 32] { + let nonce = u64_to_evm_uint(deposit_nonce); + let synthetic_nonce = keccak::hashv(&[submitter.as_ref(), path_id, &nonce]).to_bytes(); + keccak::hashv(&[executor_program_id.as_ref(), depositor.as_ref(), &synthetic_nonce]).to_bytes() +} + +pub fn v5_param_modification_domain(gateway_program_id: &Pubkey) -> [u8; 32] { + let name_hash = keccak::hash(V5_PARAM_MODIFICATION_NAME).to_bytes(); + // EVM `abi.encode(bytes32,address)` is two 32-byte words. The SVM Gateway identity is already one word. + keccak::hashv(&[&name_hash, gateway_program_id.as_ref()]).to_bytes() +} + +/// EVM-compatible packed digest over fixed 32-byte words. `new_output_amount` is already a uint256 big-endian word. +pub fn v5_param_modification_digest( + gateway_program_id: &Pubkey, + path_id: &[u8; 32], + deposit_nonce: u64, + new_output_amount: &[u8; 32], + new_exclusive_relayer: &Pubkey, +) -> [u8; 32] { + let domain = v5_param_modification_domain(gateway_program_id); + let nonce = u64_to_evm_uint(deposit_nonce); + keccak::hashv(&[ + &domain, + path_id, + &nonce, + new_output_amount, + new_exclusive_relayer.as_ref(), + ]) + .to_bytes() +} + +/// Recover the 20-byte EVM authority from `r || s || v`. Only EVM-canonical v=27/28 and low-s signatures pass. +pub fn recover_v5_authority(digest: &[u8; 32], signature: &[u8; V5_SIGNATURE_LEN]) -> Result<[u8; 20]> { + let recovery_id = match signature[64] { + 27 | 28 => signature[64] - 27, + _ => return err!(V5Error::InvalidParamModificationSignature), + }; + require!(signature[32..64] <= SECP256K1_HALF_ORDER[..], V5Error::InvalidParamModificationSignature); + let public_key = secp256k1_recover(digest, recovery_id, &signature[..64]) + .map_err(|_| error!(V5Error::InvalidParamModificationSignature))?; + let hashed = keccak::hash(public_key.to_bytes().as_ref()).to_bytes(); + Ok(hashed[12..].try_into().unwrap()) +} + +pub fn verify_v5_authority( + expected_authority: &[u8; 20], + digest: &[u8; 32], + signature: &[u8; V5_SIGNATURE_LEN], +) -> Result<()> { + require!(*expected_authority != [0u8; 20], V5Error::InvalidParamModificationRules); + require!( + recover_v5_authority(digest, signature)? == *expected_authority, + V5Error::InvalidParamModificationSignature + ); + Ok(()) +} + +/// Verify and apply only the committed JIT permissions. Output-amount changes are improvement-only; unpermitted +/// proposed values remain signature-bound but are ignored, matching the EVM adapter. +pub fn resolve_v5_deposit_modifications( + input: &AcrossDepositInput, + jit: &AcrossDepositJitParams, + gateway_program_id: &Pubkey, + path_id: &[u8; 32], +) -> Result<([u8; 32], Pubkey)> { + input.modification_rules.validate()?; + require!(input.modification_rules.jit_enabled(), V5Error::InvalidParamModificationRules); + let deposit = &input.deposit_params; + let digest = v5_param_modification_digest( + gateway_program_id, + path_id, + deposit.deposit_nonce, + &jit.new_output_amount, + &jit.new_exclusive_relayer, + ); + verify_v5_authority(&input.modification_rules.authority, &digest, &jit.signature)?; + + let output_amount = if input.modification_rules.allow_output_amount { + require!(jit.new_output_amount >= deposit.output_amount, V5Error::ParamModificationNotAnImprovement); + jit.new_output_amount + } else { + deposit.output_amount + }; + let exclusive_relayer = if input.modification_rules.allow_exclusive_relayer { + jit.new_exclusive_relayer + } else { + deposit.exclusive_relayer + }; + Ok((output_amount, exclusive_relayer)) +} + +pub fn gateway_adapter_discriminator() -> [u8; 8] { + hash(GATEWAY_ADAPTER_EXECUTE_V5_PREIMAGE).to_bytes()[..8] + .try_into() + .unwrap() +} + +pub fn derive_gateway_dispatch_authority() -> (Pubkey, u8) { + Pubkey::find_program_address(&[GATEWAY_DISPATCH_AUTHORITY_SEED, ID.as_ref()], &GATEWAY_PROGRAM_ID) +} + +pub fn derive_gateway_vault_authority() -> (Pubkey, u8) { + Pubkey::find_program_address(&[GATEWAY_VAULT_AUTHORITY_SEED], &GATEWAY_PROGRAM_ID) +} + +pub fn derive_v5_source_delegate() -> (Pubkey, u8) { + Pubkey::find_program_address(&[V5_SOURCE_DELEGATE_SEED], &ID) +} + +pub fn derive_v5_fill_delegate() -> (Pubkey, u8) { + Pubkey::find_program_address(&[V5_FILL_DELEGATE_SEED], &ID) +} + +pub fn derive_v5_fill_payer(submitter: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[V5_FILL_PAYER_SEED, submitter.as_ref()], &ID) +} + +pub fn derive_fill_status(relay_hash: &[u8; 32]) -> (Pubkey, u8) { + Pubkey::find_program_address(&[FILL_STATUS_SEED, relay_hash], &ID) +} + +pub fn require_gateway_dispatch_authority(account: &AccountInfo) -> Result<()> { + let (expected, _) = derive_gateway_dispatch_authority(); + require!(account.is_signer && *account.key == expected, V5Error::InvalidDispatchAuthority); + Ok(()) +} + +/// Resolve branch-specific accounts by authenticated key, never by submitter-controlled position. +pub fn find_v5_account<'a, 'info>( + accounts: &'a [AccountInfo<'info>], + expected: &Pubkey, + writable: bool, +) -> Result<&'a AccountInfo<'info>> { + let account = accounts + .iter() + .find(|account| account.key == expected) + .ok_or_else(|| error!(V5Error::MissingAccount))?; + require!(!writable || account.is_writable, V5Error::InvalidAccountMutability); + Ok(account) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::str::FromStr; + + fn fixture() -> Value { + serde_json::from_str(include_str!("../fixtures/v5_adapter_v1.json")).unwrap() + } + + fn bytes(value: &Value, path: &str) -> Vec { + hex::decode(value.pointer(path).unwrap().as_str().unwrap().trim_start_matches("0x")).unwrap() + } + + fn array(value: &Value, path: &str) -> [u8; N] { + bytes(value, path).try_into().unwrap() + } + + fn serialize(value: &T) -> Vec { + let mut bytes = Vec::new(); + value.serialize(&mut bytes).unwrap(); + bytes + } + + fn assert_error_name(result: Result, expected: &str) { + match result.err().unwrap() { + anchor_lang::error::Error::AnchorError(error) => assert_eq!(error.error_name, expected), + _ => panic!("expected Anchor error"), + } + } + + #[test] + fn v5_errors_use_dedicated_range() { + assert_eq!(u32::from(V5Error::InvalidWireFormat), 7_000); + assert_eq!(u32::from(V5Error::ParamModificationNotAnImprovement), 7_009); + } + + #[test] + fn v1_wire_and_gateway_dispatch_match_golden_fixture() { + let fixture = fixture(); + let input_bytes = bytes(&fixture, "/wire/depositInput"); + let jit_bytes = bytes(&fixture, "/wire/depositJit"); + let input = decode_v5_adapter_input(&input_bytes).unwrap(); + assert_eq!(serialize(&input), input_bytes); + + let deposit = match &input.mode { + V5AdapterMode::Deposit(deposit) => deposit, + _ => panic!("golden mode must be Deposit"), + }; + assert_eq!(deposit.deposit_params.deposit_nonce, 72_623_859_790_382_856); + assert!(matches!(deposit.input_amount_mode, V5InputAmountMode::InputVaultBalance { bips: 9_750 })); + assert_eq!(deposit.modification_rules.authority, array(&fixture, "/jit/authority")); + + let jit = decode_v5_deposit_jit(deposit, &jit_bytes) + .unwrap() + .expect("golden JIT must be Deposit"); + assert_eq!(serialize(&jit), jit_bytes); + + let ctx = V5GatewayContext { + step_id: array(&fixture, "/context/stepId"), + path_id: array(&fixture, "/context/pathId"), + submitter: Pubkey::new_from_array(array(&fixture, "/context/submitter")), + }; + assert_eq!(serialize(&ctx), bytes(&fixture, "/context/borsh")); + + // Local mirror of Gateway `encode_dispatch_data`: discriminator || context || two Borsh byte vectors. + let mut dispatch = gateway_adapter_discriminator().to_vec(); + dispatch.extend(serialize(&ctx)); + dispatch.extend((input_bytes.len() as u32).to_le_bytes()); + dispatch.extend(&input_bytes); + dispatch.extend((jit_bytes.len() as u32).to_le_bytes()); + dispatch.extend(&jit_bytes); + assert_eq!(dispatch, bytes(&fixture, "/dispatch/data")); + } + + #[test] + fn evm_hashes_signature_and_domain_separation_match_golden_fixture() { + let fixture = fixture(); + let gateway = Pubkey::from_str(fixture.pointer("/programs/gateway").unwrap().as_str().unwrap()).unwrap(); + let submitter = Pubkey::new_from_array(array(&fixture, "/context/submitter")); + let depositor = Pubkey::new_from_array(array(&fixture, "/deposit/depositor")); + let path_id = array(&fixture, "/context/pathId"); + let nonce = fixture + .pointer("/deposit/depositNonce") + .unwrap() + .as_str() + .unwrap() + .parse() + .unwrap(); + assert_eq!( + derive_v5_deposit_id(&gateway, &submitter, &path_id, &depositor, nonce), + array(&fixture, "/deposit/depositId") + ); + + assert_eq!(v5_param_modification_domain(&gateway), array(&fixture, "/jit/domain")); + let digest = v5_param_modification_digest( + &gateway, + &path_id, + nonce, + &array(&fixture, "/jit/newOutputAmount"), + &Pubkey::new_from_array(array(&fixture, "/jit/newExclusiveRelayer")), + ); + assert_eq!(digest, array(&fixture, "/jit/digest")); + + let authority = array(&fixture, "/jit/authority"); + let signature = array(&fixture, "/jit/signature"); + verify_v5_authority(&authority, &digest, &signature).unwrap(); + + let mut other_path_id = path_id; + other_path_id[0] ^= 1; + let other_path_digest = v5_param_modification_digest( + &gateway, + &other_path_id, + nonce, + &array(&fixture, "/jit/newOutputAmount"), + &Pubkey::new_from_array(array(&fixture, "/jit/newExclusiveRelayer")), + ); + assert!(verify_v5_authority(&authority, &other_path_digest, &signature).is_err()); + + let other_nonce_digest = v5_param_modification_digest( + &gateway, + &path_id, + nonce + 1, + &array(&fixture, "/jit/newOutputAmount"), + &Pubkey::new_from_array(array(&fixture, "/jit/newExclusiveRelayer")), + ); + assert!(verify_v5_authority(&authority, &other_nonce_digest, &signature).is_err()); + + let mut other_authority = authority; + other_authority[0] ^= 1; + assert!(verify_v5_authority(&other_authority, &digest, &signature).is_err()); + + assert!(recover_v5_authority(&digest, &array(&fixture, "/jit/highSSignature")).is_err()); + let mut invalid_v = signature; + invalid_v[64] = 0; + assert!(recover_v5_authority(&digest, &invalid_v).is_err()); + } + + #[test] + fn pda_domains_match_golden_fixture() { + let fixture = fixture(); + let submitter = Pubkey::new_from_array(array(&fixture, "/context/submitter")); + let relay_hash = array(&fixture, "/deposit/depositId"); + let cases = [ + (derive_gateway_dispatch_authority(), "/pdas/dispatchAuthority"), + (derive_gateway_vault_authority(), "/pdas/gatewayVaultAuthority"), + (derive_v5_source_delegate(), "/pdas/sourceDelegate"), + (derive_v5_fill_delegate(), "/pdas/fillDelegate"), + (derive_v5_fill_payer(&submitter), "/pdas/fillPayer"), + (derive_fill_status(&relay_hash), "/pdas/fillStatus"), + ]; + for ((key, bump), path) in cases { + assert_eq!(key.to_string(), fixture.pointer(&format!("{path}/address")).unwrap().as_str().unwrap()); + assert_eq!(u64::from(bump), fixture.pointer(&format!("{path}/bump")).unwrap().as_u64().unwrap()); + } + } + + #[test] + fn decoding_is_strict_and_zero_authority_disables_jit() { + let fixture = fixture(); + let mut encoded = bytes(&fixture, "/wire/depositInput"); + encoded.push(0); + assert!(decode_v5_adapter_input(&encoded).is_err()); + + let mut wrong_version = bytes(&fixture, "/wire/depositInput"); + wrong_version[0] = V5_ADAPTER_WIRE_VERSION + 1; + assert_error_name(decode_v5_adapter_input(&wrong_version), "UnsupportedVersion"); + assert_error_name(decode_v5_adapter_input(&[V5_ADAPTER_WIRE_VERSION + 1]), "UnsupportedVersion"); + assert_error_name(decode_v5_adapter_input(&[]), "InvalidWireFormat"); + + let mut input = decode_v5_adapter_input(&bytes(&fixture, "/wire/depositInput")).unwrap(); + let deposit = match &mut input.mode { + V5AdapterMode::Deposit(deposit) => deposit, + _ => unreachable!(), + }; + deposit.modification_rules = V5DepositModificationRules { + authority: [0u8; 20], + allow_output_amount: false, + allow_exclusive_relayer: false, + }; + let input = decode_v5_adapter_input(&serialize(&input)).unwrap(); + let deposit = match &input.mode { + V5AdapterMode::Deposit(deposit) => deposit, + _ => unreachable!(), + }; + assert!(decode_v5_deposit_jit(deposit, &[]).unwrap().is_none()); + assert!(decode_v5_deposit_jit(deposit, &[0]).is_err()); + + let mut invalid_rules = input; + if let V5AdapterMode::Deposit(deposit) = &mut invalid_rules.mode { + deposit.modification_rules.allow_output_amount = true; + } + assert!(decode_v5_adapter_input(&serialize(&invalid_rules)).is_err()); + } + + #[test] + fn fill_wire_is_branch_specific() { + let fixture = fixture(); + let input = decode_v5_adapter_input(&bytes(&fixture, "/wire/fillInput")).unwrap(); + assert!(matches!(input.mode, V5AdapterMode::Fill(_))); + decode_v5_fill_jit(&bytes(&fixture, "/wire/fillJit")).unwrap(); + assert!(decode_v5_fill_jit(&bytes(&fixture, "/wire/depositJit")).is_err()); + } + + #[test] + fn balance_resolution_floor_and_ordinary_delegate_allowance_are_strict() { + assert_eq!(resolve_v5_input_amount(V5InputAmountMode::Literal, 99, 0).unwrap(), 99); + assert_eq!(resolve_v5_input_amount(V5InputAmountMode::InputVaultBalance { bips: 9_750 }, 97, 101).unwrap(), 98); + assert!(resolve_v5_input_amount(V5InputAmountMode::InputVaultBalance { bips: 9_750 }, 99, 101).is_err()); + assert!(resolve_v5_input_amount( + V5InputAmountMode::InputVaultBalance { bips: BIPS_DENOMINATOR + 1 }, + 0, + u64::MAX + ) + .is_err()); + + require_v5_delegate_allowance(99, 99).unwrap(); + require_v5_delegate_allowance(u64::MAX, 99).unwrap(); + assert!(require_v5_delegate_allowance(98, 99).is_err()); + } + + #[test] + fn jit_permissions_and_improvement_rule_match_evm_behavior() { + let fixture = fixture(); + let input = decode_v5_adapter_input(&bytes(&fixture, "/wire/depositInput")).unwrap(); + let mut deposit = match input.mode { + V5AdapterMode::Deposit(deposit) => deposit, + _ => unreachable!(), + }; + let jit: AcrossDepositJitParams = decode_strict(&bytes(&fixture, "/wire/depositJit")).unwrap(); + let gateway = Pubkey::from_str(fixture.pointer("/programs/gateway").unwrap().as_str().unwrap()).unwrap(); + let path_id = array(&fixture, "/context/pathId"); + + assert_eq!( + resolve_v5_deposit_modifications(&deposit, &jit, &gateway, &path_id).unwrap(), + (jit.new_output_amount, jit.new_exclusive_relayer) + ); + + deposit.modification_rules.allow_exclusive_relayer = false; + assert_eq!( + resolve_v5_deposit_modifications(&deposit, &jit, &gateway, &path_id) + .unwrap() + .1, + deposit.deposit_params.exclusive_relayer + ); + + let mut worse = jit; + worse.new_output_amount = [0u8; 32]; + let digest = v5_param_modification_digest( + &gateway, + &path_id, + deposit.deposit_params.deposit_nonce, + &worse.new_output_amount, + &worse.new_exclusive_relayer, + ); + let secret_bytes: [u8; 32] = hex::decode("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + .unwrap() + .try_into() + .unwrap(); + let secret = libsecp256k1::SecretKey::parse(&secret_bytes).unwrap(); + let (signature, recovery_id) = libsecp256k1::sign(&libsecp256k1::Message::parse(&digest), &secret); + worse.signature[..64].copy_from_slice(&signature.serialize()); + worse.signature[64] = recovery_id.serialize() + 27; + assert!(resolve_v5_deposit_modifications(&deposit, &worse, &gateway, &path_id).is_err()); + } +} diff --git a/test/evm/foundry/local/SvmSpokeV5Vectors.t.sol b/test/evm/foundry/local/SvmSpokeV5Vectors.t.sol new file mode 100644 index 000000000..4c518ac35 --- /dev/null +++ b/test/evm/foundry/local/SvmSpokeV5Vectors.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @notice EVM-side conformance checks for the SVM V5 adapter's cross-VM hash and signature fixtures. +contract SvmSpokeV5VectorsTest is Test { + using ECDSA for bytes32; + + string internal fixture; + + function setUp() public { + fixture = vm.readFile("programs/svm-spoke/fixtures/v5_adapter_v1.json"); + } + + function testDepositIdVector() public view { + bytes32 submitter = vm.parseJsonBytes32(fixture, ".context.submitter"); + bytes32 pathId = vm.parseJsonBytes32(fixture, ".context.pathId"); + uint256 nonce = vm.parseUint(vm.parseJsonString(fixture, ".deposit.depositNonce")); + bytes32 syntheticNonce = keccak256(abi.encodePacked(submitter, pathId, nonce)); + assertEq(syntheticNonce, vm.parseJsonBytes32(fixture, ".deposit.syntheticNonce")); + + bytes32 depositId = keccak256( + abi.encodePacked( + vm.parseJsonBytes32(fixture, ".programs.gatewayBytes"), + vm.parseJsonBytes32(fixture, ".deposit.depositor"), + syntheticNonce + ) + ); + assertEq(depositId, vm.parseJsonBytes32(fixture, ".deposit.depositId")); + } + + function testJitSignatureVector() public view { + bytes32 nameHash = keccak256("ACXV.AcrossDepositDelegateAdapter.V1"); + assertEq(nameHash, vm.parseJsonBytes32(fixture, ".jit.nameHash")); + bytes32 domain = keccak256(abi.encode(nameHash, vm.parseJsonBytes32(fixture, ".programs.gatewayBytes"))); + assertEq(domain, vm.parseJsonBytes32(fixture, ".jit.domain")); + + bytes32 digest = _jitDigest(domain); + assertEq(digest, vm.parseJsonBytes32(fixture, ".jit.digest")); + assertEq( + digest.recover(vm.parseJsonBytes(fixture, ".jit.signature")), + vm.parseJsonAddress(fixture, ".jit.authority") + ); + } + + function testHighSSignatureVectorIsRejected() public view { + (, ECDSA.RecoverError err, ) = ECDSA.tryRecover( + vm.parseJsonBytes32(fixture, ".jit.digest"), + vm.parseJsonBytes(fixture, ".jit.highSSignature") + ); + assertEq(uint256(err), uint256(ECDSA.RecoverError.InvalidSignatureS)); + } + + function testGatewayDiscriminatorVector() public view { + bytes memory discriminator = vm.parseJsonBytes(fixture, ".dispatch.discriminator"); + assertEq(bytes8(sha256("global:adapter_execute_across_v5")), bytes8(discriminator)); + } + + function _jitDigest(bytes32 domain) internal view returns (bytes32) { + return + keccak256( + abi.encodePacked( + domain, + vm.parseJsonBytes32(fixture, ".context.pathId"), + vm.parseUint(vm.parseJsonString(fixture, ".deposit.depositNonce")), + vm.parseJsonBytes32(fixture, ".jit.newOutputAmount"), + vm.parseJsonBytes32(fixture, ".jit.newExclusiveRelayer") + ) + ); + } +} diff --git a/test/svm/SvmSpoke.V5Foundations.ts b/test/svm/SvmSpoke.V5Foundations.ts new file mode 100644 index 000000000..dceb843aa --- /dev/null +++ b/test/svm/SvmSpoke.V5Foundations.ts @@ -0,0 +1,174 @@ +import { assert } from "chai"; +import { ethers } from "ethers"; +import { PublicKey } from "@solana/web3.js"; +import { createHash } from "crypto"; +import { readFileSync } from "fs"; + +const fixture = JSON.parse(readFileSync("programs/svm-spoke/fixtures/v5_adapter_v1.json", "utf8")); + +const fromHex = (value: string): Buffer => Buffer.from(value.slice(2), "hex"); +const hex = (value: Uint8Array): string => `0x${Buffer.from(value).toString("hex")}`; +const raw = (value: number): Buffer => Buffer.alloc(32, value); +const u16 = (value: number): Buffer => { + const encoded = Buffer.alloc(2); + encoded.writeUInt16LE(value); + return encoded; +}; +const u32 = (value: number): Buffer => { + const encoded = Buffer.alloc(4); + encoded.writeUInt32LE(value); + return encoded; +}; +const u64 = (value: string): Buffer => { + const encoded = Buffer.alloc(8); + encoded.writeBigUInt64LE(BigInt(value)); + return encoded; +}; +const word = (value: string): Buffer => + Buffer.from(ethers.utils.zeroPad(ethers.BigNumber.from(value).toHexString(), 32)); + +describe("svm_spoke V5 foundations", () => { + const gateway = new PublicKey(fixture.programs.gateway); + const svmSpoke = new PublicKey(fixture.programs.svmSpoke); + + it("matches the EVM deposit-ID and JIT-signature vectors", () => { + const syntheticNonce = ethers.utils.keccak256( + ethers.utils.solidityPack( + ["bytes32", "bytes32", "uint256"], + [fixture.context.submitter, fixture.context.pathId, fixture.deposit.depositNonce] + ) + ); + assert.equal(syntheticNonce, fixture.deposit.syntheticNonce); + assert.equal( + ethers.utils.keccak256( + ethers.utils.solidityPack( + ["bytes32", "bytes32", "bytes32"], + [fixture.programs.gatewayBytes, fixture.deposit.depositor, syntheticNonce] + ) + ), + fixture.deposit.depositId + ); + + const nameHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("ACXV.AcrossDepositDelegateAdapter.V1")); + assert.equal(nameHash, fixture.jit.nameHash); + const domain = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode(["bytes32", "bytes32"], [nameHash, fixture.programs.gatewayBytes]) + ); + assert.equal(domain, fixture.jit.domain); + const digest = ethers.utils.keccak256( + ethers.utils.solidityPack( + ["bytes32", "bytes32", "uint256", "uint256", "bytes32"], + [ + domain, + fixture.context.pathId, + fixture.deposit.depositNonce, + fixture.jit.newOutputAmount, + fixture.jit.newExclusiveRelayer, + ] + ) + ); + assert.equal(digest, fixture.jit.digest); + assert.equal(ethers.utils.recoverAddress(digest, fixture.jit.signature), fixture.jit.authority); + + const curveN = ethers.BigNumber.from("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + assert.isTrue(ethers.BigNumber.from(ethers.utils.splitSignature(fixture.jit.signature).s).lte(curveN.div(2))); + assert.isTrue(ethers.BigNumber.from(`0x${fixture.jit.highSSignature.slice(66, 130)}`).gt(curveN.div(2))); + }); + + it("matches the strict Borsh Deposit and Fill wires", () => { + const authority = fromHex(fixture.jit.authority); + const depositParams = Buffer.concat([ + fromHex(fixture.deposit.depositor), + fromHex(fixture.deposit.recipient), + fromHex(fixture.deposit.inputToken), + fromHex(fixture.deposit.outputToken), + u64(fixture.deposit.inputAmount), + fromHex(fixture.deposit.outputAmount), + u64(fixture.deposit.destinationChainId), + fromHex(fixture.deposit.exclusiveRelayer), + u64(fixture.deposit.depositNonce), + u32(fixture.deposit.quoteTimestamp), + u32(fixture.deposit.fillDeadline), + u32(fixture.deposit.exclusivityParameter), + ]); + const depositInput = Buffer.concat([ + Buffer.from([fixture.version, 0]), + depositParams, + fromHex(fixture.deposit.dstStepId), + Buffer.from([fixture.deposit.inputAmountMode.discriminant]), + u16(fixture.deposit.inputAmountMode.bips), + authority, + Buffer.from([1, 1]), + ]); + assert.equal(hex(depositInput), fixture.wire.depositInput); + + const depositJit = Buffer.concat([ + fromHex(fixture.jit.newOutputAmount), + fromHex(fixture.jit.newExclusiveRelayer), + fromHex(fixture.jit.signature), + ]); + assert.equal(hex(depositJit), fixture.wire.depositJit); + + const fillInput = Buffer.concat([ + Buffer.from([fixture.version, 1]), + fromHex(fixture.deposit.recipient), + fromHex(fixture.deposit.outputToken), + u64(fixture.fill.minOutputAmount), + u32(0), + ]); + assert.equal(hex(fillInput), fixture.wire.fillInput); + + const fillJit = Buffer.concat([ + fromHex(fixture.deposit.depositor), + fromHex(fixture.deposit.recipient), + fromHex(fixture.jit.newExclusiveRelayer), + fromHex(fixture.deposit.inputToken), + fromHex(fixture.deposit.outputToken), + word(fixture.deposit.inputAmount), + u64(fixture.fill.outputAmount), + u64(fixture.fill.originChainId), + fromHex(fixture.deposit.depositId), + u32(fixture.deposit.fillDeadline), + u32(fixture.fill.exclusivityDeadline), + u32(fromHex(fixture.fill.witness).length), + fromHex(fixture.fill.witness), + u64(fixture.fill.repaymentChainId), + fromHex(fixture.fill.repaymentAddress), + ]); + assert.equal(hex(fillJit), fixture.wire.fillJit); + }); + + it("matches the Gateway dispatch bytes and all frozen PDA domains", () => { + const discriminator = createHash("sha256").update("global:adapter_execute_across_v5").digest().subarray(0, 8); + assert.equal(hex(discriminator), fixture.dispatch.discriminator); + + const input = fromHex(fixture.wire.depositInput); + const jit = fromHex(fixture.wire.depositJit); + const dispatch = Buffer.concat([ + discriminator, + fromHex(fixture.context.borsh), + u32(input.length), + input, + u32(jit.length), + jit, + ]); + assert.equal(hex(dispatch), fixture.dispatch.data); + + const derive = (seeds: Buffer[], program: PublicKey): [string, number] => { + const [key, bump] = PublicKey.findProgramAddressSync(seeds, program); + return [key.toBase58(), bump]; + }; + const cases: Array<[[string, number], { address: string; bump: number }]> = [ + [derive([Buffer.from("dispatch_authority"), svmSpoke.toBuffer()], gateway), fixture.pdas.dispatchAuthority], + [derive([Buffer.from("vault_authority")], gateway), fixture.pdas.gatewayVaultAuthority], + [derive([Buffer.from("v5_source_delegate")], svmSpoke), fixture.pdas.sourceDelegate], + [derive([Buffer.from("v5_fill_delegate")], svmSpoke), fixture.pdas.fillDelegate], + [derive([Buffer.from("v5_fill_payer"), raw(0x11)], svmSpoke), fixture.pdas.fillPayer], + [derive([Buffer.from("fills"), fromHex(fixture.deposit.depositId)], svmSpoke), fixture.pdas.fillStatus], + ]; + for (const [[address, bump], expected] of cases) { + assert.equal(address, expected.address); + assert.equal(bump, expected.bump); + } + }); +});