-
Notifications
You must be signed in to change notification settings - Fork 90
feat(svm): add V5 adapter foundations #1535
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: faisal/svm-spoke-v5
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The constant is introduced but the three existing call sites still use the bare literal — Right now there are two sources of truth for the same replay namespace, which is the opposite of what extracting the constant was for. Anchor's
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed all three existing account constraints to use Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖 |
||
|
|
||
| // 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These dev-deps back 7 tests that CI will never run.
.github/workflows/pr.ymlhaslint-rust(cargo +nightly fmt --all -- --check && cargo clippy) but nocargo testanywhere in the file. The TS vectors run viaanchor testand the Solidity vectors viatest-evm, so two of the three languages are covered — but the Rust half, which is the one that actually exercisesdecode_v5_adapter_input,resolve_v5_deposit_modifications, and the PDA derivations, is unguarded.Given the whole point of this PR is a frozen surface protected by golden vectors, that's the gap most likely to let drift through. A step in the existing
lint-and-check-generatedjob (it already installs a Rust toolchain and warms the cargo cache) would cover it:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
cargo test -p svm-spoke --libto the existinglint-and-check-generatedjob, immediately after Rust lint. The expanded local suite now passes 9 tests.Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖