diff --git a/.cargo/config.toml b/.cargo/config.toml index d513085448..aa777fd804 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,7 +2,7 @@ CF_ETH_CONTRACT_ABI_ROOT = { value = "contract-interfaces/eth-contract-abis", relative = true } CF_ETH_CONTRACT_ABI_TAG = "v1.1.2" CF_SOL_PROGRAM_IDL_ROOT = { value = "contract-interfaces/sol-program-idls", relative = true } -CF_SOL_PROGRAM_IDL_TAG = "v1.0.0" +CF_SOL_PROGRAM_IDL_TAG = "v1.0.0-swap-endpoint" CF_ARB_CONTRACT_ABI_ROOT = { value = "contract-interfaces/arb-contract-abis", relative = true } CF_TEST_CONFIG_ROOT = { value = "engine/config/testing", relative = true } diff --git a/.github/workflows/upgrade-test.yml b/.github/workflows/upgrade-test.yml index bbf637355b..7db028d08a 100644 --- a/.github/workflows/upgrade-test.yml +++ b/.github/workflows/upgrade-test.yml @@ -23,6 +23,7 @@ on: env: FORCE_COLOR: 1 SOLANA_VERSION: v1.18.17 + SOLANA_PROGRAMS_VERSION: v1.0.0-swap-endpoint NODE_COUNT: "1-node" permissions: @@ -172,7 +173,20 @@ jobs: echo "/usr/lib after copy of .so files" ls -l /usr/lib touch ./localnet/.setup_complete + # TODO: This is a temporary fix to allow the localnet docker-compose.yml from an older commit to run the latest solana programs version. Remove after 1.8 is released. + sed -i 's|ghcr.io/chainflip-io/solana-localnet-ledger:v[0-9.]*|ghcr.io/chainflip-io/solana-localnet-ledger:${{ env.SOLANA_PROGRAMS_VERSION }}|g' localnet/docker-compose.yml ./localnet/manage.sh + git reset --hard + + # TODO: This is a temporary workaround to insert the 1.8 image nonces. Remove after 1.8 is released. + - name: Nonce workaround + if: inputs.run-job + run: | + git checkout ${{ github.sha }} + git rev-parse HEAD + cd bouncer + pnpm install --frozen-lockfile + ./shared/force_sol_nonces.ts - name: Run bouncer on upgrade-from version 🙅‍♂️ if: inputs.run-job @@ -183,8 +197,6 @@ jobs: cd bouncer pnpm install --frozen-lockfile ./run.sh - # TODO: Temporary to discard the fixes above - git reset --hard - name: Upgrade network 🚀 if: inputs.run-job @@ -229,13 +241,13 @@ jobs: # In the case of a compatible upgrade, we don't expect any logs here continue-on-error: true run: | - cat /tmp/chainflip/*/start-all-engines-pre-upgrade.*log + cat /tmp/chainflip/*/chainflip-engine-pre-upgrade.*log - name: Print new post-upgrade chainflip-engine logs 🚗 if: always() continue-on-error: true run: | - cat /tmp/chainflip/*/start-all-engines-post-upgrade.*log + cat /tmp/chainflip/*/chainflip-engine-post-upgrade.*log - name: Print chainflip-node logs 📡 if: always() diff --git a/bouncer/cf-abis b/bouncer/cf-abis deleted file mode 120000 index cced4bce38..0000000000 --- a/bouncer/cf-abis +++ /dev/null @@ -1 +0,0 @@ -../eth-contract-abis/perseverance-rc17 \ No newline at end of file diff --git a/bouncer/shared/contract_interfaces.ts b/bouncer/shared/contract_interfaces.ts index b909d41364..a7dd2d4ff6 100644 --- a/bouncer/shared/contract_interfaces.ts +++ b/bouncer/shared/contract_interfaces.ts @@ -13,7 +13,7 @@ function loadContractCached(abiPath: string) { }; } const CF_ETH_CONTRACT_ABI_TAG = 'v1.1.2'; -const CF_SOL_PROGRAM_IDL_TAG = 'v1.0.0'; +const CF_SOL_PROGRAM_IDL_TAG = 'v1.0.0-swap-endpoint'; export const getErc20abi = loadContractCached( '../contract-interfaces/eth-contract-abis/IERC20.json', ); diff --git a/bouncer/shared/evm_vault_swap.ts b/bouncer/shared/evm_vault_swap.ts index 221a476820..b2aedb77f6 100644 --- a/bouncer/shared/evm_vault_swap.ts +++ b/bouncer/shared/evm_vault_swap.ts @@ -1,6 +1,3 @@ -import * as anchor from '@coral-xyz/anchor'; -// import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'; - import { InternalAsset as Asset, executeSwap, @@ -12,13 +9,9 @@ import { } from '@chainflip/cli'; import { HDNodeWallet } from 'ethers'; import { randomBytes } from 'crypto'; -import { PublicKey, sendAndConfirmTransaction, Keypair } from '@solana/web3.js'; -import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import Keyring from '../polkadot/keyring'; import { - observeBalanceIncrease, getContractAddress, - observeCcmReceived, amountToFineAmount, defaultAssetAmounts, chainFromAsset, @@ -26,26 +19,12 @@ import { stateChainAssetFromAsset, createEvmWalletAndFund, newAddress, - evmChains, - getSolWhaleKeyPair, - getSolConnection, } from './utils'; -import { getBalance } from './get_balance'; import { CcmDepositMetadata, DcaParams, FillOrKillParamsX128 } from './new_swap'; -import { SwapContext, SwapStatus } from './swap_context'; - -import VaultIdl from '../../contract-interfaces/sol-program-idls/v1.0.0/vault.json'; -import SwapEndpointIdl from '../../contract-interfaces/sol-program-idls/v1.0.0/swap_endpoint.json'; - -import { SwapEndpoint } from '../../contract-interfaces/sol-program-idls/v1.0.0/types/swap_endpoint'; -import { Vault } from '../../contract-interfaces/sol-program-idls/v1.0.0/types/vault'; - -// Workaround because of anchor issue -const { BN } = anchor; const erc20Assets: Asset[] = ['Flip', 'Usdc', 'Usdt', 'ArbUsdc']; -export async function executeVaultSwap( +export async function executeEvmVaultSwap( sourceAsset: Asset, destAsset: Asset, destAddress: string, @@ -81,7 +60,7 @@ export async function executeVaultSwap( if (erc20Assets.includes(sourceAsset)) { // Doing effectively infinite approvals to make sure it doesn't fail. // eslint-disable-next-line @typescript-eslint/no-use-before-define - await approveTokenVault( + await approveEvmTokenVault( sourceAsset, (BigInt(amountToFineAmount(amountToSwap, assetDecimals(sourceAsset))) * 100n).toString(), evmWallet, @@ -128,218 +107,12 @@ export async function executeVaultSwap( return receipt; } -// Temporary before the SDK implements this. -export async function executeSolContractSwap( - srcAsset: Asset, - destAsset: Asset, - destAddress: string, - messageMetadata?: CcmDepositMetadata, -) { - const destChain = chainFromAsset(destAsset); - - // const solanaSwapEndpointId = new PublicKey(getContractAddress('Solana', 'SWAP_ENDPOINT')); - const solanaVaultDataAccount = new PublicKey(getContractAddress('Solana', 'DATA_ACCOUNT')); - const swapEndpointDataAccount = new PublicKey( - getContractAddress('Solana', 'SWAP_ENDPOINT_DATA_ACCOUNT'), - ); - const whaleKeypair = getSolWhaleKeyPair(); - - // We should just be able to do this instead but it's not working... - // const wallet = new NodeWallet(whaleKeypair); - // const provider = new anchor.AnchorProvider(connection, wallet, { - // commitment: 'processed', - // }); - // const cfSwapEndpointProgram = new anchor.Program(SwapEndpointIdl, provider); - // const vaultProgram = new anchor.Program(VaultIdl, provider); - - // The current workaround requires having the wallet in a id.json and then set the ANCHOR_WALLET env. - // TODO: Depending on how the SDK is implemented we can remove this. - process.env.ANCHOR_WALLET = 'shared/solana_keypair.json'; - - const connection = getSolConnection(); - const cfSwapEndpointProgram = new anchor.Program(SwapEndpointIdl as SwapEndpoint); - const vaultProgram = new anchor.Program(VaultIdl as Vault); - - const newEventAccountKeypair = Keypair.generate(); - const fetchedDataAccount = await vaultProgram.account.dataAccount.fetch(solanaVaultDataAccount); - const aggKey = fetchedDataAccount.aggKey; - - const tx = - srcAsset === 'Sol' - ? await cfSwapEndpointProgram.methods - .xSwapNative({ - amount: new BN( - amountToFineAmount(defaultAssetAmounts(srcAsset), assetDecimals(srcAsset)), - ), - dstChain: Number(destChain), - dstAddress: Buffer.from(destAddress), - dstToken: Number(stateChainAssetFromAsset(destAsset)), - ccmParameters: messageMetadata - ? { - message: Buffer.from(messageMetadata.message.slice(2), 'hex'), - gasAmount: new BN(messageMetadata.gasBudget), - } - : null, - // TODO: Encode cfParameters from ccmAdditionalData and other vault swap parameters - cfParameters: Buffer.from(messageMetadata?.ccmAdditionalData?.slice(2) ?? '', 'hex'), - }) - .accountsPartial({ - dataAccount: solanaVaultDataAccount, - aggKey, - from: whaleKeypair.publicKey, - eventDataAccount: newEventAccountKeypair.publicKey, - swapEndpointDataAccount, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .signers([whaleKeypair, newEventAccountKeypair]) - .transaction() - : await cfSwapEndpointProgram.methods - .xSwapToken({ - amount: new BN( - amountToFineAmount(defaultAssetAmounts(srcAsset), assetDecimals(srcAsset)), - ), - dstChain: Number(destChain), - dstAddress: Buffer.from(destAddress), - dstToken: Number(stateChainAssetFromAsset(destAsset)), - ccmParameters: messageMetadata - ? { - message: Buffer.from(messageMetadata.message.slice(2), 'hex'), - gasAmount: new BN(messageMetadata.gasBudget), - } - : null, - // TODO: Encode cfParameters from ccmAdditionalData and other vault swap parameters - cfParameters: Buffer.from(messageMetadata?.ccmAdditionalData?.slice(2) ?? '', 'hex'), - decimals: assetDecimals(srcAsset), - }) - .accountsPartial({ - dataAccount: solanaVaultDataAccount, - tokenVaultAssociatedTokenAccount: new PublicKey( - getContractAddress('Solana', 'TOKEN_VAULT_ATA'), - ), - from: whaleKeypair.publicKey, - fromTokenAccount: getAssociatedTokenAddressSync( - new PublicKey(getContractAddress('Solana', 'SolUsdc')), - whaleKeypair.publicKey, - false, - ), - eventDataAccount: newEventAccountKeypair.publicKey, - swapEndpointDataAccount, - tokenSupportedAccount: new PublicKey( - getContractAddress('Solana', 'SolUsdcTokenSupport'), - ), - tokenProgram: TOKEN_PROGRAM_ID, - mint: new PublicKey(getContractAddress('Solana', 'SolUsdc')), - systemProgram: anchor.web3.SystemProgram.programId, - }) - .signers([whaleKeypair, newEventAccountKeypair]) - .transaction(); - const txHash = await sendAndConfirmTransaction(connection, tx, [ - whaleKeypair, - newEventAccountKeypair, - ]); - - console.log('tx', txHash); - return txHash; -} -export type VaultSwapParams = { - sourceAsset: Asset; - destAsset: Asset; - destAddress: string; - txHash: string; -}; - -export async function performVaultSwap( +export async function approveEvmTokenVault( sourceAsset: Asset, - destAsset: Asset, - destAddress: string, - swapTag = '', - messageMetadata?: CcmDepositMetadata, - swapContext?: SwapContext, - log = true, - amount?: string, - boostFeeBps?: number, - fillOrKillParams?: FillOrKillParamsX128, - dcaParams?: DcaParams, -): Promise { - const tag = swapTag ?? ''; - const amountToSwap = amount ?? defaultAssetAmounts(sourceAsset); - const srcChain = chainFromAsset(sourceAsset); - - try { - let wallet; - let txHash: string; - let sourceAddress: string; - - if (evmChains.includes(srcChain)) { - // Generate a new wallet for each vault swap to prevent nonce issues when running in parallel - // with other swaps via deposit channels. - wallet = await createEvmWalletAndFund(sourceAsset); - sourceAddress = wallet!.address.toLowerCase(); - } else { - sourceAddress = getSolWhaleKeyPair().publicKey.toBase58(); - } - - const oldBalance = await getBalance(destAsset, destAddress); - if (log) { - console.log(`${tag} Old balance: ${oldBalance}`); - console.log( - `${tag} Executing (${sourceAsset}) vault swap to(${destAsset}) ${destAddress}. Current balance: ${oldBalance}`, - ); - } - - // TODO: Temporary before the SDK implements this. - if (evmChains.includes(srcChain)) { - // To uniquely identify the VaultSwap, we need to use the TX hash. This is only known - // after sending the transaction, so we send it first and observe the events afterwards. - // There are still multiple blocks of safety margin inbetween before the event is emitted - const receipt = await executeVaultSwap( - sourceAsset, - destAsset, - destAddress, - messageMetadata, - amountToSwap, - boostFeeBps, - fillOrKillParams, - dcaParams, - wallet, - ); - txHash = receipt.hash; - sourceAddress = wallet!.address.toLowerCase(); - } else { - txHash = await executeSolContractSwap(sourceAsset, destAsset, destAddress, messageMetadata); - sourceAddress = getSolWhaleKeyPair().publicKey.toBase58(); - } - swapContext?.updateStatus(swapTag, SwapStatus.VaultContractExecuted); - - const ccmEventEmitted = messageMetadata - ? observeCcmReceived(sourceAsset, destAsset, destAddress, messageMetadata, sourceAddress) - : Promise.resolve(); - - const [newBalance] = await Promise.all([ - observeBalanceIncrease(destAsset, destAddress, oldBalance), - ccmEventEmitted, - ]); - if (log) { - console.log(`${tag} Swap success! New balance: ${newBalance}!`); - } - swapContext?.updateStatus(swapTag, SwapStatus.Success); - return { - sourceAsset, - destAsset, - destAddress, - txHash, - }; - } catch (err) { - console.error('err:', err); - swapContext?.updateStatus(swapTag, SwapStatus.Failure); - if (err instanceof Error) { - console.log(err.stack); - } - throw new Error(`${tag} ${err}`); - } -} -export async function approveTokenVault(sourceAsset: Asset, amount: string, wallet: HDNodeWallet) { + amount: string, + wallet: HDNodeWallet, +) { if (!erc20Assets.includes(sourceAsset)) { throw new Error(`Unsupported asset, not an ERC20: ${sourceAsset}`); } diff --git a/bouncer/shared/force_sol_nonces.ts b/bouncer/shared/force_sol_nonces.ts new file mode 100755 index 0000000000..66f3009bca --- /dev/null +++ b/bouncer/shared/force_sol_nonces.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env -S pnpm tsx + +import { PublicKey } from '@solana/web3.js'; +import { decodeSolAddress, runWithTimeoutAndExit } from '../shared/utils'; +import { submitGovernanceExtrinsic } from '../shared/cf_governance'; + +async function forceRecoverSolNonce(nonceAddress: string, nonceValue: string) { + await submitGovernanceExtrinsic(async (chainflip) => + chainflip.tx.environment.forceRecoverSolNonce( + decodeSolAddress(new PublicKey(nonceAddress).toBase58()), + decodeSolAddress(new PublicKey(nonceValue).toBase58()), + ), + ); +} + +async function main() { + await forceRecoverSolNonce( + '2cNMwUCF51djw2xAiiU54wz1WrU8uG4Q8Kp8nfEuwghw', + '8T217weMrePR8VqqiY1J6VQKn5GfDXDwTPuYekPffNTo', + ); + await forceRecoverSolNonce( + 'HVG21SovGzMBJDB9AQNuWb6XYq4dDZ6yUwCbRUuFnYDo', + 'Hvg5WgDgdhcex1TsJW8PiPqcxUizLitoEmXcCShmXVWJ', + ); + await forceRecoverSolNonce( + 'HDYArziNzyuNMrK89igisLrXFe78ti8cvkcxfx4qdU2p', + '8vM4M9MWoYZE7YDGhpUhoetabY8dwaz4AcDR9hbCHd7u', + ); + await forceRecoverSolNonce( + 'HLPsNyxBqfq2tLE31v6RiViLp2dTXtJRgHgsWgNDRPs2', + '3fWpjCEzbHNU8qQD8YqoE5PfFahHNp4nwVVgJzxwTZya', + ); + await forceRecoverSolNonce( + 'GKMP63TqzbueWTrFYjRwMNkAyTHpQ54notRbAbMDmePM', + '96CDysvpx87Cd4TnxsMajFA9cKwFid1tMUFWnQWnifpJ', + ); + await forceRecoverSolNonce( + 'EpmHm2aSPsB5ZZcDjqDhQ86h1BV32GFCbGSMuC58Y2tn', + 'BjY7LRovNVwGEh5BGcfK4bZcjVan4YzuyFqcBwraG9Bj', + ); + await forceRecoverSolNonce( + '9yBZNMrLrtspj4M7bEf2X6tqbqHxD2vNETw8qSdvJHMa', + 'Hb35byiENfrMFwznb5TUxdAWN52dV81tWYWT3N99VRWr', + ); + await forceRecoverSolNonce( + 'J9dT7asYJFGS68NdgDCYjzU2Wi8uBoBusSHN1Z6JLWna', + '4TbbvCow8yHxnzdMT22gUt3JvHwAF8dbscBCLRezmpCY', + ); + await forceRecoverSolNonce( + 'GUMpVpQFNYJvSbyTtUarZVL7UDUgErKzDTSVJhekUX55', + 'FooctpZoHqoSjDE983JTJRyovN5Py6PZiiubd53gLFMv', + ); + await forceRecoverSolNonce( + 'AUiHYbzH7qLZSkb3u7nAqtvqC7e41sEzgWjBEvXrpfGv', + 'HCNp5KwKadPNiPs3nY1DtVcDfFkuUE2uUBsBueZbnkWc', + ); +} + +await runWithTimeoutAndExit(main(), 60); diff --git a/bouncer/shared/perform_swap.ts b/bouncer/shared/perform_swap.ts index a698803304..b387ff49ee 100644 --- a/bouncer/shared/perform_swap.ts +++ b/bouncer/shared/perform_swap.ts @@ -14,10 +14,19 @@ import { chainFromAsset, observeSwapRequested, SwapRequestType, + evmChains, + createEvmWalletAndFund, + getSolWhaleKeyPair, + decodeSolAddress, + VaultSwapParams, + TransactionOriginId, + TransactionOrigin, } from '../shared/utils'; import { CcmDepositMetadata } from '../shared/new_swap'; import { SwapContext, SwapStatus } from './swap_context'; import { getChainflipApi, observeEvent } from './utils/substrate'; +import { executeEvmVaultSwap } from './evm_vault_swap'; +import { executeSolVaultSwap } from './sol_vault_swap'; function encodeDestinationAddress(address: string, destAsset: Asset): string { let destAddress = address; @@ -129,7 +138,7 @@ export async function doPerformSwap( const swapRequestedHandle = observeSwapRequested( sourceAsset, destAsset, - channelId, + { type: TransactionOrigin.DepositChannel, channelId }, messageMetadata ? SwapRequestType.Ccm : SwapRequestType.Regular, ); @@ -233,3 +242,136 @@ export async function performAndTrackSwap( else throw new Error('Failed to retrieve broadcastId!'); console.log(`${tag} broadcast executed succesfully, swap is complete!`); } + +export async function executeVaultSwap( + sourceAsset: Asset, + destAsset: Asset, + destAddress: string, + messageMetadata?: CcmDepositMetadata, + amount?: string, + boostFeeBps?: number, + fillOrKillParams?: FillOrKillParamsX128, + dcaParams?: DcaParams, +) { + let sourceAddress: string; + let transactionId: TransactionOriginId; + + const srcChain = chainFromAsset(sourceAsset); + + if (evmChains.includes(srcChain)) { + // Generate a new wallet for each vault swap to prevent nonce issues when running in parallel + // with other swaps via deposit channels. + const wallet = await createEvmWalletAndFund(sourceAsset); + sourceAddress = wallet.address.toLowerCase(); + + // To uniquely identify the VaultSwap, we need to use the TX hash. This is only known + // after sending the transaction, so we send it first and observe the events afterwards. + // There are still multiple blocks of safety margin inbetween before the event is emitted + const receipt = await executeEvmVaultSwap( + sourceAsset, + destAsset, + destAddress, + messageMetadata, + amount, + boostFeeBps, + fillOrKillParams, + dcaParams, + wallet, + ); + transactionId = { type: TransactionOrigin.VaultSwapEvm, txHash: receipt.hash }; + sourceAddress = wallet.address.toLowerCase(); + } else { + // Temporary until we implement the Solana encoding in the SDK/BrokerApi + if (boostFeeBps || fillOrKillParams || dcaParams) { + throw new Error( + 'BoostFeeBps, FillOrKillParams and DcaParams are not supported for Solana vault swaps for now', + ); + } + const { slot, accountAddress } = await executeSolVaultSwap( + sourceAsset, + destAsset, + destAddress, + messageMetadata, + ); + transactionId = { + type: TransactionOrigin.VaultSwapSolana, + addressAndSlot: [decodeSolAddress(accountAddress.toBase58()), slot], + }; + sourceAddress = decodeSolAddress(getSolWhaleKeyPair().publicKey.toBase58()); + } + + return { transactionId, sourceAddress }; +} + +export async function performVaultSwap( + sourceAsset: Asset, + destAsset: Asset, + destAddress: string, + swapTag = '', + messageMetadata?: CcmDepositMetadata, + swapContext?: SwapContext, + log = true, + amount?: string, + boostFeeBps?: number, + fillOrKillParams?: FillOrKillParamsX128, + dcaParams?: DcaParams, +): Promise { + const tag = swapTag ?? ''; + + const oldBalance = await getBalance(destAsset, destAddress); + if (log) { + console.log(`${tag} Old balance: ${oldBalance}`); + console.log( + `${tag} Executing (${sourceAsset}) vault swap to(${destAsset}) ${destAddress}. Current balance: ${oldBalance}`, + ); + } + + try { + const { transactionId, sourceAddress } = await executeVaultSwap( + sourceAsset, + destAsset, + destAddress, + messageMetadata, + amount, + boostFeeBps, + fillOrKillParams, + dcaParams, + ); + swapContext?.updateStatus(swapTag, SwapStatus.VaultSwapInitiated); + + await observeSwapRequested( + sourceAsset, + destAsset, + transactionId, + messageMetadata ? SwapRequestType.Ccm : SwapRequestType.Regular, + ); + + swapContext?.updateStatus(swapTag, SwapStatus.VaultSwapScheduled); + + const ccmEventEmitted = messageMetadata + ? observeCcmReceived(sourceAsset, destAsset, destAddress, messageMetadata, sourceAddress) + : Promise.resolve(); + + const [newBalance] = await Promise.all([ + observeBalanceIncrease(destAsset, destAddress, oldBalance), + ccmEventEmitted, + ]); + if (log) { + console.log(`${tag} Swap success! New balance: ${newBalance}!`); + } + swapContext?.updateStatus(swapTag, SwapStatus.Success); + return { + sourceAsset, + destAsset, + destAddress, + transactionId, + }; + } catch (err) { + console.error('err:', err); + swapContext?.updateStatus(swapTag, SwapStatus.Failure); + if (err instanceof Error) { + console.log(err.stack); + } + throw new Error(`${tag} ${err}`); + } +} diff --git a/bouncer/shared/sol_vault_swap.ts b/bouncer/shared/sol_vault_swap.ts new file mode 100644 index 0000000000..4a252b2394 --- /dev/null +++ b/bouncer/shared/sol_vault_swap.ts @@ -0,0 +1,215 @@ +import * as anchor from '@coral-xyz/anchor'; + +import { InternalAsset as Asset, Chains, assetConstants } from '@chainflip/cli'; +import { PublicKey, Keypair, sendAndConfirmTransaction } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { + getContractAddress, + amountToFineAmount, + defaultAssetAmounts, + chainFromAsset, + assetDecimals, + getSolWhaleKeyPair, + getSolConnection, + chainContractId, + decodeDotAddressForContract, + sleep, +} from './utils'; +import { CcmDepositMetadata } from './new_swap'; + +import { SwapEndpoint } from '../../contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint'; +import { Vault } from '../../contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/vault'; +import { getSolanaSwapEndpointIdl, getSolanaVaultIdl } from './contract_interfaces'; + +// @ts-expect-error workaround because of anchor issue +const { BN } = anchor.default; + +// Using AnchorProvider runs into issues so instead we store the wallet in id.json and then +// set the ANCHOR_WALLET env. Depending on how the SDK is implemented we can remove this. +process.env.ANCHOR_WALLET = 'shared/solana_keypair.json'; + +const createdEventAccounts: PublicKey[] = []; + +// Temporary before the SDK implements this. +export async function executeSolVaultSwap( + srcAsset: Asset, + destAsset: Asset, + destAddress: string, + messageMetadata?: CcmDepositMetadata, + amount?: string, +) { + const destChain = chainFromAsset(destAsset); + + const solanaVaultDataAccount = new PublicKey(getContractAddress('Solana', 'DATA_ACCOUNT')); + const swapEndpointDataAccount = new PublicKey( + getContractAddress('Solana', 'SWAP_ENDPOINT_DATA_ACCOUNT'), + ); + const whaleKeypair = getSolWhaleKeyPair(); + + const connection = getSolConnection(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const VaultIdl: any = await getSolanaVaultIdl(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SwapEndpointIdl: any = await getSolanaSwapEndpointIdl(); + + const cfSwapEndpointProgram = new anchor.Program(SwapEndpointIdl as SwapEndpoint); + const vaultProgram = new anchor.Program(VaultIdl as Vault); + + const newEventAccountKeypair = Keypair.generate(); + createdEventAccounts.push(newEventAccountKeypair.publicKey); + + const fetchedDataAccount = await vaultProgram.account.dataAccount.fetch(solanaVaultDataAccount); + const aggKey = fetchedDataAccount.aggKey; + + const amountToSwap = new BN( + amountToFineAmount(amount ?? defaultAssetAmounts(srcAsset), assetDecimals(srcAsset)), + ); + + let cfParameters; + + if (messageMetadata) { + // TODO: Currently manually encoded. To use SDK/BrokerApi. + switch (destChain) { + case Chains.Ethereum: + case Chains.Arbitrum: + cfParameters = + '0x000001000000040101010101010101010101010101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000000000000303030303030303030303030303030303030303030303030303030303030303040000'; + break; + default: + throw new Error(`Unsupported chain: ${destChain}`); + } + } else { + // TODO: Currently manually encoded. To use SDK/BrokerApi. + switch (destChain) { + case Chains.Ethereum: + case Chains.Arbitrum: + cfParameters = + '0x0001000000000202020202020202020202020202020202020202000000000000000000000000000000000000000000000000000000000000000000000303030303030303030303030303030303030303030303030303030303030303040000'; + break; + case Chains.Polkadot: + cfParameters = + '0x0001000000010404040404040404040404040404040404040404040404040404040404040404000000000000000000000000000000000000000000000000000000000000000000000303030303030303030303030303030303030303030303030303030303030303040000'; + break; + // TODO: Not supporting BTC for now because the encoding is annoying. + default: + throw new Error(`Unsupported chain: ${destChain}`); + } + } + + const destinationAddress = + destChain === Chains.Polkadot ? decodeDotAddressForContract(destAddress) : destAddress; + + const tx = + srcAsset === 'Sol' + ? await cfSwapEndpointProgram.methods + .xSwapNative({ + amount: amountToSwap, + dstChain: chainContractId(destChain), + dstAddress: Buffer.from(destinationAddress.slice(2), 'hex'), + dstToken: assetConstants[destAsset].contractId, + ccmParameters: messageMetadata + ? { + message: Buffer.from(messageMetadata.message.slice(2), 'hex'), + gasAmount: new BN(messageMetadata.gasBudget), + } + : null, + cfParameters: Buffer.from(cfParameters!.slice(2) ?? '', 'hex'), + }) + .accountsPartial({ + dataAccount: solanaVaultDataAccount, + aggKey, + from: whaleKeypair.publicKey, + eventDataAccount: newEventAccountKeypair.publicKey, + swapEndpointDataAccount, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([whaleKeypair, newEventAccountKeypair]) + .transaction() + : await cfSwapEndpointProgram.methods + .xSwapToken({ + amount: amountToSwap, + dstChain: chainContractId(destChain), + dstAddress: Buffer.from(destinationAddress.slice(2), 'hex'), + dstToken: assetConstants[destAsset].contractId, + ccmParameters: messageMetadata + ? { + message: Buffer.from(messageMetadata.message.slice(2), 'hex'), + gasAmount: new BN(messageMetadata.gasBudget), + } + : null, + cfParameters: Buffer.from(cfParameters!.slice(2) ?? '', 'hex'), + decimals: assetDecimals(srcAsset), + }) + .accountsPartial({ + dataAccount: solanaVaultDataAccount, + tokenVaultAssociatedTokenAccount: new PublicKey( + getContractAddress('Solana', 'TOKEN_VAULT_ATA'), + ), + from: whaleKeypair.publicKey, + fromTokenAccount: getAssociatedTokenAddressSync( + new PublicKey(getContractAddress('Solana', 'SolUsdc')), + whaleKeypair.publicKey, + false, + ), + eventDataAccount: newEventAccountKeypair.publicKey, + swapEndpointDataAccount, + tokenSupportedAccount: new PublicKey( + getContractAddress('Solana', 'SolUsdcTokenSupport'), + ), + tokenProgram: TOKEN_PROGRAM_ID, + mint: new PublicKey(getContractAddress('Solana', 'SolUsdc')), + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([whaleKeypair, newEventAccountKeypair]) + .transaction(); + const txHash = await sendAndConfirmTransaction( + connection, + tx, + [whaleKeypair, newEventAccountKeypair], + { commitment: 'confirmed' }, + ); + + const transactionData = await connection.getTransaction(txHash, { commitment: 'confirmed' }); + if (transactionData === null) { + throw new Error('Solana TransactionData is empty'); + } + return { txHash, slot: transactionData!.slot, accountAddress: newEventAccountKeypair.publicKey }; +} + +export async function checkSolEventAccountsClosure( + eventAccounts: PublicKey[] = createdEventAccounts, +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SwapEndpointIdl: any = await getSolanaSwapEndpointIdl(); + const cfSwapEndpointProgram = new anchor.Program(SwapEndpointIdl as SwapEndpoint); + const swapEndpointDataAccountAddress = new PublicKey( + getContractAddress('Solana', 'SWAP_ENDPOINT_DATA_ACCOUNT'), + ); + + const maxRetries = 50; // 300 seconds + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const swapEndpointDataAccount = + await cfSwapEndpointProgram.account.swapEndpointDataAccount.fetch( + swapEndpointDataAccountAddress, + ); + + if (swapEndpointDataAccount.openEventAccounts.length >= 10) { + await sleep(6000); + } else { + const onChainOpenedAccounts = swapEndpointDataAccount.openEventAccounts.map((element) => + element.toString(), + ); + for (const eventAccount of eventAccounts) { + if (!onChainOpenedAccounts.includes(eventAccount.toString())) { + const accountInfo = await getSolConnection().getAccountInfo(eventAccount); + if (accountInfo !== null) { + throw new Error('Event account still exists, should have been closed'); + } + } + } + return; + } + } + throw new Error('Timed out waiting for event accounts to be closed'); +} diff --git a/bouncer/shared/swap_context.ts b/bouncer/shared/swap_context.ts index 5825547225..a11afc174a 100644 --- a/bouncer/shared/swap_context.ts +++ b/bouncer/shared/swap_context.ts @@ -3,7 +3,8 @@ import assert from 'assert'; export enum SwapStatus { Initiated, Funded, - VaultContractExecuted, + VaultSwapInitiated, + VaultSwapScheduled, SwapScheduled, Success, Failure, @@ -35,16 +36,23 @@ export class SwapContext { ); break; } - case SwapStatus.VaultContractExecuted: { + case SwapStatus.VaultSwapInitiated: { assert( currentStatus === SwapStatus.Initiated, `Unexpected status transition for ${tag}. Transitioning from ${currentStatus} to ${status}`, ); break; } + case SwapStatus.VaultSwapScheduled: { + assert( + currentStatus === SwapStatus.VaultSwapInitiated, + `Unexpected status transition for ${tag}. Transitioning from ${currentStatus} to ${status}`, + ); + break; + } case SwapStatus.SwapScheduled: { assert( - currentStatus === SwapStatus.VaultContractExecuted || currentStatus === SwapStatus.Funded, + currentStatus === SwapStatus.Funded, `Unexpected status transition for ${tag}. Transitioning from ${currentStatus} to ${status}`, ); break; @@ -52,7 +60,7 @@ export class SwapContext { case SwapStatus.Success: { assert( currentStatus === SwapStatus.SwapScheduled || - currentStatus === SwapStatus.VaultContractExecuted, + currentStatus === SwapStatus.VaultSwapScheduled, `Unexpected status transition for ${tag}. Transitioning from ${currentStatus} to ${status}`, ); break; diff --git a/bouncer/shared/swapping.ts b/bouncer/shared/swapping.ts index c7e9520493..84f23783a6 100644 --- a/bouncer/shared/swapping.ts +++ b/bouncer/shared/swapping.ts @@ -3,7 +3,7 @@ import { Keypair, PublicKey } from '@solana/web3.js'; import Web3 from 'web3'; import { u8aToHex } from '@polkadot/util'; import { randomAsHex, randomAsNumber } from '../polkadot/util-crypto'; -import { performSwap } from '../shared/perform_swap'; +import { performSwap, performVaultSwap } from '../shared/perform_swap'; import { newAddress, chainFromAsset, @@ -16,7 +16,6 @@ import { } from '../shared/utils'; import { BtcAddressType } from '../shared/new_btc_address'; import { CcmDepositMetadata } from '../shared/new_swap'; -import { performVaultSwap } from './evm_vault_swap'; import { SwapContext, SwapStatus } from './swap_context'; enum SolidityType { diff --git a/bouncer/shared/utils.ts b/bouncer/shared/utils.ts index 12a941199f..c44a43f787 100644 --- a/bouncer/shared/utils.ts +++ b/bouncer/shared/utils.ts @@ -50,6 +50,13 @@ export const evmChains = ['Ethereum', 'Arbitrum'] as Chain[]; export type Asset = SDKAsset; export type Chain = SDKChain; +export type VaultSwapParams = { + sourceAsset: Asset; + destAsset: Asset; + destAddress: string; + transactionId: TransactionOriginId; +}; + const isSDKAsset = (asset: Asset): asset is SDKAsset => asset in assetConstants; const isSDKChain = (chain: Chain): chain is SDKChain => chain in chainConstants; @@ -488,18 +495,50 @@ export enum SwapRequestType { IngressEgressFee = 'IngressEgressFee', } +export enum TransactionOrigin { + DepositChannel = 'DepositChannel', + VaultSwapEvm = 'VaultSwapEvm', + VaultSwapSolana = 'VaultSwapSolana', +} + +export type TransactionOriginId = + | { type: TransactionOrigin.DepositChannel; channelId: number } + | { type: TransactionOrigin.VaultSwapEvm; txHash: string } + | { type: TransactionOrigin.VaultSwapSolana; addressAndSlot: [string, number] }; + function checkRequestTypeMatches(actual: object | string, expected: SwapRequestType) { if (typeof actual === 'object') { return expected in actual; } - return expected === actual; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function checkTransactionInMatches(actual: any, expected: TransactionOriginId): boolean { + if ('DepositChannel' in actual) { + return ( + expected.type === TransactionOrigin.DepositChannel && + Number(actual.DepositChannel.channelId.replaceAll(',', '')) === expected.channelId + ); + } + if ('Vault' in actual) { + return ( + ('Evm' in actual.Vault.txId && + expected.type === TransactionOrigin.VaultSwapEvm && + actual.Vault.txId.Evm === expected.txHash) || + ('Solana' in actual.Vault.txId && + expected.type === TransactionOrigin.VaultSwapSolana && + actual.Vault.txId.Solana[1].replaceAll(',', '') === expected.addressAndSlot[1].toString() && + actual.Vault.txId.Solana[0].toString() === expected.addressAndSlot[0].toString()) + ); + } + throw new Error(`Unsupported transaction origin type ${actual}`); +} + export async function observeSwapRequested( sourceAsset: Asset, destAsset: Asset, - id: number | string, + id: TransactionOriginId, swapRequestType: SwapRequestType, ) { // need to await this to prevent the chainflip api from being disposed prematurely @@ -508,11 +547,7 @@ export async function observeSwapRequested( const data = event.data; if (typeof data.origin === 'object') { - const channelMatches = - (typeof id === 'number' && - 'DepositChannel' in data.origin && - Number(data.origin.DepositChannel.channelId.replaceAll(',', '')) === id) || - (typeof id === 'string' && 'Vault' in data.origin && data.origin.Vault.txId.Evm === id); + const channelMatches = checkTransactionInMatches(data.origin, id); const sourceAssetMatches = sourceAsset === (data.inputAsset as Asset); const destAssetMatches = destAsset === (data.outputAsset as Asset); const requestTypeMatches = checkRequestTypeMatches(data.requestType, swapRequestType); diff --git a/bouncer/tests/DCA_test.ts b/bouncer/tests/DCA_test.ts index 57b5d8c8d0..e4282f766f 100644 --- a/bouncer/tests/DCA_test.ts +++ b/bouncer/tests/DCA_test.ts @@ -6,14 +6,14 @@ import { observeBalanceIncrease, observeSwapRequested, SwapRequestType, + TransactionOrigin, } from '../shared/utils'; import { send } from '../shared/send'; import { observeEvent, observeEvents } from '../shared/utils/substrate'; import { getBalance } from '../shared/get_balance'; import { ExecutableTest } from '../shared/executable_test'; -import { requestNewSwap } from '../shared/perform_swap'; +import { executeVaultSwap, requestNewSwap } from '../shared/perform_swap'; import { DcaParams, FillOrKillParamsX128 } from '../shared/new_swap'; -import { executeVaultSwap } from '../shared/evm_vault_swap'; /* eslint-disable @typescript-eslint/no-use-before-define */ export const testDCASwaps = new ExecutableTest('DCA-Swaps', main, 150); @@ -66,7 +66,7 @@ async function testDCASwap( swapRequestedHandle = observeSwapRequested( inputAsset, destAsset, - depositChannelId, + { type: TransactionOrigin.DepositChannel, channelId: depositChannelId }, SwapRequestType.Regular, ); @@ -74,7 +74,7 @@ async function testDCASwap( await send(inputAsset, swapRequest.depositAddress, amount.toString()); testDCASwaps.log(`Sent ${amount} ${inputAsset} to ${swapRequest.depositAddress}`); } else { - const receipt = await executeVaultSwap( + const { transactionId } = await executeVaultSwap( inputAsset, destAsset, destAddress, @@ -85,13 +85,13 @@ async function testDCASwap( dcaParams, ); - testDCASwaps.log(`Vault swap executed, tx hash: ${receipt.hash}`); + testDCASwaps.log(`Vault swap executed, tx id: ${transactionId}`); // Look after Swap Requested of data.origin.Vault.tx_hash swapRequestedHandle = observeSwapRequested( inputAsset, destAsset, - receipt.hash, + transactionId, SwapRequestType.Regular, ); } diff --git a/bouncer/tests/all_concurrent_tests.ts b/bouncer/tests/all_concurrent_tests.ts index 338c6e36bf..d603d29666 100755 --- a/bouncer/tests/all_concurrent_tests.ts +++ b/bouncer/tests/all_concurrent_tests.ts @@ -15,6 +15,7 @@ import { testAllSwaps } from './all_swaps'; import { depositChannelCreation } from './request_swap_deposit_address_with_affiliates'; import { testDCASwaps } from './DCA_test'; import { testBrokerLevelScreening } from './broker_level_screening'; +import { checkSolEventAccountsClosure } from '../shared/sol_vault_swap'; async function runAllConcurrentTests() { // Specify the number of nodes via providing an argument to this script. @@ -58,6 +59,8 @@ async function runAllConcurrentTests() { await Promise.all([broadcastAborted.stop(), feeDeficitRefused.stop()]); + await checkSolEventAccountsClosure(); + await checkAvailabilityAllSolanaNonces(); } diff --git a/bouncer/tests/all_swaps.ts b/bouncer/tests/all_swaps.ts index 9f777ff8ef..b7bee979a0 100644 --- a/bouncer/tests/all_swaps.ts +++ b/bouncer/tests/all_swaps.ts @@ -1,10 +1,9 @@ import { InternalAsset as Asset, InternalAssets as Assets } from '@chainflip/cli'; -import { VaultSwapParams } from '../shared/evm_vault_swap'; import { ExecutableTest } from '../shared/executable_test'; import { SwapParams } from '../shared/perform_swap'; import { newCcmMetadata, testSwap, testVaultSwap } from '../shared/swapping'; import { btcAddressTypes } from '../shared/new_btc_address'; -import { ccmSupportedChains, chainFromAsset } from '../shared/utils'; +import { ccmSupportedChains, chainFromAsset, VaultSwapParams } from '../shared/utils'; /* eslint-disable @typescript-eslint/no-use-before-define */ export const testAllSwaps = new ExecutableTest('All-Swaps', main, 3000); @@ -68,5 +67,14 @@ async function main() { }); }); + // Not doing BTC due to encoding complexity in vault_swap. Will be fixed once SDK supports it. + appendSwap('Sol', 'Eth', testVaultSwap); + appendSwap('Sol', 'Usdc', testVaultSwap, true); + appendSwap('Sol', 'ArbEth', testVaultSwap); + appendSwap('Sol', 'ArbEth', testVaultSwap, true); + appendSwap('Sol', 'Dot', testVaultSwap); + appendSwap('SolUsdc', 'Eth', testVaultSwap); + appendSwap('SolUsdc', 'Flip', testVaultSwap, true); + await Promise.all(allSwaps); } diff --git a/bouncer/tests/broker_fee_collection.ts b/bouncer/tests/broker_fee_collection.ts index 594948f3ff..54b7a814a1 100644 --- a/bouncer/tests/broker_fee_collection.ts +++ b/bouncer/tests/broker_fee_collection.ts @@ -15,6 +15,7 @@ import { amountToFineAmountBigInt, SwapRequestType, observeSwapRequested, + TransactionOrigin, } from '../shared/utils'; import { getBalance } from '../shared/get_balance'; import { getChainflipApi, observeEvent } from '../shared/utils/substrate'; @@ -120,7 +121,7 @@ async function testBrokerFees(inputAsset: Asset, seed?: string): Promise { const swapRequestedHandle = observeSwapRequested( inputAsset, destAsset, - channelId, + { type: TransactionOrigin.DepositChannel, channelId }, SwapRequestType.Regular, ); diff --git a/bouncer/tests/evm_deposits.ts b/bouncer/tests/evm_deposits.ts index f046016904..a3104cfb95 100644 --- a/bouncer/tests/evm_deposits.ts +++ b/bouncer/tests/evm_deposits.ts @@ -16,6 +16,7 @@ import { assetContractId, observeSwapRequested, SwapRequestType, + TransactionOrigin, } from '../shared/utils'; import { signAndSendTxEvm } from '../shared/send_evm'; import { getCFTesterAbi } from '../shared/contract_interfaces'; @@ -152,7 +153,7 @@ async function testDoubleDeposit(sourceAsset: Asset, destAsset: Asset) { const swapRequestedHandle = observeSwapRequested( sourceAsset, destAsset, - swapParams.channelId, + { type: TransactionOrigin.DepositChannel, channelId: swapParams.channelId }, SwapRequestType.Regular, ); @@ -166,7 +167,7 @@ async function testDoubleDeposit(sourceAsset: Asset, destAsset: Asset) { const swapRequestedHandle = observeSwapRequested( sourceAsset, destAsset, - swapParams.channelId, + { type: TransactionOrigin.DepositChannel, channelId: swapParams.channelId }, SwapRequestType.Regular, ); diff --git a/bouncer/tests/fill_or_kill.ts b/bouncer/tests/fill_or_kill.ts index 84b40b2dfb..525663cd9c 100644 --- a/bouncer/tests/fill_or_kill.ts +++ b/bouncer/tests/fill_or_kill.ts @@ -8,14 +8,14 @@ import { observeBalanceIncrease, observeSwapRequested, SwapRequestType, + TransactionOrigin, } from '../shared/utils'; -import { requestNewSwap } from '../shared/perform_swap'; +import { executeVaultSwap, requestNewSwap } from '../shared/perform_swap'; import { send } from '../shared/send'; import { getBalance } from '../shared/get_balance'; import { observeEvent } from '../shared/utils/substrate'; import { CcmDepositMetadata, FillOrKillParamsX128 } from '../shared/new_swap'; import { ExecutableTest } from '../shared/executable_test'; -import { executeVaultSwap } from '../shared/evm_vault_swap'; import { newCcmMetadata } from '../shared/swapping'; /* eslint-disable @typescript-eslint/no-use-before-define */ @@ -60,12 +60,10 @@ async function testMinPriceRefund(inputAsset: Asset, amount: number, swapViaVaul refundParameters, ); const depositAddress = swapRequest.depositAddress; - const depositChannelId = swapRequest.channelId; - swapRequestedHandle = observeSwapRequested( inputAsset, destAsset, - depositChannelId, + { type: TransactionOrigin.DepositChannel, channelId: swapRequest.channelId }, SwapRequestType.Regular, ); @@ -85,7 +83,7 @@ async function testMinPriceRefund(inputAsset: Asset, amount: number, swapViaVaul Math.random() < 0.5 ? ccmMetadata.ccmAdditionalData : undefined; } - const receipt = await executeVaultSwap( + const { transactionId } = await executeVaultSwap( inputAsset, destAsset, destAddress, @@ -98,7 +96,7 @@ async function testMinPriceRefund(inputAsset: Asset, amount: number, swapViaVaul swapRequestedHandle = observeSwapRequested( inputAsset, destAsset, - receipt.hash, + transactionId, SwapRequestType.Regular, ); } diff --git a/bouncer/tests/gaslimit_ccm.ts b/bouncer/tests/gaslimit_ccm.ts index 3bccd06174..1f38819410 100644 --- a/bouncer/tests/gaslimit_ccm.ts +++ b/bouncer/tests/gaslimit_ccm.ts @@ -14,6 +14,7 @@ import { sleep, SwapRequestType, SwapType, + TransactionOrigin, } from '../shared/utils'; import { requestNewSwap } from '../shared/perform_swap'; import { send } from '../shared/send'; @@ -151,7 +152,7 @@ async function trackGasLimitSwap( const swapRequestedHandle = observeSwapRequested( sourceAsset, destAsset, - channelId, + { type: TransactionOrigin.DepositChannel, channelId }, SwapRequestType.Ccm, ); await send(sourceAsset, depositAddress); diff --git a/contract-interfaces/sol-program-idls/download-sol-program-idls.sh b/contract-interfaces/sol-program-idls/download-sol-program-idls.sh index a6f892928e..6bcd10b839 100755 --- a/contract-interfaces/sol-program-idls/download-sol-program-idls.sh +++ b/contract-interfaces/sol-program-idls/download-sol-program-idls.sh @@ -30,8 +30,11 @@ gh release download \ unzip -u ${ZIP_FILE} \ 'vault.json' \ + 'vault.ts' \ 'cf_tester.json' \ + 'cf_tester.ts' \ 'swap_endpoint.json' \ + 'swap_endpoint.ts' \ -d $TARGET_DIR rm ${ZIP_FILE} \ No newline at end of file diff --git a/contract-interfaces/sol-program-idls/v1.0.0/cf_tester.json b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/cf_tester.json similarity index 100% rename from contract-interfaces/sol-program-idls/v1.0.0/cf_tester.json rename to contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/cf_tester.json diff --git a/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/cf_tester.ts b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/cf_tester.ts new file mode 100644 index 0000000000..b449ed17ac --- /dev/null +++ b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/cf_tester.ts @@ -0,0 +1,541 @@ +/** + * Program IDL in camelCase format in order to be used in JS/TS. + * + * Note that this is only a type helper and is not the actual IDL. The original + * IDL can be found at `target/idl/cf_tester.json`. + */ +export type CfTester = { + "address": "8pBPaVfTAcjLeNfC187Fkvi9b1XEFhRNJ95BQXXVksmH", + "metadata": { + "name": "cfTester", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "cfReceiveNative", + "discriminator": [ + 228, + 51, + 109, + 5, + 176, + 83, + 201, + 81 + ], + "accounts": [ + { + "name": "receiverNative", + "writable": true + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + }, + { + "name": "instructionSysvar", + "address": "Sysvar1nstructions1111111111111111111111111" + } + ], + "args": [ + { + "name": "sourceChain", + "type": "u32" + }, + { + "name": "sourceAddress", + "type": "bytes" + }, + { + "name": "message", + "type": "bytes" + }, + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "cfReceiveToken", + "discriminator": [ + 66, + 95, + 143, + 16, + 13, + 3, + 3, + 83 + ], + "accounts": [ + { + "name": "receiverTokenAccount", + "writable": true + }, + { + "name": "tokenProgram", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "mint" + }, + { + "name": "instructionSysvar", + "address": "Sysvar1nstructions1111111111111111111111111" + } + ], + "args": [ + { + "name": "sourceChain", + "type": "u32" + }, + { + "name": "sourceAddress", + "type": "bytes" + }, + { + "name": "message", + "type": "bytes" + }, + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "contractSwapNative", + "discriminator": [ + 254, + 187, + 162, + 188, + 29, + 232, + 174, + 193 + ], + "accounts": [ + { + "name": "vault" + }, + { + "name": "dataAccount" + }, + { + "name": "aggKey", + "writable": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "arg", + "path": "seed" + } + ] + } + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + }, + { + "name": "eventAuthority" + } + ], + "args": [ + { + "name": "seed", + "type": "bytes" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "dstChain", + "type": "u32" + }, + { + "name": "dstAddress", + "type": "bytes" + }, + { + "name": "dstToken", + "type": "u32" + }, + { + "name": "ccmParameters", + "type": { + "option": { + "defined": { + "name": "ccmParams" + } + } + } + }, + { + "name": "cfParameters", + "type": "bytes" + } + ] + }, + { + "name": "contractSwapToken", + "discriminator": [ + 95, + 144, + 96, + 179, + 3, + 2, + 75, + 95 + ], + "accounts": [ + { + "name": "vault" + }, + { + "name": "dataAccount" + }, + { + "name": "tokenVaultAssociatedTokenAcount", + "writable": true + }, + { + "name": "pda", + "pda": { + "seeds": [ + { + "kind": "arg", + "path": "seed" + } + ] + } + }, + { + "name": "pdaAssociatedTokenAccount", + "writable": true + }, + { + "name": "tokenSupportedAccount" + }, + { + "name": "tokenProgram", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "mint" + }, + { + "name": "eventAuthority" + } + ], + "args": [ + { + "name": "seed", + "type": "bytes" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "dstChain", + "type": "u32" + }, + { + "name": "dstAddress", + "type": "bytes" + }, + { + "name": "dstToken", + "type": "u32" + }, + { + "name": "ccmParameters", + "type": { + "option": { + "defined": { + "name": "ccmParams" + } + } + } + }, + { + "name": "cfParameters", + "type": "bytes" + }, + { + "name": "decimals", + "type": "u8" + } + ] + }, + { + "name": "initialize", + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "dataAccount", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 100, + 97, + 116, + 97 + ] + } + ] + } + }, + { + "name": "initializer", + "writable": true, + "signer": true + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "newAggKey", + "type": "pubkey" + }, + { + "name": "newGovKey", + "type": "pubkey" + }, + { + "name": "newTokenVaultPda", + "type": "pubkey" + }, + { + "name": "tokenVaultPdaBump", + "type": "u8" + }, + { + "name": "upgradeSignerPda", + "type": "pubkey" + }, + { + "name": "upgradeSignerPdaBump", + "type": "u8" + } + ] + } + ], + "accounts": [ + { + "name": "dataAccount", + "discriminator": [ + 85, + 240, + 182, + 158, + 76, + 7, + 18, + 233 + ] + }, + { + "name": "supportedToken", + "discriminator": [ + 56, + 162, + 96, + 99, + 193, + 245, + 204, + 108 + ] + } + ], + "events": [ + { + "name": "receivedCcm", + "discriminator": [ + 220, + 233, + 232, + 105, + 128, + 112, + 80, + 63 + ] + } + ], + "types": [ + { + "name": "ccmParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "message", + "type": "bytes" + }, + { + "name": "gasAmount", + "type": "u64" + } + ] + } + }, + { + "name": "dataAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "aggKey", + "type": "pubkey" + }, + { + "name": "govKey", + "type": "pubkey" + }, + { + "name": "tokenVaultPda", + "type": "pubkey" + }, + { + "name": "tokenVaultBump", + "type": "u8" + }, + { + "name": "upgradeSignerPda", + "type": "pubkey" + }, + { + "name": "upgradeSignerPdaBump", + "type": "u8" + }, + { + "name": "suspended", + "type": "bool" + }, + { + "name": "suspendedIxSwaps", + "type": "bool" + }, + { + "name": "suspendedEventSwaps", + "type": "bool" + }, + { + "name": "minNativeSwapAmount", + "type": "u64" + }, + { + "name": "maxDstAddressLen", + "type": "u16" + }, + { + "name": "maxCcmMessageLen", + "type": "u32" + }, + { + "name": "maxCfParametersLen", + "type": "u32" + }, + { + "name": "maxEventAccounts", + "type": "u32" + } + ] + } + }, + { + "name": "receivedCcm", + "type": { + "kind": "struct", + "fields": [ + { + "name": "sourceChain", + "type": "u32" + }, + { + "name": "sourceAddress", + "type": "bytes" + }, + { + "name": "message", + "type": "bytes" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "remainingPubkeys", + "type": { + "vec": "pubkey" + } + }, + { + "name": "remainingIsSigner", + "type": { + "vec": "bool" + } + }, + { + "name": "remainingIsWritable", + "type": { + "vec": "bool" + } + } + ] + } + }, + { + "name": "supportedToken", + "type": { + "kind": "struct", + "fields": [ + { + "name": "tokenMintPubkey", + "type": "pubkey" + }, + { + "name": "minSwapAmount", + "type": "u64" + } + ] + } + } + ] +}; diff --git a/contract-interfaces/sol-program-idls/v1.0.0/swap_endpoint.json b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint.json similarity index 99% rename from contract-interfaces/sol-program-idls/v1.0.0/swap_endpoint.json rename to contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint.json index 19f570dfd6..5fcb28951d 100644 --- a/contract-interfaces/sol-program-idls/v1.0.0/swap_endpoint.json +++ b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint.json @@ -528,6 +528,10 @@ "type": { "kind": "struct", "fields": [ + { + "name": "creation_slot", + "type": "u64" + }, { "name": "sender", "type": "pubkey" diff --git a/contract-interfaces/sol-program-idls/v1.0.0/types/swap_endpoint.ts b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint.ts similarity index 99% rename from contract-interfaces/sol-program-idls/v1.0.0/types/swap_endpoint.ts rename to contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint.ts index 8eae9704d2..f57e3f0edb 100644 --- a/contract-interfaces/sol-program-idls/v1.0.0/types/swap_endpoint.ts +++ b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/swap_endpoint.ts @@ -534,6 +534,10 @@ export type SwapEndpoint = { "type": { "kind": "struct", "fields": [ + { + "name": "creationSlot", + "type": "u64" + }, { "name": "sender", "type": "pubkey" diff --git a/contract-interfaces/sol-program-idls/v1.0.0/vault.json b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/vault.json similarity index 100% rename from contract-interfaces/sol-program-idls/v1.0.0/vault.json rename to contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/vault.json diff --git a/contract-interfaces/sol-program-idls/v1.0.0/types/vault.ts b/contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/vault.ts similarity index 100% rename from contract-interfaces/sol-program-idls/v1.0.0/types/vault.ts rename to contract-interfaces/sol-program-idls/v1.0.0-swap-endpoint/vault.ts diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 9be3fd7bed..cce550e7d3 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -32,7 +32,6 @@ httparse = { workspace = true } http = { workspace = true } itertools = { workspace = true, default-features = true } jsonrpsee = { workspace = true, features = ["full"] } - dyn-clone = { workspace = true } ethbloom = { workspace = true } ethers = { workspace = true, features = ["rustls"] } @@ -41,7 +40,10 @@ num-bigint = { workspace = true } num-derive = { workspace = true } num-traits = { workspace = true } secp256k1 = { workspace = true, features = ["hashes"] } -serde = { workspace = true, default-features = true, features = ["derive", "rc"] } +serde = { workspace = true, default-features = true, features = [ + "derive", + "rc", +] } serde_json = { workspace = true } sha2 = { workspace = true, default-features = true } subxt = { workspace = true, features = ["substrate-compat"] } @@ -51,7 +53,9 @@ tokio-stream = { workspace = true, features = ["sync"] } url = { workspace = true } web3 = { workspace = true, features = ["ws-tls-tokio"] } zeroize = { workspace = true } -curve25519-dalek = { workspace = true, default-features = true, features = ["serde"] } +curve25519-dalek = { workspace = true, default-features = true, features = [ + "serde", +] } ed25519-dalek = { workspace = true } pin-project = { workspace = true } rand = { workspace = true, default-features = true } @@ -92,19 +96,26 @@ sol-prim = { workspace = true, features = ["pda", "str", "serde", "scale"] } # substrate deps cf-amm = { workspace = true, default-features = true } -codec = { workspace = true, default-features = true, features = ["derive", "full"] } +codec = { workspace = true, default-features = true, features = [ + "derive", + "full", +] } frame-support = { workspace = true, default-features = true } frame-system = { workspace = true, default-features = true } sc-rpc-api = { workspace = true, default-features = true } sc-transaction-pool-api = { workspace = true, default-features = true } -scale-info = { workspace = true, default-features = true, features = ["derive"] } +scale-info = { workspace = true, default-features = true, features = [ + "derive", +] } sp-core = { workspace = true, default-features = true } sp-rpc = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } sp-version = { workspace = true, default-features = true } substrate-frame-rpc-system = { workspace = true } -frame-metadata = { workspace = true, default-features = true, features = ["current"] } +frame-metadata = { workspace = true, default-features = true, features = [ + "current", +] } serde_bytes = { workspace = true, default-features = true } bs58 = { workspace = true, default-features = true } base64 = { workspace = true } @@ -117,7 +128,9 @@ mockall = { workspace = true, features = ["nightly"] } multisig = { workspace = true, features = ["test"] } rlp = { workspace = true, default-features = true } tempfile = { workspace = true } -cf-utilities = { workspace = true, default-features = true, features = ["test-utils"] } +cf-utilities = { workspace = true, default-features = true, features = [ + "test-utils", +] } serde_path_to_error = { workspace = true } [build-dependencies] diff --git a/engine/src/elections/voter_api.rs b/engine/src/elections/voter_api.rs index 0b0dd50a4a..3d1c30a36d 100644 --- a/engine/src/elections/voter_api.rs +++ b/engine/src/elections/voter_api.rs @@ -78,4 +78,4 @@ macro_rules! generate_voter_api_tuple_impls { } } -generate_voter_api_tuple_impls!(tuple_6_impls: ((A, A0), (B, B0), (C, C0), (D, D0), (EE, E0), (FF, F0))); +generate_voter_api_tuple_impls!(tuple_7_impls: ((A, A0), (B, B0), (C, C0), (D, D0), (EE, E0), (FF, F0), (GG, G0))); diff --git a/engine/src/witness/common/cf_parameters.rs b/engine/src/witness/common/cf_parameters.rs index e873f0d69a..5496e445df 100644 --- a/engine/src/witness/common/cf_parameters.rs +++ b/engine/src/witness/common/cf_parameters.rs @@ -37,12 +37,7 @@ mod tests { const MAX_CF_PARAM_LENGTH: u32 = MAX_CCM_ADDITIONAL_DATA_LENGTH + MAX_VAULT_SWAP_PARAMETERS_LENGTH; - const REFERENCE_EXPECTED_ENCODED: &[u8] = &[ - 0, 1, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 4, 0, 0, - ]; + const REFERENCE_EXPECTED_ENCODED_HEX: &str = "0001000000000202020202020202020202020202020202020202000000000000000000000000000000000000000000000000000000000000000000000303030303030303030303030303030303030303030303030303030303030303040000"; #[test] fn test_cf_parameters_max_length() { @@ -75,8 +70,9 @@ mod tests { }; let mut encoded = VersionedCfParameters::V0(cf_parameters).encode(); - - assert_eq!(encoded, REFERENCE_EXPECTED_ENCODED); + let expected_encoded: Vec = + hex::decode(REFERENCE_EXPECTED_ENCODED_HEX).expect("Decoding hex string failed"); + assert_eq!(encoded, expected_encoded); let ccm_cf_parameters = CfParameters { ccm_additional_data: CcmAdditionalData::default(), @@ -86,8 +82,8 @@ mod tests { encoded = VersionedCcmCfParameters::V0(ccm_cf_parameters).encode(); // Extra byte for the empty ccm metadata - let expected_encoded = [vec![0], Vec::from(REFERENCE_EXPECTED_ENCODED)].concat(); + let expected_encoded_with_metadata = [vec![0], expected_encoded.clone()].concat(); - assert_eq!(encoded, expected_encoded); + assert_eq!(encoded, expected_encoded_with_metadata); } } diff --git a/engine/src/witness/sol.rs b/engine/src/witness/sol.rs index ce6a750a49..1745336bbf 100644 --- a/engine/src/witness/sol.rs +++ b/engine/src/witness/sol.rs @@ -1,6 +1,7 @@ mod egress_witnessing; mod fee_tracking; mod nonce_witnessing; +mod program_swaps_witnessing; mod sol_deposits; use crate::{ @@ -16,24 +17,31 @@ use crate::{ }, }; use anyhow::Result; -use cf_chains::{sol::SolHash, Chain}; +use cf_chains::{ + sol::{api::VaultSwapAccountAndSender, SolHash}, + Chain, +}; use futures::FutureExt; -use pallet_cf_elections::{electoral_system::ElectoralSystem, vote_storage::VoteStorage}; +use pallet_cf_elections::{ + electoral_system::ElectoralSystem, + electoral_systems::solana_vault_swap_accounts::SolanaVaultSwapsVote, vote_storage::VoteStorage, +}; use state_chain_runtime::{ chainflip::solana_elections::{ SolanaBlockHeightTracking, SolanaEgressWitnessing, SolanaElectoralSystemRunner, SolanaFeeTracking, SolanaIngressTracking, SolanaLiveness, SolanaNonceTracking, - TransactionSuccessDetails, + SolanaVaultSwapTracking, TransactionSuccessDetails, }, SolanaInstance, }; -use cf_utilities::{ - metrics::CHAIN_TRACKING, - task_scope::{self, Scope}, -}; +use cf_utilities::{metrics::CHAIN_TRACKING, task_scope, task_scope::Scope}; use pallet_cf_elections::vote_storage::change::MonotonicChangeVote; -use std::{str::FromStr, sync::Arc}; +use std::{ + collections::{BTreeSet, HashSet}, + str::FromStr, + sync::Arc, +}; #[derive(Clone)] struct SolanaBlockHeightTrackingVoter { @@ -187,6 +195,40 @@ impl VoterApi for SolanaLivenessVoter { } } +#[derive(Clone)] +struct SolanaVaultSwapsVoter { + client: SolRetryRpcClient, +} + +#[async_trait::async_trait] +impl VoterApi for SolanaVaultSwapsVoter { + async fn vote( + &self, + settings: ::ElectoralSettings, + properties: ::ElectionProperties, + ) -> Result< + <::Vote as VoteStorage>::Vote, + anyhow::Error, + > { + program_swaps_witnessing::get_program_swaps( + &self.client, + settings.swap_endpoint_data_account_address, + properties + .witnessed_open_accounts + .into_iter() + .map(|VaultSwapAccountAndSender { vault_swap_account, .. }| vault_swap_account) + .collect::>(), + properties.closure_initiated_accounts, + settings.usdc_token_mint_pubkey, + ) + .await + .map(|(new_accounts, confirm_closed_accounts)| SolanaVaultSwapsVote { + new_accounts: new_accounts.into_iter().collect::>(), + confirm_closed_accounts: confirm_closed_accounts.into_iter().collect::>(), + }) + } +} + pub async fn start( scope: &Scope<'_, anyhow::Error>, client: SolRetryRpcClient, @@ -213,7 +255,8 @@ where SolanaIngressTrackingVoter { client: client.clone() }, SolanaNonceTrackingVoter { client: client.clone() }, SolanaEgressWitnessingVoter { client: client.clone() }, - SolanaLivenessVoter { client }, + SolanaLivenessVoter { client: client.clone() }, + SolanaVaultSwapsVoter { client }, )), ) .continuously_vote() diff --git a/engine/src/witness/sol/program_swaps_witnessing.rs b/engine/src/witness/sol/program_swaps_witnessing.rs new file mode 100644 index 0000000000..a41c4bee07 --- /dev/null +++ b/engine/src/witness/sol/program_swaps_witnessing.rs @@ -0,0 +1,584 @@ +use crate::{ + sol::{ + commitment_config::CommitmentConfig, + retry_rpc::{SolRetryRpcApi, SolRetryRpcClient}, + rpc_client_api::{RpcAccountInfoConfig, UiAccount, UiAccountData, UiAccountEncoding}, + }, + witness::common::cf_parameters::{ + CfParameters, VaultSwapParameters, VersionedCcmCfParameters, VersionedCfParameters, + }, +}; +use anyhow::{anyhow, bail, ensure, Context}; +use base64::Engine; +use cf_chains::{ + address::EncodedAddress, + assets::sol::Asset as SolAsset, + sol::{ + api::VaultSwapAccountAndSender, + sol_tx_core::program_instructions::{ + swap_endpoints::types::{SwapEndpointDataAccount, SwapEvent}, + ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH, + }, + SolAddress, + }, + CcmChannelMetadata, CcmDepositMetadata, ForeignChainAddress, +}; +use codec::Decode; +use futures::{stream, StreamExt, TryStreamExt}; +use itertools::Itertools; +use state_chain_runtime::chainflip::solana_elections::SolanaVaultSwapDetails; +use std::collections::{BTreeSet, HashSet}; +use tracing::warn; + +const MAXIMUM_CONCURRENT_RPCS: usize = 16; +// Querying less than 100 (rpc call max) as those event accounts can be quite big. +// Max length ~ 1300 bytes per account. We set it to 10 as an arbitrary number to +// avoid large queries. +const MAX_MULTIPLE_EVENT_ACCOUNTS_QUERY: usize = 10; + +// 1. Query the on-chain list of opened accounts from SwapEndpointDataAccount. +// 2. Check the returned accounts against the SC opened_accounts. The SC is the source of truth for +// the opened channels we can rely on that to not query the same accounts multiple times. +// 3. If they are already seen in the SC we do nothing with them and skip the query. +// 4. If an account is in the SC but not see in the engine we report it as closed. +// 5. If they are not seen in the SC we query the account data. Then we parse the account data and +// ensure it's a valid a program swap. The new program swap needs to be reported to the SC. + +pub async fn get_program_swaps( + sol_rpc: &SolRetryRpcClient, + swap_endpoint_data_account_address: SolAddress, + sc_open_accounts: HashSet, + sc_closure_initiated_accounts: BTreeSet, + usdc_token_mint_pubkey: SolAddress, +) -> Result< + ( + Vec<(VaultSwapAccountAndSender, Option)>, + Vec, + ), + anyhow::Error, +> { + let (new_program_swap_accounts, closed_accounts, slot) = get_changed_program_swap_accounts( + sol_rpc, + sc_open_accounts, + sc_closure_initiated_accounts, + swap_endpoint_data_account_address, + ) + .await?; + + if new_program_swap_accounts.is_empty() { + return Ok((vec![], closed_accounts)); + } + + let new_swaps = stream::iter(new_program_swap_accounts) + .chunks(MAX_MULTIPLE_EVENT_ACCOUNTS_QUERY) + .map(|new_program_swap_accounts_chunk| { + get_program_swap_event_accounts_data(sol_rpc, new_program_swap_accounts_chunk, slot) + }) + .buffered(MAXIMUM_CONCURRENT_RPCS) + .map_ok(stream::iter) + .try_flatten() + .filter_map(|response| { + futures::future::ready( + response + .inspect_err(|e| { + tracing::error!("Error querying for program swap account data: {e:?}"); + }) + .ok(), + ) + }) + .map( + |( + vault_swap_account, + SwapEvent { + creation_slot, + sender, + dst_chain, + dst_address, + dst_token, + amount, + src_token, + ccm_parameters, + cf_parameters, + }, + )| { + { + let vault_swap_details = move || { + let from_asset = + if let Some(token) = src_token { + if token == usdc_token_mint_pubkey.into() { + SolAsset::SolUsdc + } else { + bail!("Unsupported input token for the witnessed solana vault swap."); + } + } else { + SolAsset::Sol + }; + + let ( + deposit_metadata, + VaultSwapParameters { + refund_params, + dca_params, + boost_fee, + broker_fee, + affiliate_fees, + }, + ) = match ccm_parameters { + None => { + let VersionedCfParameters::V0(CfParameters { + ccm_additional_data: (), + vault_swap_parameters, + }) = VersionedCfParameters::decode(&mut &cf_parameters[..]) + .map_err(|e| { + anyhow!("Error while decoding VersionedCfParameters for solana vault swap: {}.", e) + })?; + (None, vault_swap_parameters) + }, + Some(ccm_parameters) => { + let VersionedCcmCfParameters::V0(CfParameters { + ccm_additional_data, + vault_swap_parameters + }) = VersionedCcmCfParameters::decode(&mut &cf_parameters[..]).map_err(|e| { + anyhow!("Error while decoding VersionedCcmCfParameters for solana vault swap: {}.", e) + }, + )?; + + ( + Some(CcmDepositMetadata { + source_chain: cf_primitives::ForeignChain::Solana, + source_address: Some(ForeignChainAddress::Sol( + sender.into(), + )), + channel_metadata: CcmChannelMetadata { + message: ccm_parameters + .message + .to_vec() + .try_into() + .map_err(|_| { + anyhow!( + "Failed to deposit CCM: `message` too long." + ) + })?, + gas_budget: ccm_parameters.gas_amount.into(), + ccm_additional_data, + }, + }), + vault_swap_parameters, + ) + }, + }; + Ok(SolanaVaultSwapDetails { + from: from_asset, + deposit_amount: amount, + destination_address: EncodedAddress::from_chain_bytes( + dst_chain.try_into().map_err(|e| { + anyhow!("Error while parsing destination chain for solana vault swap:{}.", e) + })?, + dst_address.to_vec(), + ) + .map_err(|e| { + anyhow!("Failed to decode the destination address for solana vault swap:{}.", e) + })?, + to: dst_token.try_into().map_err(|e| { + anyhow!("Error while decoding destination token for solana vault swap: {}.", e) + })?, + deposit_metadata, + swap_account: vault_swap_account, + creation_slot, + broker_fee, + affiliate_fees: affiliate_fees + .into_iter() + .map(|entry| cf_primitives::Beneficiary { account: entry.affiliate, bps: entry.fee.into() }) + .collect_vec() + .try_into() + .map_err(|_| { + anyhow!("runtime supports at least as many affiliates as we allow in cf_parameters encoding") + })?, + refund_params, + dca_params, + boost_fee, + }) + }; + ( + VaultSwapAccountAndSender { + vault_swap_account, + swap_sender: sender.into(), + }, + vault_swap_details() + .inspect_err(|e| { + warn!("Unable to derive swap details for account `{vault_swap_account}`: {e}") + }) + .ok(), + ) + } + }, + ) + .collect() + .await; + + Ok((new_swaps, closed_accounts)) +} + +async fn get_changed_program_swap_accounts( + sol_rpc: &SolRetryRpcClient, + sc_opened_accounts: HashSet, + sc_closure_initiated_accounts: BTreeSet, + swap_endpoint_data_account_address: SolAddress, +) -> Result<(Vec, Vec, u64), anyhow::Error> { + let (_historical_number_event_accounts, open_event_accounts, slot) = + get_swap_endpoint_data(sol_rpc, swap_endpoint_data_account_address).await?; + + let new_program_swap_accounts: Vec<_> = open_event_accounts + .iter() + .filter(|account| { + !sc_opened_accounts.contains(account) && + !sc_closure_initiated_accounts.iter().any(|x| &x.vault_swap_account == *account) + }) + .cloned() + .collect(); + let closed_accounts: Vec<_> = sc_closure_initiated_accounts + .into_iter() + .filter(|account| !open_event_accounts.contains(&account.vault_swap_account)) + .collect(); + + Ok((new_program_swap_accounts, closed_accounts, slot)) +} + +// Query the list of opened accounts from SwapEndpointDataAccount. The Swap Endpoint program ensures +// that the list is updated atomically whenever a swap event account is opened or closed. +async fn get_swap_endpoint_data( + sol_rpc: &SolRetryRpcClient, + swap_endpoint_data_account_address: SolAddress, +) -> Result<(u128, HashSet, u64), anyhow::Error> { + let accounts_info_response = sol_rpc + .get_multiple_accounts( + &[swap_endpoint_data_account_address], + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + data_slice: None, + commitment: Some(CommitmentConfig::finalized()), + min_context_slot: None, + }, + ) + .await; + + let slot = accounts_info_response.context.slot; + let accounts_info = accounts_info_response + .value + .into_iter() + .exactly_one() + .expect("We queried for exactly one account."); + + match accounts_info { + Some(UiAccount { data: UiAccountData::Binary(base64_string, encoding), .. }) => { + if encoding != UiAccountEncoding::Base64 { + return Err(anyhow!("Data account encoding is not base64")); + } + let bytes = base64::engine::general_purpose::STANDARD.decode(base64_string)?; + + // 8 Discriminator + 16 Historical Number Event Accounts + 4 bytes vector length + data + if bytes.len() < ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH + 20 { + return Err(anyhow!("Expected account to have at least 28 bytes")); + } + + let swap_endpoint_data_account = + SwapEndpointDataAccount::check_and_deserialize(&bytes[..]) + .map_err(|e| anyhow!("Failed to deserialize data: {:?}", e))?; + + Ok(( + swap_endpoint_data_account.historical_number_event_accounts, + swap_endpoint_data_account + .open_event_accounts + .into_iter() + .map(|acc| acc.into()) + .collect::>(), + slot, + )) + }, + Some(_) => + Err(anyhow!("Expected UiAccountData::Binary(String, UiAccountEncoding::Base64)")), + None => Err(anyhow!( + "Expected swap_endpoint_data_account_address to be found: {:?}", + swap_endpoint_data_account_address + )), + } +} + +async fn get_program_swap_event_accounts_data( + sol_rpc: &SolRetryRpcClient, + program_swap_event_accounts: Vec, + min_context_slot: u64, +) -> anyhow::Result>> { + let account_infos = sol_rpc + .get_multiple_accounts( + &program_swap_event_accounts[..], + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + data_slice: None, + commitment: Some(CommitmentConfig::finalized()), + min_context_slot: Some(min_context_slot), + }, + ) + .await + .value; + + ensure!( + account_infos.len() == program_swap_event_accounts.len(), + "Number of queried accounts should match number of returned accounts." + ); + + Ok(program_swap_event_accounts + .into_iter() + .zip(account_infos.into_iter()) + .map(|(account, account_info)| { + Ok(( + account, + match account_info { + Some(UiAccount { + data: UiAccountData::Binary(base64_string, UiAccountEncoding::Base64), + .. + }) => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(base64_string) + .map_err(|e| anyhow!("Failed to decode base64 string: {}", e))?; + + SwapEvent::check_and_deserialize(&bytes[..]) + .map_err(|e| anyhow!("Failed to deserialize data: {}", e)) + }, + Some(other) => Err(anyhow!( + "Expected UiAccountData::Binary(_, UiAccountEncoding::Base64), got {}", + match other.data { + UiAccountData::Binary(_, other) => + format!("UiAccountData::Binary(_, {:?})", other), + UiAccountData::Json(_) => "UiAccountData::Json(_)".to_string(), + UiAccountData::LegacyBinary(_) => + "UiAccountData::LegacyBinary(_)".to_string(), + } + )), + // It could happen that some account is closed between the queries. This + // should not happen because: + // 1. Accounts in `new_program_swap_accounts` can only be accounts that have + // newly been opened and they won't be closed until consensus is reached. + // 2. If due to rpc load management the get event accounts rpc is queried at a + // slot < get swap endpoint data rpc slot, the min_context_slot will prevent + // it from being executed before that. + // This could only happen if an engine is behind and were to see the account + // opened and closed between queries. That's not realistic as it takes + // minutes for an account to be closed and even if it were to happen + // it's not problematic as we'd have reached consensus and the engine + // would just filter it out. + None => Err(anyhow!("Account does not exist.")), + } + .context(format!("Error getting SwapEvent data for account `{account}`."))?, + )) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use crate::{ + settings::{HttpEndpoint, NodeContainer}, + sol::retry_rpc::SolRetryRpcClient, + }; + + use cf_chains::{Chain, Solana}; + use cf_utilities::task_scope; + use futures_util::FutureExt; + use std::str::FromStr; + + use super::*; + + #[tokio::test] + #[ignore] + async fn test_get_swap_endpoint_data() { + task_scope::task_scope(|scope| { + async { + let client = SolRetryRpcClient::new( + scope, + NodeContainer { + primary: HttpEndpoint { + http_endpoint: "https://api.devnet.solana.com".into(), + }, + backup: None, + }, + None, + Solana::WITNESS_PERIOD, + ) + .await + .unwrap(); + + let (historical_number_event_accounts, open_event_accounts, _) = + get_swap_endpoint_data( + &client, + // Swap Endpoint Data Account Address with no opened accounts + SolAddress::from_str("BckDu65u2ofAfaSDDEPg2qJTufKB4PvGxwcYhJ2wkBTC") + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(historical_number_event_accounts, 0_u128); + assert_eq!(open_event_accounts.len(), 0); + + let (historical_number_event_accounts, open_event_accounts, _) = + get_swap_endpoint_data( + &client, + // Swap Endpoint Data Account Address with two opened accounts + SolAddress::from_str("72HKrbbesW9FGuBoebns77uvY9fF9MEsw4HTMEeV53W9") + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(historical_number_event_accounts, 2_u128); + assert_eq!( + open_event_accounts, + vec![ + SolAddress::from_str("HhxGAt8THMtsW97Zuo5ZrhKgqsdD5EBgCx9vZ4n62xpf") + .unwrap(), + SolAddress::from_str("E81G7Q1BjierakQCfL9B5Tm485eiaRPb22bcKD2vtRfU") + .unwrap() + ] + .into_iter() + .collect() + ); + + Ok(()) + } + .boxed() + }) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore] + async fn test_get_changed_program_swap_accounts() { + task_scope::task_scope(|scope| { + async { + let client = SolRetryRpcClient::new( + scope, + NodeContainer { + primary: HttpEndpoint { + http_endpoint: "https://api.devnet.solana.com".into(), + }, + backup: None, + }, + None, + Solana::WITNESS_PERIOD, + ) + .await + .unwrap(); + + let (new_program_swap_accounts, closed_accounts, _) = + get_changed_program_swap_accounts( + &client, + Default::default(), + BTreeSet::from([VaultSwapAccountAndSender { + vault_swap_account: SolAddress::from_str( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ) + .unwrap(), + swap_sender: Default::default(), + }]), + // Swap Endpoint Data Account Address with no opened accounts + SolAddress::from_str("BckDu65u2ofAfaSDDEPg2qJTufKB4PvGxwcYhJ2wkBTC") + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(new_program_swap_accounts, vec![]); + assert_eq!( + closed_accounts, + vec![VaultSwapAccountAndSender { + vault_swap_account: SolAddress::from_str( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ) + .unwrap(), + swap_sender: Default::default(), + }] + ); + + let (new_program_swap_accounts, closed_accounts, _) = + get_changed_program_swap_accounts( + &client, + Default::default(), + BTreeSet::from([VaultSwapAccountAndSender { + vault_swap_account: SolAddress::from_str( + "HhxGAt8THMtsW97Zuo5ZrhKgqsdD5EBgCx9vZ4n62xpf", + ) + .unwrap(), + swap_sender: Default::default(), + }]), + // Swap Endpoint Data Account Address with two opened accounts + SolAddress::from_str("72HKrbbesW9FGuBoebns77uvY9fF9MEsw4HTMEeV53W9") + .unwrap(), + ) + .await + .unwrap(); + + println!("new_program_swap_accounts: {:?}", new_program_swap_accounts); + println!("closed_accounts: {:?}", closed_accounts); + + assert_eq!( + new_program_swap_accounts, + vec![SolAddress::from_str("E81G7Q1BjierakQCfL9B5Tm485eiaRPb22bcKD2vtRfU") + .unwrap()] + ); + assert_eq!(closed_accounts, vec![]); + + Ok(()) + } + .boxed() + }) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore] + async fn test_get_program_swap_event_accounts_data() { + task_scope::task_scope(|scope| { + async { + let client = SolRetryRpcClient::new( + scope, + NodeContainer { + primary: HttpEndpoint { http_endpoint: "http://127.0.0.1:8899".into() }, + backup: None, + }, + None, + Solana::WITNESS_PERIOD, + ) + .await + .unwrap(); + + let program_swap_event_accounts_data = get_program_swap_event_accounts_data( + &client, + vec![ + SolAddress::from_str("GNrA2Ztxv1tJF3G4NVPEQtbRb9uT8rXcEY6ddPfzpnnT") + .unwrap(), + SolAddress::from_str("8yeBhX5BB4L9MfDddhwzktdmzMeNUEcvgZGPWLD3HDDY") + .unwrap(), + SolAddress::from_str("Dd1k91cWt84qJoQr3FT7EXQpSaMtZtwPwdho7RbMWtEV") + .unwrap(), + ], + 123, + ) + .await + .unwrap(); + + println!( + "program_swap_event_accounts_data: {:?}", + program_swap_event_accounts_data + ); + + Ok(()) + } + .boxed() + }) + .await + .unwrap(); + } +} diff --git a/engine/src/witness/sol/sol_deposits.rs b/engine/src/witness/sol/sol_deposits.rs index 02de0b880c..3e16fdb0cb 100644 --- a/engine/src/witness/sol/sol_deposits.rs +++ b/engine/src/witness/sol/sol_deposits.rs @@ -1,7 +1,12 @@ use anyhow::ensure; use base64::Engine; use cf_chains::sol::{ - sol_tx_core::address_derivation::{derive_associated_token_account, derive_fetch_account}, + sol_tx_core::{ + address_derivation::{derive_associated_token_account, derive_fetch_account}, + program_instructions::{ + types::DepositChannelHistoricalFetch, ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH, + }, + }, SolAddress, SolAmount, }; use cf_primitives::chains::assets::sol::Asset; @@ -25,9 +30,8 @@ use crate::sol::{ pub use sol_prim::consts::{SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID}; // 16 (u128) + 8 (discriminator) -const FETCH_ACCOUNT_BYTE_LENGTH: usize = 24; +const FETCH_ACCOUNT_BYTE_LENGTH: usize = 16 + ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH; const MAX_MULTIPLE_ACCOUNTS_QUERY: usize = 100; -const FETCH_ACCOUNT_DISCRIMINATOR: [u8; 8] = [188, 68, 197, 38, 48, 192, 81, 100]; const MAXIMUM_CONCURRENT_RPCS: usize = 16; /// We track Solana (Sol and SPL-token) deposits by periodically querying the @@ -270,22 +274,17 @@ fn parse_fetch_account_amount( return Err(anyhow::anyhow!("Data account encoding is not base64")); } - let mut bytes = base64::engine::general_purpose::STANDARD + let bytes = base64::engine::general_purpose::STANDARD .decode(base64_string) .expect("Failed to decode base64 string"); ensure!(bytes.len() == FETCH_ACCOUNT_BYTE_LENGTH); - let discriminator: Vec = bytes.drain(..8).collect(); - - ensure!( - discriminator == FETCH_ACCOUNT_DISCRIMINATOR, - "Discriminator does not match expected value" - ); - - let array: [u8; 16] = bytes.try_into().expect("Byte slice length doesn't match u128"); + let deserialized_data: DepositChannelHistoricalFetch = + DepositChannelHistoricalFetch::check_and_deserialize(&bytes[..]) + .map_err(|e| anyhow::anyhow!("Failed to deserialize data: {:?}", e))?; - Ok(u128::from_le_bytes(array)) + Ok(deserialized_data.amount) }, _ => Err(anyhow::anyhow!("Data account encoding is not base64")), } diff --git a/foreign-chains/solana/sol-prim/src/consts.rs b/foreign-chains/solana/sol-prim/src/consts.rs index 9db587414e..a80c364606 100644 --- a/foreign-chains/solana/sol-prim/src/consts.rs +++ b/foreign-chains/solana/sol-prim/src/consts.rs @@ -43,3 +43,7 @@ pub const TOKEN_ACCOUNT_RENT: u64 = 2039280u64; pub const NONCE_ACCOUNT_LENGTH: u64 = 80u64; pub const SOL_USDC_DECIMAL: u8 = 6u8; + +pub const MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES: usize = 10; +pub const MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS: u32 = 14400; +pub const NONCE_AVAILABILITY_THRESHOLD_FOR_INITIATING_SWAP_ACCOUNT_CLOSURES: usize = 4; diff --git a/localnet/docker-compose.yml b/localnet/docker-compose.yml index e366e02621..ac650782c2 100644 --- a/localnet/docker-compose.yml +++ b/localnet/docker-compose.yml @@ -9,7 +9,7 @@ services: command: /bin/sh -c "cp -R /initial-state/* /localnet-initial-state" solana-init: - image: ghcr.io/chainflip-io/solana-localnet-ledger:v1.0.0 + image: ghcr.io/chainflip-io/solana-localnet-ledger:v1.0.0-swap-endpoint pull_policy: if_not_present container_name: init-solana platform: linux/amd64 diff --git a/state-chain/cf-integration-tests/src/mock_runtime.rs b/state-chain/cf-integration-tests/src/mock_runtime.rs index 56251afa19..82a3e1d365 100644 --- a/state-chain/cf-integration-tests/src/mock_runtime.rs +++ b/state-chain/cf-integration-tests/src/mock_runtime.rs @@ -17,7 +17,9 @@ use sp_consensus_grandpa::AuthorityId as GrandpaId; use sp_runtime::{FixedU128, Percent, Permill}; use state_chain_runtime::{ chainflip::{ - solana_elections::{SolanaFeeUnsynchronisedSettings, SolanaIngressSettings}, + solana_elections::{ + SolanaFeeUnsynchronisedSettings, SolanaIngressSettings, SolanaVaultSwapsSettings, + }, Offence, }, constants::common::*, @@ -307,6 +309,7 @@ impl ExtBuilder { (), (), (), + Default::default(), ), unsynchronised_settings: ( (), @@ -317,6 +320,7 @@ impl ExtBuilder { (), (), (), + (), ), settings: ( (), @@ -328,6 +332,11 @@ impl ExtBuilder { (), (), BLOCKS_BETWEEN_LIVENESS_CHECKS, + SolanaVaultSwapsSettings { + swap_endpoint_data_account_address: + sol_test_values::SWAP_ENDPOINT_DATA_ACCOUNT_ADDRESS, + usdc_token_mint_pubkey: sol_test_values::USDC_TOKEN_MINT_PUB_KEY, + }, ), }), }, diff --git a/state-chain/cf-integration-tests/src/solana.rs b/state-chain/cf-integration-tests/src/solana.rs index 6455055949..d04d3bf093 100644 --- a/state-chain/cf-integration-tests/src/solana.rs +++ b/state-chain/cf-integration-tests/src/solana.rs @@ -29,9 +29,9 @@ use frame_support::{ use pallet_cf_elections::{ electoral_systems::{ blockchain::delta_based_ingress::ChannelTotalIngressedFor, - composite::tuple_6_impls::CompositeElectionIdentifierExtra, + composite::tuple_7_impls::CompositeElectionIdentifierExtra, }, - vote_storage::{composite::tuple_6_impls::CompositeVote, AuthorityVote}, + vote_storage::{composite::tuple_7_impls::CompositeVote, AuthorityVote}, CompositeAuthorityVoteOf, CompositeElectionIdentifierOf, MAXIMUM_VOTES_PER_EXTRINSIC, }; use pallet_cf_ingress_egress::{DepositWitness, FetchOrTransfer}; diff --git a/state-chain/chains/src/benchmarking_value.rs b/state-chain/chains/src/benchmarking_value.rs index 9af451aaff..2a0e5ddb36 100644 --- a/state-chain/chains/src/benchmarking_value.rs +++ b/state-chain/chains/src/benchmarking_value.rs @@ -4,6 +4,10 @@ use cf_primitives::{ Asset, }; #[cfg(feature = "runtime-benchmarks")] +use cf_primitives::{ + AffiliateShortId, Beneficiary, DcaParameters, ForeignChain, ShortId, MAX_AFFILIATES, +}; +#[cfg(feature = "runtime-benchmarks")] use core::str::FromStr; #[cfg(feature = "runtime-benchmarks")] @@ -235,9 +239,92 @@ impl BenchmarkValue for Utxo { } } +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for U256 { + fn benchmark_value() -> Self { + Self([1u64; 4]) + } +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for DcaParameters { + fn benchmark_value() -> Self { + Self { + number_of_chunks: BenchmarkValue::benchmark_value(), + chunk_interval: BenchmarkValue::benchmark_value(), + } + } +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for Beneficiary { + fn benchmark_value() -> Self { + Self { account: BenchmarkValue::benchmark_value(), bps: BenchmarkValue::benchmark_value() } + } +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for Beneficiary { + fn benchmark_value() -> Self { + Self { + account: sp_runtime::AccountId32::new([1u8; 32]), + bps: BenchmarkValue::benchmark_value(), + } + } +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue + for sp_runtime::BoundedVec, sp_core::ConstU32<{ MAX_AFFILIATES + 1 }>> +{ + fn benchmark_value() -> Self { + sp_runtime::BoundedVec::try_from(vec![BenchmarkValue::benchmark_value()]).unwrap() + } +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for AffiliateShortId { + fn benchmark_value() -> Self { + cf_primitives::AffiliateShortId(BenchmarkValue::benchmark_value()) + } +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue + for frame_support::BoundedVec, sp_core::ConstU32> +{ + fn benchmark_value() -> Self { + sp_runtime::BoundedVec::try_from(vec![BenchmarkValue::benchmark_value()]).unwrap() + } +} + +#[macro_export] +macro_rules! impl_bounded_vec_benchmark_value { + ($element:ty, $n:literal) => { + #[cfg(feature = "runtime-benchmarks")] + impl BenchmarkValue for sp_runtime::BoundedVec<$element, sp_core::ConstU32<{ $n }>> { + fn benchmark_value() -> Self { + sp_runtime::BoundedVec::try_from(vec![BenchmarkValue::benchmark_value()]).unwrap() + } + } + }; +} + +impl_bounded_vec_benchmark_value!(u8, 10000); +impl_bounded_vec_benchmark_value!(u8, 1000); + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for ForeignChain { + fn benchmark_value() -> Self { + Self::Ethereum + } +} + impl_default_benchmark_value!(()); -impl_default_benchmark_value!(u32); impl_default_benchmark_value!(u64); +impl_default_benchmark_value!(u32); +impl_default_benchmark_value!(u16); +impl_default_benchmark_value!(u8); #[macro_export] macro_rules! impl_tuple_benchmark_value { @@ -258,3 +345,4 @@ impl_tuple_benchmark_value!(A, B, C); impl_tuple_benchmark_value!(A, B, C, D); impl_tuple_benchmark_value!(A, B, C, D, EE); impl_tuple_benchmark_value!(A, B, C, D, EE, F); +impl_tuple_benchmark_value!(A, B, C, D, EE, F, GG); diff --git a/state-chain/chains/src/lib.rs b/state-chain/chains/src/lib.rs index f721f14221..a063e1982b 100644 --- a/state-chain/chains/src/lib.rs +++ b/state-chain/chains/src/lib.rs @@ -7,6 +7,7 @@ use crate::{ sol::SolanaCrypto, }; use core::{fmt::Display, iter::Step}; +use sol::api::VaultSwapAccountAndSender; use sp_std::marker::PhantomData; use crate::{ @@ -495,6 +496,12 @@ pub trait RegisterRedemption: ApiCall<::ChainCrypto> { ) -> Self; } +pub trait CloseSolanaVaultSwapAccounts: ApiCall<::ChainCrypto> { + fn new_unsigned( + accounts: Vec, + ) -> Result; +} + #[derive(Debug, Encode, Decode, Clone, PartialEq, Eq, TypeInfo)] pub enum AllBatchError { /// Empty transaction - the call is not required. @@ -693,7 +700,18 @@ mod bounded_hex { /// Deposit channel Metadata for Cross-Chain-Message. #[derive( - Clone, Debug, PartialEq, Eq, Encode, Decode, TypeInfo, Serialize, Deserialize, MaxEncodedLen, + Clone, + Debug, + PartialEq, + Eq, + Encode, + Decode, + TypeInfo, + Serialize, + Deserialize, + MaxEncodedLen, + PartialOrd, + Ord, )] pub struct CcmChannelMetadata { /// Call data used after the message is egressed. @@ -710,6 +728,17 @@ pub struct CcmChannelMetadata { pub ccm_additional_data: CcmAdditionalData, } +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for CcmChannelMetadata { + fn benchmark_value() -> Self { + Self { + message: BenchmarkValue::benchmark_value(), + gas_budget: BenchmarkValue::benchmark_value(), + ccm_additional_data: BenchmarkValue::benchmark_value(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TypeInfo)] pub struct CcmSwapAmounts { pub principal_swap_amount: AssetAmount, @@ -725,13 +754,26 @@ pub enum CcmFailReason { InvalidMetadata, } -#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode, TypeInfo, Serialize, Deserialize)] +#[derive( + Clone, Debug, PartialEq, Eq, Encode, Decode, TypeInfo, Serialize, Deserialize, PartialOrd, Ord, +)] pub struct CcmDepositMetadataGeneric
{ pub channel_metadata: CcmChannelMetadata, pub source_chain: ForeignChain, pub source_address: Option
, } +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for CcmDepositMetadataGeneric
{ + fn benchmark_value() -> Self { + Self { + channel_metadata: BenchmarkValue::benchmark_value(), + source_chain: BenchmarkValue::benchmark_value(), + source_address: Some(BenchmarkValue::benchmark_value()), + } + } +} + impl
CcmDepositMetadataGeneric
{ pub fn into_swap_metadata( self, @@ -859,7 +901,18 @@ pub struct SwapRefundParameters { } #[derive( - Clone, Debug, PartialEq, Eq, Encode, Decode, TypeInfo, MaxEncodedLen, Serialize, Deserialize, + Clone, + Debug, + PartialEq, + Eq, + Encode, + Decode, + TypeInfo, + MaxEncodedLen, + Serialize, + Deserialize, + PartialOrd, + Ord, )] pub struct ChannelRefundParametersGeneric { pub retry_duration: cf_primitives::BlockNumber, @@ -867,6 +920,17 @@ pub struct ChannelRefundParametersGeneric { pub min_price: Price, } +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for ChannelRefundParametersGeneric { + fn benchmark_value() -> Self { + Self { + retry_duration: BenchmarkValue::benchmark_value(), + refund_address: BenchmarkValue::benchmark_value(), + min_price: BenchmarkValue::benchmark_value(), + } + } +} + pub type ChannelRefundParameters = ChannelRefundParametersGeneric; pub type ChannelRefundParametersEncoded = ChannelRefundParametersGeneric; diff --git a/state-chain/chains/src/sol.rs b/state-chain/chains/src/sol.rs index df2aa03eda..c68f7193a6 100644 --- a/state-chain/chains/src/sol.rs +++ b/state-chain/chains/src/sol.rs @@ -26,8 +26,10 @@ pub use crate::assets::sol::Asset as SolAsset; use crate::benchmarking_value::BenchmarkValue; pub use sol_prim::{ consts::{ - LAMPORTS_PER_SIGNATURE, MAX_TRANSACTION_LENGTH, MICROLAMPORTS_PER_LAMPORT, - TOKEN_ACCOUNT_RENT, + LAMPORTS_PER_SIGNATURE, MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES, + MAX_TRANSACTION_LENGTH, MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS, + MICROLAMPORTS_PER_LAMPORT, + NONCE_AVAILABILITY_THRESHOLD_FOR_INITIATING_SWAP_ACCOUNT_CLOSURES, TOKEN_ACCOUNT_RENT, }, pda::{Pda as DerivedAddressBuilder, PdaError as AddressDerivationError}, Address as SolAddress, Amount as SolAmount, ComputeLimit as SolComputeLimit, Digest as SolHash, @@ -156,7 +158,7 @@ pub mod compute_units_costs { pub const COMPUTE_UNITS_PER_ROTATION: SolComputeLimit = 8_000u32; pub const COMPUTE_UNITS_PER_SET_GOV_KEY: SolComputeLimit = 15_000u32; pub const COMPUTE_UNITS_PER_BUMP_DERIVATION: SolComputeLimit = 2_000u32; - pub const COMPUTE_UNITS_PER_CLOSE_EVENT_ACCOUNTS: SolComputeLimit = 10_000u32; + pub const COMPUTE_UNITS_PER_CLOSE_VAULT_SWAP_ACCOUNTS: SolComputeLimit = 10_000u32; pub const COMPUTE_UNITS_PER_CLOSE_ACCOUNT: SolComputeLimit = 10_000u32; /// This is equivalent to a priority fee diff --git a/state-chain/chains/src/sol/api.rs b/state-chain/chains/src/sol/api.rs index 543db442d8..25dcc6e742 100644 --- a/state-chain/chains/src/sol/api.rs +++ b/state-chain/chains/src/sol/api.rs @@ -4,6 +4,7 @@ use codec::{Decode, Encode}; use core::marker::PhantomData; use frame_support::{CloneNoBound, DebugNoBound, EqNoBound, PartialEqNoBound}; use scale_info::TypeInfo; +use serde::{Deserialize, Serialize}; use sol_prim::consts::SOL_USDC_DECIMAL; use sp_core::RuntimeDebug; use sp_std::{vec, vec::Vec}; @@ -18,9 +19,9 @@ use crate::{ SolAsset, SolHash, SolTransaction, SolanaCrypto, }, AllBatch, AllBatchError, ApiCall, CcmChannelMetadata, Chain, ChainCrypto, ChainEnvironment, - ConsolidateCall, ConsolidationError, ExecutexSwapAndCall, ExecutexSwapAndCallError, - FetchAssetParams, ForeignChainAddress, SetAggKeyWithAggKey, SetGovKeyWithAggKey, Solana, - TransferAssetParams, TransferFallback, TransferFallbackError, + CloseSolanaVaultSwapAccounts, ConsolidateCall, ConsolidationError, ExecutexSwapAndCall, + ExecutexSwapAndCallError, FetchAssetParams, ForeignChainAddress, SetAggKeyWithAggKey, + SetGovKeyWithAggKey, Solana, TransferAssetParams, TransferFallback, TransferFallbackError, }; use cf_primitives::{EgressId, ForeignChain}; @@ -37,7 +38,25 @@ pub struct ApiEnvironment; pub struct CurrentAggKey; pub type DurableNonceAndAccount = (SolAddress, SolHash); -pub type EventAccountAndSender = (SolAddress, SolAddress); + +#[derive( + Clone, + Encode, + Decode, + PartialEq, + Debug, + TypeInfo, + Copy, + Serialize, + Deserialize, + Ord, + PartialOrd, + Eq, +)] +pub struct VaultSwapAccountAndSender { + pub vault_swap_account: SolAddress, + pub swap_sender: SolAddress, +} /// Super trait combining all Environment lookups required for the Solana chain. /// Also contains some calls for easy data retrieval. @@ -385,8 +404,8 @@ impl SolanaApi { }) } - pub fn batch_close_event_accounts( - event_accounts: Vec, + pub fn batch_close_vault_swap_accounts( + vault_swap_accounts: Vec, ) -> Result { // Lookup environment variables, such as aggkey and durable nonce. let agg_key = Environment::current_agg_key()?; @@ -395,8 +414,8 @@ impl SolanaApi { let durable_nonce = Environment::nonce_account()?; // Build the transaction - let transaction = SolanaTransactionBuilder::close_event_accounts( - event_accounts, + let transaction = SolanaTransactionBuilder::close_vault_swap_accounts( + vault_swap_accounts, sol_api_environment.vault_program_data_account, sol_api_environment.swap_endpoint_program, sol_api_environment.swap_endpoint_program_data_account, @@ -522,6 +541,14 @@ impl TransferFallback for SolanaApi { } } +impl CloseSolanaVaultSwapAccounts for SolanaApi { + fn new_unsigned( + accounts: Vec, + ) -> Result { + Self::batch_close_vault_swap_accounts(accounts) + } +} + impl SetGovKeyWithAggKey for SolanaApi { fn new_unsigned( _maybe_old_key: Option<::GovKey>, diff --git a/state-chain/chains/src/sol/benchmarking.rs b/state-chain/chains/src/sol/benchmarking.rs index 1eb8b75659..71bdd3818f 100644 --- a/state-chain/chains/src/sol/benchmarking.rs +++ b/state-chain/chains/src/sol/benchmarking.rs @@ -1,7 +1,8 @@ #![cfg(feature = "runtime-benchmarks")] use super::{ - api::SolanaApi, SolAddress, SolHash, SolMessage, SolSignature, SolTrackedData, SolTransaction, + api::{SolanaApi, VaultSwapAccountAndSender}, + SolAddress, SolHash, SolMessage, SolSignature, SolTrackedData, SolTransaction, SolanaTransactionData, }; @@ -25,7 +26,6 @@ impl BenchmarkValue for SolTrackedData { } } -#[cfg(feature = "runtime-benchmarks")] impl BenchmarkValue for SolMessage { fn benchmark_value() -> Self { Self::new_with_blockhash(&[], None, &SolHash::default().into()) @@ -66,3 +66,12 @@ impl BenchmarkValue for SolanaApi { .expect("Benchmark value for SolApi must work.") } } + +impl BenchmarkValue for VaultSwapAccountAndSender { + fn benchmark_value() -> Self { + Self { + swap_sender: BenchmarkValue::benchmark_value(), + vault_swap_account: BenchmarkValue::benchmark_value(), + } + } +} diff --git a/state-chain/chains/src/sol/sol_tx_core.rs b/state-chain/chains/src/sol/sol_tx_core.rs index 2cb86ed8c5..830a7c4399 100644 --- a/state-chain/chains/src/sol/sol_tx_core.rs +++ b/state-chain/chains/src/sol/sol_tx_core.rs @@ -658,6 +658,7 @@ pub struct CompiledInstruction { PartialOrd, Copy, BorshSerialize, + BorshDeserialize, )] pub struct Pubkey(pub [u8; 32]); @@ -874,8 +875,9 @@ impl FromStr for Hash { pub mod sol_test_values { use crate::{ sol::{ - signing_key::SolSigningKey, sol_tx_core::signer::Signer, SolAddress, SolAmount, - SolAsset, SolCcmAccounts, SolCcmAddress, SolComputeLimit, SolHash, + api::VaultSwapAccountAndSender, signing_key::SolSigningKey, + sol_tx_core::signer::Signer, SolAddress, SolAmount, SolAsset, SolCcmAccounts, + SolCcmAddress, SolComputeLimit, SolHash, }, CcmChannelMetadata, CcmDepositMetadata, ForeignChain, ForeignChainAddress, }; @@ -897,6 +899,8 @@ pub mod sol_test_values { // stored There will be a different one per each supported spl-token pub const USDC_TOKEN_VAULT_ASSOCIATED_TOKEN_ACCOUNT: SolAddress = const_address("GgqCE4bTwMy4QWVaTRTKJqETAgim49zNrH1dL6zXaTpd"); + pub const SWAP_ENDPOINT_DATA_ACCOUNT_ADDRESS: SolAddress = + const_address("GgqCE4bTwMy4QWVaTRTKJqETAgim49zNrH1dL6zXaTpd"); pub const NONCE_ACCOUNTS: [SolAddress; 10] = [ const_address("2cNMwUCF51djw2xAiiU54wz1WrU8uG4Q8Kp8nfEuwghw"), const_address("HVG21SovGzMBJDB9AQNuWb6XYq4dDZ6yUwCbRUuFnYDo"), @@ -913,51 +917,51 @@ pub mod sol_test_values { const_address("35uYgHdfZQT4kHkaaXQ6ZdCkK5LFrsk43btTLbGCRCNT"); pub const SWAP_ENDPOINT_PROGRAM_DATA_ACCOUNT: SolAddress = const_address("2tmtGLQcBd11BMiE9B1tAkQXwmPNgR79Meki2Eme4Ec9"); - pub const EVENT_AND_SENDER_ACCOUNTS: [(SolAddress, SolAddress); 11] = [ - ( - const_address("2cHcSNtikMpjxJfwwoYL3udpy7hedRExyhakk2eZ6cYA"), - const_address("7tVhSXxGfZyHQem8MdZVB6SoRsrvV4H8h1rX6hwBuvEA"), - ), - ( - const_address("6uuU1NFyThN3KJpU9mYXkGSmd8Qgncmd9aYAWYN71VkC"), - const_address("P3GYr1Z67jdBVimzFjMXQpeuew5TY5txoZ9CvqASpaP"), - ), - ( - const_address("DmAom3kp2ZKk9cnbWEsnbkLHkp3sx9ef1EX6GWj1JRUB"), - const_address("CS7yX5TKX36ugF4bycmVQ5vqB2ZbNVC5tvtrtLP92GDW"), - ), - ( - const_address("CJSdHgxwHLEbTsxKsJk9UyJxUEgku2UC9GXRTzR2ieSh"), - const_address("2taCR53epDtdrFZBxzKcbmv3cb5Umc5x9k2YCjmTDAnH"), - ), - ( - const_address("7DGwjsQEFA7XzZS9z5YbMhYGzWJSh5T78hRrU47RDTd2"), - const_address("FDPzoZj951Hq92jhoFdyzAVyUjyXhL8VEnqBhyjsDhow"), - ), - ( - const_address("A6yYXUmZHa32mcFRnwwq8ZQKCEYUn9ewF1vWn2wsXN5a"), - const_address("9bNNNU9B52VPVGm6zRccwPEexDHD1ntndD2aNu2un3ca"), - ), - ( - const_address("2F3365PULNzt7moa9GgHARy7Lumj5ptDQF7wDt6xeuHK"), - const_address("4m5t38fJsvULKaPyWZKWjzfbvnzBGL86BTRNk5vLLUrh"), - ), - ( - const_address("8sCBWv9tzdf2iC4GNj61UBN6TZpzsLP5Ppv9x1ENX4HT"), - const_address("A3P5kfRU1vgZn7GjNMomS8ye6GHsoHC4JoVNUotMbDPE"), - ), - ( - const_address("3b1FkNvnvKJ4TzKeft7wA47VfYpjkoHPE4ER13ZTNecX"), - const_address("ERwuPnX66dCZqj85kH9QQJmwcVrzcczBnu8onJY2R7tG"), - ), - ( - const_address("Bnrp9X562krXVfaY8FnwJa3Mxp1gbDCrvGNW1qc99rKe"), - const_address("2aoZg41FFnTBnuHpkfHdFsCuPz8DhN4dsUW5386XwE8g"), - ), - ( - const_address("EuLceVgXMaJNPT7C88pnL7DRWcf1poy9BCeWY1GL8Agd"), - const_address("G1iXMtwUU76JGau9cJm6N8wBTmcsvyXuJcC7PtfU1TXZ"), - ), + pub const EVENT_AND_SENDER_ACCOUNTS: [VaultSwapAccountAndSender; 11] = [ + VaultSwapAccountAndSender { + vault_swap_account: const_address("2cHcSNtikMpjxJfwwoYL3udpy7hedRExyhakk2eZ6cYA"), + swap_sender: const_address("7tVhSXxGfZyHQem8MdZVB6SoRsrvV4H8h1rX6hwBuvEA"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("6uuU1NFyThN3KJpU9mYXkGSmd8Qgncmd9aYAWYN71VkC"), + swap_sender: const_address("P3GYr1Z67jdBVimzFjMXQpeuew5TY5txoZ9CvqASpaP"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("DmAom3kp2ZKk9cnbWEsnbkLHkp3sx9ef1EX6GWj1JRUB"), + swap_sender: const_address("CS7yX5TKX36ugF4bycmVQ5vqB2ZbNVC5tvtrtLP92GDW"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("CJSdHgxwHLEbTsxKsJk9UyJxUEgku2UC9GXRTzR2ieSh"), + swap_sender: const_address("2taCR53epDtdrFZBxzKcbmv3cb5Umc5x9k2YCjmTDAnH"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("7DGwjsQEFA7XzZS9z5YbMhYGzWJSh5T78hRrU47RDTd2"), + swap_sender: const_address("FDPzoZj951Hq92jhoFdyzAVyUjyXhL8VEnqBhyjsDhow"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("A6yYXUmZHa32mcFRnwwq8ZQKCEYUn9ewF1vWn2wsXN5a"), + swap_sender: const_address("9bNNNU9B52VPVGm6zRccwPEexDHD1ntndD2aNu2un3ca"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("2F3365PULNzt7moa9GgHARy7Lumj5ptDQF7wDt6xeuHK"), + swap_sender: const_address("4m5t38fJsvULKaPyWZKWjzfbvnzBGL86BTRNk5vLLUrh"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("8sCBWv9tzdf2iC4GNj61UBN6TZpzsLP5Ppv9x1ENX4HT"), + swap_sender: const_address("A3P5kfRU1vgZn7GjNMomS8ye6GHsoHC4JoVNUotMbDPE"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("3b1FkNvnvKJ4TzKeft7wA47VfYpjkoHPE4ER13ZTNecX"), + swap_sender: const_address("ERwuPnX66dCZqj85kH9QQJmwcVrzcczBnu8onJY2R7tG"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("Bnrp9X562krXVfaY8FnwJa3Mxp1gbDCrvGNW1qc99rKe"), + swap_sender: const_address("2aoZg41FFnTBnuHpkfHdFsCuPz8DhN4dsUW5386XwE8g"), + }, + VaultSwapAccountAndSender { + vault_swap_account: const_address("EuLceVgXMaJNPT7C88pnL7DRWcf1poy9BCeWY1GL8Agd"), + swap_sender: const_address("G1iXMtwUU76JGau9cJm6N8wBTmcsvyXuJcC7PtfU1TXZ"), + }, ]; pub const RAW_KEYPAIR: [u8; 32] = [ 6, 151, 150, 20, 145, 210, 176, 113, 98, 200, 192, 80, 73, 63, 133, 232, 208, 124, 81, 213, diff --git a/state-chain/chains/src/sol/sol_tx_core/program_instructions.rs b/state-chain/chains/src/sol/sol_tx_core/program_instructions.rs index dccf261237..ab1324d1eb 100644 --- a/state-chain/chains/src/sol/sol_tx_core/program_instructions.rs +++ b/state-chain/chains/src/sol/sol_tx_core/program_instructions.rs @@ -1,6 +1,6 @@ use super::{AccountMeta, Instruction, Pubkey}; -use borsh::BorshSerialize; +use borsh::{BorshDeserialize, BorshSerialize}; use cf_utilities::SliceToArray; use core::str::FromStr; use scale_info::prelude::string::String; @@ -284,6 +284,30 @@ macro_rules! solana_program { } ),+ $(,)? } + $(, + types: [ + $( + $type_name:ident { + $( + $type_arg:ident: $type_arg_type:ty + ),+ + $(,)? + } + ),+ + $(,)? + ] + )? + $(, + accounts: [ + $( + { + $account_type:ident, + discriminator: $discriminator:expr $(,)? + } + ),+ + $(,)? + ] + )? ) => { pub struct $program { program_id: Pubkey, @@ -334,6 +358,46 @@ macro_rules! solana_program { } )+ + $( + pub mod types { + use super::*; + + $( + #[derive(BorshDeserialize, BorshSerialize, Debug, Default, Clone, PartialEq, Eq)] + pub struct $type_name { + $( + pub $type_arg: $type_arg_type, + )+ + } + )+ + } + )? + + $( + pub mod accounts { + use super::*; + $( + impl super::types::$account_type { + pub const fn discriminator() -> [u8; 8] { + $discriminator + } + + pub fn check_and_deserialize(bytes: &[u8]) -> borsh::io::Result { + use borsh::io::{ErrorKind, Error}; + if bytes.len() < ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH { + return Err(Error::new(ErrorKind::Other, "No account discriminator")); + } + let (discriminator, rest) = bytes.split_at(ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH); + if discriminator != Self::discriminator() { + return Err(Error::new(ErrorKind::Other, "Unexpected account discriminator")); + } + Self::try_from_slice(rest) + } + } + )+ + } + )? + #[cfg(test)] mod test { use super::*; @@ -365,6 +429,54 @@ macro_rules! solana_program { }); } + + $( + #[test] + fn types_exist_in_idl() { + use std::collections::BTreeMap; + test(|idl| { + $( + let ty = idl.types.iter().find(|ty| ty.name == stringify!($type_name)).expect("Type not found in IDL").ty.clone(); + assert!(ty.kind == "struct", "Non-struct IDL types not supported."); + let fields = ty.fields.into_iter().map(|field| (field.name, field.ty)).collect::>(); + $( + assert_eq!( + fields.get(stringify!($type_arg)).map(|f| f.to_string()), + Some(stringify!($type_arg_type).to_owned()), + "Field {} of type {} not found in IDL", + stringify!($type_arg), + stringify!($type_arg_type), + ); + )+ + )+ + }); + } + )? + $( + #[test] + fn accounts_exist_in_idl() { + test(|idl| { + let defined_in_idl = idl.accounts.iter().map(|acc| acc.name.clone()).collect::>(); + let defined_in_code = [ + $( + stringify!($account_type).to_owned(), + )+ + ].into_iter().collect::>(); + assert!( + defined_in_code.is_subset(&defined_in_idl), + "Some accounts are not defined in the IDL: {:?}", + defined_in_code.difference(&defined_in_idl).cloned().collect::>() + ); + $( + assert_eq!( + types::$account_type::discriminator(), + idl.accounts.iter().find(|acc| acc.name == stringify!($account_type)).unwrap().discriminator + ); + )+ + }); + } + )? + $( #[cfg(test)] mod $call_name { @@ -626,9 +738,25 @@ solana_program!( bpf_loader_upgradeable: { signer: false, writable: false }, ] }, - } + }, + types: [ + DepositChannelHistoricalFetch { + amount: u128, + } + ], + accounts: [ + { + DepositChannelHistoricalFetch, + discriminator: [188, 68, 197, 38, 48, 192, 81, 100], + }, + ] ); +pub const FETCH_ACCOUNT_DISCRIMINATOR: [u8; ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH] = + types::DepositChannelHistoricalFetch::discriminator(); + +pub const ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH: usize = 8; + pub mod swap_endpoints { use super::*; @@ -646,9 +774,45 @@ pub mod swap_endpoints { agg_key: { signer: true, writable: true }, swap_endpoint_data_account: { signer: false, writable: true }, ] + } + }, + types: [ + CcmParams { + message: Vec, + gas_amount: u64, }, - } + SwapEvent { + creation_slot: u64, + sender: Pubkey, + dst_chain: u32, + dst_address: Vec, + dst_token: u32, + amount: u64, + src_token: Option, + ccm_parameters: Option, + cf_parameters: Vec, + }, + SwapEndpointDataAccount { + historical_number_event_accounts: u128, + open_event_accounts: Vec, + }, + ], + accounts: [ + { + SwapEvent, + discriminator: [150, 251, 114, 94, 200, 113, 248, 70], + }, + { + SwapEndpointDataAccount, + discriminator: [79, 152, 191, 225, 128, 108, 11, 139], + }, + ] ); + + pub const SWAP_EVENT_ACCOUNT_DISCRIMINATOR: [u8; ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH] = + types::SwapEvent::discriminator(); + pub const SWAP_ENDPOINT_DATA_ACCOUNT_DISCRIMINATOR: [u8; ANCHOR_PROGRAM_DISCRIMINATOR_LENGTH] = + types::SwapEndpointDataAccount::discriminator(); } #[cfg(test)] @@ -668,35 +832,39 @@ mod idl { pub struct IdlArg { pub name: String, #[serde(rename = "type")] - pub ty: IdlType, + pub ty: IdlFieldType, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] - pub enum IdlType { + pub enum IdlFieldType { Bytes, U8, U16, U64, U32, + U128, Bool, Pubkey, Defined { name: String }, - Option(Box), + Option(Box), + Vec(Box), } - impl std::fmt::Display for IdlType { + impl std::fmt::Display for IdlFieldType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - IdlType::Bytes => write!(f, "Vec"), - IdlType::U8 => write!(f, "u8"), - IdlType::U16 => write!(f, "u16"), - IdlType::U64 => write!(f, "u64"), - IdlType::U32 => write!(f, "u32"), - IdlType::Bool => write!(f, "bool"), - IdlType::Pubkey => write!(f, "Pubkey"), - IdlType::Defined { name } => write!(f, "{}", name), - IdlType::Option(ty) => write!(f, "Option<{}>", ty), + IdlFieldType::Bytes => write!(f, "Vec"), + IdlFieldType::U8 => write!(f, "u8"), + IdlFieldType::U16 => write!(f, "u16"), + IdlFieldType::U64 => write!(f, "u64"), + IdlFieldType::U32 => write!(f, "u32"), + IdlFieldType::U128 => write!(f, "u128"), + IdlFieldType::Bool => write!(f, "bool"), + IdlFieldType::Pubkey => write!(f, "Pubkey"), + IdlFieldType::Defined { name } => write!(f, "{}", name), + IdlFieldType::Option(ty) => write!(f, "Option<{}>", ty), + IdlFieldType::Vec(ty) => write!(f, "Vec<{}>", ty), } } } @@ -727,12 +895,36 @@ mod idl { pub description: String, } + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct IdlAccount { + pub name: String, + pub discriminator: [u8; 8], + } + + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct IdlType { + pub kind: String, + pub fields: Vec, + } + + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct IdlTypes { + pub name: String, + #[serde(rename = "type")] + pub ty: IdlType, + } + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct Idl { pub address: String, pub metadata: IdlMetadata, pub instructions: Vec, pub errors: Vec, + pub accounts: Vec, + pub types: Vec, } impl Idl { @@ -742,5 +934,13 @@ mod idl { .find(|instr| instr.name == name) .expect("instruction not found") } + + #[allow(dead_code)] + pub fn account(&self, name: &str) -> &IdlAccount { + self.accounts + .iter() + .find(|account| account.name == name) + .expect("account not found") + } } } diff --git a/state-chain/chains/src/sol/transaction_builder.rs b/state-chain/chains/src/sol/transaction_builder.rs index 1bc32d9adc..380c861cb0 100644 --- a/state-chain/chains/src/sol/transaction_builder.rs +++ b/state-chain/chains/src/sol/transaction_builder.rs @@ -11,11 +11,11 @@ use sol_prim::consts::{ use crate::{ sol::{ - api::{DurableNonceAndAccount, EventAccountAndSender, SolanaTransactionBuildingError}, + api::{DurableNonceAndAccount, SolanaTransactionBuildingError, VaultSwapAccountAndSender}, compute_units_costs::{ compute_limit_with_buffer, BASE_COMPUTE_UNITS_PER_TX, COMPUTE_UNITS_PER_BUMP_DERIVATION, COMPUTE_UNITS_PER_CLOSE_ACCOUNT, - COMPUTE_UNITS_PER_CLOSE_EVENT_ACCOUNTS, COMPUTE_UNITS_PER_FETCH_NATIVE, + COMPUTE_UNITS_PER_CLOSE_VAULT_SWAP_ACCOUNTS, COMPUTE_UNITS_PER_FETCH_NATIVE, COMPUTE_UNITS_PER_FETCH_TOKEN, COMPUTE_UNITS_PER_ROTATION, COMPUTE_UNITS_PER_SET_GOV_KEY, COMPUTE_UNITS_PER_TRANSFER_NATIVE, COMPUTE_UNITS_PER_TRANSFER_TOKEN, @@ -445,8 +445,8 @@ impl SolanaTransactionBuilder { /// Creates an instruction to close a number of open event swap accounts created via program /// swap. - pub fn close_event_accounts( - event_accounts: Vec, + pub fn close_vault_swap_accounts( + vault_swap_accounts: Vec, vault_program_data_account: SolAddress, swap_endpoint_program: SolAddress, swap_endpoint_data_account: SolAddress, @@ -454,21 +454,21 @@ impl SolanaTransactionBuilder { durable_nonce: DurableNonceAndAccount, compute_price: SolAmount, ) -> Result { - let number_of_accounts = event_accounts.len(); - let event_and_sender_vec: Vec = event_accounts + let number_of_accounts = vault_swap_accounts.len(); + let swap_and_sender_vec: Vec = vault_swap_accounts .into_iter() // Both event account and payee should be writable and non-signers - .flat_map(|(event_account, payee)| { + .flat_map(|VaultSwapAccountAndSender { vault_swap_account, swap_sender }| { vec![ - AccountMeta::new(event_account.into(), false), - AccountMeta::new(payee.into(), false), + AccountMeta::new(vault_swap_account.into(), false), + AccountMeta::new(swap_sender.into(), false), ] }) .collect(); let instructions = vec![SwapEndpointProgram::with_id(swap_endpoint_program) .close_event_accounts(vault_program_data_account, agg_key, swap_endpoint_data_account) - .with_remaining_accounts(event_and_sender_vec)]; + .with_remaining_accounts(swap_and_sender_vec)]; Self::build( instructions, @@ -476,7 +476,7 @@ impl SolanaTransactionBuilder { agg_key.into(), compute_price, compute_limit_with_buffer( - COMPUTE_UNITS_PER_CLOSE_EVENT_ACCOUNTS + + COMPUTE_UNITS_PER_CLOSE_VAULT_SWAP_ACCOUNTS + COMPUTE_UNITS_PER_CLOSE_ACCOUNT * number_of_accounts as u32, ), ) @@ -725,11 +725,11 @@ mod test { } #[test] - fn can_close_event_accounts() { + fn can_close_vault_swap_accounts() { let env = api_env(); - let event_accounts = vec![EVENT_AND_SENDER_ACCOUNTS[0]]; - let transaction = SolanaTransactionBuilder::close_event_accounts( - event_accounts, + let vault_swap_accounts = vec![EVENT_AND_SENDER_ACCOUNTS[0]]; + let transaction = SolanaTransactionBuilder::close_vault_swap_accounts( + vault_swap_accounts, env.vault_program_data_account, env.swap_endpoint_program, env.swap_endpoint_program_data_account, @@ -739,18 +739,18 @@ mod test { ) .unwrap(); - // Serialized tx built in `close_event_accounts` test + // Serialized tx built in `close_vault_swap_accounts` test let expected_serialized_tx = hex_literal::hex!("01026e2d4bdca9e638b59507a70ea62ad88f098ffb25df028a19288702698fdf6d1cf77618b2123c0205a8e8d272ba8ea645b7e75c606ca3aa4356b65fa52ca20b0100050af79d5e026f12edc6443a534b2cdd5072233989b415d7596573e743f3e5b386fb17e5cc1f4d51a40626e11c783b75a45a4922615ecd7f5320b9d4d46481a196a317eb2b10d3377bda2bc7bea65bec6b8372f4fc3463ec2cd6f9fde4b2c633d1921c1f0efc91eeb48bb80c90cf97775cd5d843a96f16500266cee2c20d053152d2665730decf59d4cd6db8437dab77302287431eb7562b5997601851a0eab6946f00000000000000000000000000000000000000000000000000000000000000000306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a4000000006a7d517192c568ee08a845f73d29788cf035c3145b21ab344d8062ea94000000e14940a2247d0a8a33650d7dfe12d269ecabce61c1219b5a6dcdb6961026e091ef91c791d2aa8492c90f12540abd10056ce5dd8d9ab08461476c1dcc1622938c27e9074fac5e8d36cf04f94a0606fdd8ddbb420e99a489c7915ce5699e4890004050302070004040000000600090340420f000000000006000502307500000905080003010408a5663d01b94dbd79").to_vec(); test_constructed_transaction(transaction, expected_serialized_tx); } #[test] - fn can_close_max_event_accounts() { + fn can_close_max_vault_swap_accounts() { let env = api_env(); // We can close 11 accounts without reaching the transaction length limit. - let transaction = SolanaTransactionBuilder::close_event_accounts( + let transaction = SolanaTransactionBuilder::close_vault_swap_accounts( EVENT_AND_SENDER_ACCOUNTS.to_vec(), env.vault_program_data_account, env.swap_endpoint_program, diff --git a/state-chain/node/src/chain_spec.rs b/state-chain/node/src/chain_spec.rs index c3b2ec6c9e..a9801dc356 100644 --- a/state-chain/node/src/chain_spec.rs +++ b/state-chain/node/src/chain_spec.rs @@ -374,6 +374,7 @@ pub fn inner_cf_development_config( 100_000, sol_vault_program, sol_usdc_token_mint_pubkey, + sol_swap_endpoint_program_data_account, )), }, )) @@ -535,6 +536,7 @@ macro_rules! network_spec { 100000, sol_vault_program, sol_usdc_token_mint_pubkey, + sol_swap_endpoint_program_data_account, )), }, )) diff --git a/state-chain/node/src/chain_spec/berghain.rs b/state-chain/node/src/chain_spec/berghain.rs index 567c4518b5..471c287a9d 100644 --- a/state-chain/node/src/chain_spec/berghain.rs +++ b/state-chain/node/src/chain_spec/berghain.rs @@ -159,4 +159,4 @@ pub const AUCTION_PARAMETERS: SetSizeParameters = pub const BITCOIN_SAFETY_MARGIN: u64 = 2; pub const ETHEREUM_SAFETY_MARGIN: u64 = 6; pub const ARBITRUM_SAFETY_MARGIN: u64 = 1; -pub const SOLANA_SAFETY_MARGIN: u64 = 1; //TODO: put correct value +pub const SOLANA_SAFETY_MARGIN: u64 = 1; // Unused - we use "finalized" instead diff --git a/state-chain/node/src/chain_spec/devnet.rs b/state-chain/node/src/chain_spec/devnet.rs index 78caab48db..ed57cf86d8 100644 --- a/state-chain/node/src/chain_spec/devnet.rs +++ b/state-chain/node/src/chain_spec/devnet.rs @@ -21,4 +21,4 @@ pub const AUCTION_PARAMETERS: SetSizeParameters = SetSizeParameters { pub const BITCOIN_SAFETY_MARGIN: u64 = 2; pub const ETHEREUM_SAFETY_MARGIN: u64 = 2; pub const ARBITRUM_SAFETY_MARGIN: u64 = 1; -pub const SOLANA_SAFETY_MARGIN: u64 = 1; //TODO: put correct value +pub const SOLANA_SAFETY_MARGIN: u64 = 1; // Unused - we use "finalized" instead diff --git a/state-chain/node/src/chain_spec/perseverance.rs b/state-chain/node/src/chain_spec/perseverance.rs index 4d08adc614..cfe40972c8 100644 --- a/state-chain/node/src/chain_spec/perseverance.rs +++ b/state-chain/node/src/chain_spec/perseverance.rs @@ -158,4 +158,4 @@ pub fn extra_accounts() -> Vec<(AccountId, AccountRole, FlipBalance, Option Vec<(AccountId, AccountRole, FlipBalance, Option { // If the validator hasn't voted, they will get a None. pub vote: Option<(VotePropertiesOf, ::Vote)>, diff --git a/state-chain/pallets/cf-elections/src/electoral_systems.rs b/state-chain/pallets/cf-elections/src/electoral_systems.rs index bede530e3d..c7a51f6fc8 100644 --- a/state-chain/pallets/cf-elections/src/electoral_systems.rs +++ b/state-chain/pallets/cf-elections/src/electoral_systems.rs @@ -6,6 +6,7 @@ pub mod liveness; pub mod mock; pub mod monotonic_change; pub mod monotonic_median; +pub mod solana_vault_swap_accounts; pub mod unsafe_median; #[cfg(test)] diff --git a/state-chain/pallets/cf-elections/src/electoral_systems/composite.rs b/state-chain/pallets/cf-elections/src/electoral_systems/composite.rs index 8d7d3fdc7f..1c0f49e18f 100644 --- a/state-chain/pallets/cf-elections/src/electoral_systems/composite.rs +++ b/state-chain/pallets/cf-elections/src/electoral_systems/composite.rs @@ -13,6 +13,7 @@ pub mod tags { pub struct D; pub struct EE; pub struct FF; + pub struct GG; } macro_rules! generate_electoral_system_tuple_impls { @@ -458,4 +459,4 @@ macro_rules! generate_electoral_system_tuple_impls { }; } -generate_electoral_system_tuple_impls!(tuple_6_impls: ((A, A0), (B, B0), (C, C0), (D, D0), (EE, E0), (FF, F0))); +generate_electoral_system_tuple_impls!(tuple_7_impls: ((A, A0), (B, B0), (C, C0), (D, D0), (EE, E0), (FF, F0), (GG, G0))); diff --git a/state-chain/pallets/cf-elections/src/electoral_systems/mocks.rs b/state-chain/pallets/cf-elections/src/electoral_systems/mocks.rs index 2dcb58fc96..5bb2e35289 100644 --- a/state-chain/pallets/cf-elections/src/electoral_systems/mocks.rs +++ b/state-chain/pallets/cf-elections/src/electoral_systems/mocks.rs @@ -117,13 +117,17 @@ where // We may want to test initialisation of elections within on finalise, so *don't* want to // initialise an election in the utilities. pub fn build(self) -> TestContext { + let setup = self.clone(); + // We need to clear the storage at every build so if there are multiple test contexts used // within a single test they do not conflict. MockStorageAccess::clear_storage(); - MockStorageAccess::set_electoral_settings::(self.electoral_settings.clone()); + MockStorageAccess::set_electoral_settings::(setup.electoral_settings.clone()); + MockStorageAccess::set_unsynchronised_state::(setup.unsynchronised_state.clone()); + MockStorageAccess::set_unsynchronised_settings::(setup.unsynchronised_settings.clone()); - TestContext { setup: self.clone() } + TestContext { setup } } } @@ -136,8 +140,6 @@ impl TestContext { mut consensus_votes: ConsensusVotes, expected_consensus: Option, ) -> Self { - assert!(consensus_votes.num_authorities() > 0, "Cannot have zero authorities."); - use rand::seq::SliceRandom; consensus_votes.votes.shuffle(&mut rand::thread_rng()); @@ -189,6 +191,17 @@ impl TestContext { self } + pub fn expect_election_properties_only_election( + self, + expected_properties: ES::ElectionProperties, + ) -> Self { + assert_eq!( + MockStorageAccess::election_properties::(self.only_election_id()), + expected_properties + ); + self + } + /// Test the finalization of the election. /// /// `pre_finalize_checks` is a closure that is called with a read-only access to the electoral diff --git a/state-chain/pallets/cf-elections/src/electoral_systems/solana_vault_swap_accounts.rs b/state-chain/pallets/cf-elections/src/electoral_systems/solana_vault_swap_accounts.rs new file mode 100644 index 0000000000..ce92e88461 --- /dev/null +++ b/state-chain/pallets/cf-elections/src/electoral_systems/solana_vault_swap_accounts.rs @@ -0,0 +1,289 @@ +use codec::{Decode, Encode}; +use scale_info::TypeInfo; +use serde::{Deserialize, Serialize}; +use sp_std::collections::{btree_map::BTreeMap, btree_set::BTreeSet}; + +#[cfg(feature = "runtime-benchmarks")] +use cf_chains::benchmarking_value::BenchmarkValue; +#[cfg(feature = "runtime-benchmarks")] +use cf_chains::sol::api::VaultSwapAccountAndSender; + +use crate::{ + electoral_system::{ + AuthorityVoteOf, ConsensusVotes, ElectionReadAccess, ElectionWriteAccess, ElectoralSystem, + ElectoralWriteAccess, VotePropertiesOf, + }, + vote_storage::{self, VoteStorage}, + CorruptStorageError, ElectionIdentifier, +}; +use cf_chains::sol::{ + MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES, + MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS, + NONCE_AVAILABILITY_THRESHOLD_FOR_INITIATING_SWAP_ACCOUNT_CLOSURES, +}; +use cf_utilities::success_threshold_from_share_count; +use frame_support::{ + pallet_prelude::{MaybeSerializeDeserialize, Member}, + sp_runtime::traits::Saturating, + Parameter, +}; +use itertools::Itertools; +use sp_std::vec::Vec; + +pub trait SolanaVaultSwapAccountsHook { + fn close_accounts(accounts: Vec) -> Result<(), E>; + fn initiate_vault_swap(swap_details: SwapDetails); + fn get_number_of_available_sol_nonce_accounts() -> usize; +} + +pub type SolanaVaultSwapAccountsLastClosedAt = BlockNumber; + +#[derive( + Clone, PartialEq, Eq, Debug, Serialize, Deserialize, TypeInfo, Encode, Decode, Default, +)] +pub struct SolanaVaultSwapsKnownAccounts { + pub witnessed_open_accounts: Vec, + pub closure_initiated_accounts: BTreeSet, +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for SolanaVaultSwapsKnownAccounts { + fn benchmark_value() -> Self { + Self { + witnessed_open_accounts: sp_std::vec![BenchmarkValue::benchmark_value()], + closure_initiated_accounts: BTreeSet::from([BenchmarkValue::benchmark_value()]), + } + } +} + +#[derive( + Clone, PartialEq, Eq, Debug, Serialize, Deserialize, TypeInfo, Encode, Decode, Ord, PartialOrd, +)] +pub struct SolanaVaultSwapsVote { + pub new_accounts: BTreeSet<(Account, Option)>, + pub confirm_closed_accounts: BTreeSet, +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue + for SolanaVaultSwapsVote +{ + fn benchmark_value() -> Self { + Self { + new_accounts: BTreeSet::from([( + BenchmarkValue::benchmark_value(), + Some(BenchmarkValue::benchmark_value()), + )]), + confirm_closed_accounts: BTreeSet::from([BenchmarkValue::benchmark_value()]), + } + } +} + +pub struct SolanaVaultSwapAccounts< + Account, + SwapDetails, + BlockNumber, + Settings, + Hook, + ValidatorId, + E, +> { + _phantom: core::marker::PhantomData<( + Account, + SwapDetails, + BlockNumber, + Settings, + Hook, + ValidatorId, + E, + )>, +} +impl< + E: sp_std::fmt::Debug + 'static, + Account: MaybeSerializeDeserialize + Member + Parameter + Ord, + SwapDetails: MaybeSerializeDeserialize + Member + Parameter + Ord, + BlockNumber: MaybeSerializeDeserialize + Member + Parameter + Ord + Saturating + Into + Copy, + Settings: Member + Parameter + MaybeSerializeDeserialize + Eq, + Hook: SolanaVaultSwapAccountsHook + 'static, + ValidatorId: Member + Parameter + Ord + MaybeSerializeDeserialize, + > ElectoralSystem + for SolanaVaultSwapAccounts +{ + type ValidatorId = ValidatorId; + type ElectoralUnsynchronisedState = SolanaVaultSwapAccountsLastClosedAt; + type ElectoralUnsynchronisedStateMapKey = (); + type ElectoralUnsynchronisedStateMapValue = (); + + type ElectoralUnsynchronisedSettings = (); + type ElectoralSettings = Settings; + type ElectionIdentifierExtra = (); + type ElectionProperties = SolanaVaultSwapsKnownAccounts; + type ElectionState = (); + type Vote = vote_storage::bitmap::Bitmap>; + type Consensus = SolanaVaultSwapsVote; + type OnFinalizeContext = BlockNumber; + type OnFinalizeReturn = (); + + fn generate_vote_properties( + _election_identifier: ElectionIdentifier, + _previous_vote: Option<(VotePropertiesOf, AuthorityVoteOf)>, + _vote: &::PartialVote, + ) -> Result, CorruptStorageError> { + Ok(()) + } + + fn is_vote_desired>( + _election_access: &ElectionAccess, + _current_vote: Option<(VotePropertiesOf, AuthorityVoteOf)>, + ) -> Result { + Ok(true) + } + + fn is_vote_needed( + (_, current_partial_vote, _): ( + VotePropertiesOf, + ::PartialVote, + AuthorityVoteOf, + ), + (proposed_partial_vote, _): ( + ::PartialVote, + ::Vote, + ), + ) -> bool { + current_partial_vote != proposed_partial_vote + } + + fn on_finalize>( + election_identifiers: Vec>, + current_block_number: &Self::OnFinalizeContext, + ) -> Result { + if let Some(election_identifier) = election_identifiers + .into_iter() + .at_most_one() + .map_err(|_| CorruptStorageError::new())? + { + let election_access = ElectoralAccess::election_mut(election_identifier); + if let Some(consensus) = election_access.check_consensus()?.has_consensus() { + let mut known_accounts = election_access.properties()?; + election_access.delete(); + known_accounts.witnessed_open_accounts.extend(consensus.new_accounts.iter().map( + |(account, maybe_swap_details)| { + if let Some(swap_details) = maybe_swap_details.as_ref() { + Hook::initiate_vault_swap(swap_details.clone()); + } + account.clone() + }, + )); + consensus.confirm_closed_accounts.into_iter().for_each(|acc| { + known_accounts.closure_initiated_accounts.remove(&acc); + }); + + // Since closing accounts is a low priority action, we wait for certain number of + // sol nonces to be free for us to initiate account closures which indicates that + // there is not enough Chainflip activity on the sol side and so we can process + // account closures. + // + // we also wait for certain number of accounts to buffer up or allow a certain + // amount of time to pass before initiating account closures. + if Hook::get_number_of_available_sol_nonce_accounts() > + NONCE_AVAILABILITY_THRESHOLD_FOR_INITIATING_SWAP_ACCOUNT_CLOSURES && + (known_accounts.witnessed_open_accounts.len() >= + MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES || + (*current_block_number) + // current block number is always greater than when apicall was last + // created + .saturating_sub(ElectoralAccess::unsynchronised_state()?) + .into() >= MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS) + { + let accounts_to_close: Vec<_> = known_accounts + .witnessed_open_accounts + .drain( + ..sp_std::cmp::min( + known_accounts.witnessed_open_accounts.len(), + MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES, + ), + ) + .collect(); + match Hook::close_accounts(accounts_to_close.clone()) { + Ok(()) => { + known_accounts.closure_initiated_accounts.extend(accounts_to_close); + ElectoralAccess::set_unsynchronised_state(*current_block_number)?; + }, + Err(e) => { + log::error!("Failed to initiate account closure: {:?}", e); + known_accounts.witnessed_open_accounts.extend(accounts_to_close); + }, + } + } + ElectoralAccess::new_election((), known_accounts, ())?; + } + } else { + ElectoralAccess::new_election( + (), + SolanaVaultSwapsKnownAccounts { + witnessed_open_accounts: Vec::new(), + closure_initiated_accounts: BTreeSet::new(), + }, + (), + )?; + } + + Ok(()) + } + + fn check_consensus>( + _election_access: &ElectionAccess, + _previous_consensus: Option<&Self::Consensus>, + consensus_votes: ConsensusVotes, + ) -> Result, CorruptStorageError> { + let num_authorities = consensus_votes.num_authorities(); + let success_threshold = success_threshold_from_share_count(num_authorities); + let active_votes = consensus_votes.active_votes(); + let num_active_votes = active_votes.len() as u32; + Ok(if num_active_votes >= success_threshold { + let mut counts_votes = BTreeMap::new(); + let mut counts_new_accounts = BTreeMap::new(); + let mut counts_confirm_closed_accounts = BTreeMap::new(); + + for vote in active_votes { + counts_votes.entry(vote).and_modify(|count| *count += 1).or_insert(1); + } + + counts_votes.iter().for_each(|(vote, count)| { + count_votes(&vote.new_accounts, &mut counts_new_accounts, count); + count_votes( + &vote.confirm_closed_accounts, + &mut counts_confirm_closed_accounts, + count, + ); + }); + + counts_new_accounts.retain(|_, count| *count >= success_threshold); + let new_accounts = counts_new_accounts.into_keys().collect::>(); + counts_confirm_closed_accounts.retain(|_, count| *count >= success_threshold); + let confirm_closed_accounts = + counts_confirm_closed_accounts.into_keys().collect::>(); + + if new_accounts.is_empty() && confirm_closed_accounts.is_empty() { + None + } else { + Some(SolanaVaultSwapsVote { new_accounts, confirm_closed_accounts }) + } + } else { + None + }) + } +} + +fn count_votes( + accounts: &BTreeSet, + counts_accounts: &mut BTreeMap, + count: &u32, +) { + accounts.iter().for_each(|account| { + counts_accounts + .entry((*account).clone()) + .and_modify(|c| *c += *count) + .or_insert(*count); + }); +} diff --git a/state-chain/pallets/cf-elections/src/electoral_systems/tests.rs b/state-chain/pallets/cf-elections/src/electoral_systems/tests.rs index 84cdc6ad36..7fffd97d21 100644 --- a/state-chain/pallets/cf-elections/src/electoral_systems/tests.rs +++ b/state-chain/pallets/cf-elections/src/electoral_systems/tests.rs @@ -6,5 +6,6 @@ pub mod egress_success; pub mod liveness; pub mod monotonic_change; pub mod monotonic_median; +pub mod solana_vault_swap_accounts; pub mod unsafe_median; pub mod utils; diff --git a/state-chain/pallets/cf-elections/src/electoral_systems/tests/solana_vault_swap_accounts.rs b/state-chain/pallets/cf-elections/src/electoral_systems/tests/solana_vault_swap_accounts.rs new file mode 100644 index 0000000000..a5552e4966 --- /dev/null +++ b/state-chain/pallets/cf-elections/src/electoral_systems/tests/solana_vault_swap_accounts.rs @@ -0,0 +1,484 @@ +use cf_chains::sol::{ + MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES, + MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS, + NONCE_AVAILABILITY_THRESHOLD_FOR_INITIATING_SWAP_ACCOUNT_CLOSURES, +}; +use sp_std::collections::btree_set::BTreeSet; + +use super::{mocks::*, register_checks}; +use crate::{ + electoral_system::{ConsensusStatus, ConsensusVote, ConsensusVotes}, + electoral_systems::solana_vault_swap_accounts::{ + SolanaVaultSwapAccounts, SolanaVaultSwapAccountsHook, SolanaVaultSwapsKnownAccounts, + SolanaVaultSwapsVote, + }, +}; + +pub type Account = u64; +pub type SwapDetails = (); +pub type BlockNumber = u32; +pub type ValidatorId = (); + +thread_local! { + pub static CLOSE_ACCOUNTS_CALLED: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub static INITIATE_VAULT_SWAP_CALLED: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub static GET_NUMBER_OF_SOL_NONCES_CALLED: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub static FAIL_CLOSE_ACCOUNTS: std::cell::Cell = const { std::cell::Cell::new(false) }; + pub static NO_OF_SOL_NONCES: std::cell::Cell = const { std::cell::Cell::new(10) }; +} + +struct MockHook; + +impl SolanaVaultSwapAccountsHook for MockHook { + fn close_accounts(_accounts: Vec) -> Result<(), ()> { + CLOSE_ACCOUNTS_CALLED.with(|hook_called| hook_called.set(hook_called.get() + 1)); + if FAIL_CLOSE_ACCOUNTS.with(|hook_called| hook_called.get()) { + Err(()) + } else { + Ok(()) + } + } + + fn initiate_vault_swap(_swap_details: SwapDetails) { + INITIATE_VAULT_SWAP_CALLED.with(|hook_called| hook_called.set(hook_called.get() + 1)); + } + + fn get_number_of_available_sol_nonce_accounts() -> usize { + GET_NUMBER_OF_SOL_NONCES_CALLED.with(|hook_called| hook_called.set(hook_called.get() + 1)); + NO_OF_SOL_NONCES.with(|hook_called| hook_called.get()) + } +} + +impl MockHook { + pub fn close_accounts_called() -> u8 { + CLOSE_ACCOUNTS_CALLED.with(|hook_called| hook_called.get()) + } + pub fn init_swap_called() -> u8 { + INITIATE_VAULT_SWAP_CALLED.with(|hook_called| hook_called.get()) + } + pub fn get_number_of_available_sol_nonce_accounts_called() -> u8 { + GET_NUMBER_OF_SOL_NONCES_CALLED.with(|hook_called| hook_called.get()) + } +} + +type MinimalVaultSwapAccounts = + SolanaVaultSwapAccounts; + +register_checks! { + MinimalVaultSwapAccounts { + only_one_election(_pre, post) { + assert_eq!(post.election_identifiers.len(), 1, "Only one election should exist."); + }, + initiate_vault_swap_hook_not_called(_pre, _post) { + assert_eq!(INITIATE_VAULT_SWAP_CALLED.with(|hook_called| hook_called.get()), 0, "Hook should have been called once so far!"); + }, + initiate_vault_swap_hook_called_twice(_pre, _post) { + assert_eq!(INITIATE_VAULT_SWAP_CALLED.with(|hook_called| hook_called.get()), 2, "Hook not called expected number of times"); + }, + initiate_vault_swap_hook_called_four_times(_pre,_post) { + assert_eq!(INITIATE_VAULT_SWAP_CALLED.with(|hook_called| hook_called.get()), 4, "Hook not called expected number of times"); + }, + initiate_vault_swap_hook_called_15_times(_pre, _post) { + assert_eq!(INITIATE_VAULT_SWAP_CALLED.with(|hook_called| hook_called.get()), 15, "Hook not called expected number of times"); + }, + close_accounts_hook_not_called(_pre, _post) { + assert_eq!(CLOSE_ACCOUNTS_CALLED.with(|hook_called| hook_called.get()), 0, "Hook should not have been called!"); + }, + close_accounts_hook_called_once(_pre, _post) { + assert_eq!(CLOSE_ACCOUNTS_CALLED.with(|hook_called| hook_called.get()), 1, "Hook not called expected number of times"); + }, + get_sol_nonces_hook_not_called(_pre, _post) { + assert_eq!(GET_NUMBER_OF_SOL_NONCES_CALLED.with(|hook_called| hook_called.get()), 0, "Hook should not have been called!"); + }, + get_sol_nonces_hook_called_once(_pre, _post) { + assert_eq!(GET_NUMBER_OF_SOL_NONCES_CALLED.with(|hook_called| hook_called.get()), 1, "Hook not called expected number of times"); + }, + get_sol_nonces_hook_called_twice(_pre, _post) { + assert_eq!(GET_NUMBER_OF_SOL_NONCES_CALLED.with(|hook_called| hook_called.get()), 2, "Hook not called expected number of times"); + }, + } +} + +pub const TEST_NUMBER_OF_ACCOUNTS: u64 = 15; + +#[test] +fn on_finalize_accounts_limit_reached() { + TestSetup::default() + .with_unsynchronised_state(0) + .build() + .test_on_finalize( + &0u32, + |_| { + assert_eq!( + MockHook::close_accounts_called(), + 0, + "Hook should not have been called!" + ); + assert_eq!(MockHook::init_swap_called(), 0, "Hook should not have been called!"); + assert_eq!( + MockHook::get_number_of_available_sol_nonce_accounts_called(), + 0, + "Hook should not have been called!" + ); + }, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_not_called(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_not_called(), + ], + ) + .force_consensus_update(ConsensusStatus::Gained { + new: generate_votes_for_account_range(0..TEST_NUMBER_OF_ACCOUNTS), + most_recent: None, + }) + // account closure will be initiated since account limit is reached, even though time limit + // has not reached yet. + .test_on_finalize( + &1u32, + |_| {}, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_called_15_times(), + Check::::close_accounts_hook_called_once(), + Check::::get_sol_nonces_hook_called_once(), + ], + ); +} + +#[test] +fn on_finalize_time_limit_reached() { + TestSetup::default() + .with_unsynchronised_state(0) + .build() + .test_on_finalize( + &0u32, + |_| { + assert_eq!( + MockHook::close_accounts_called(), + 0, + "Hook should not have been called!" + ); + assert_eq!(MockHook::init_swap_called(), 0, "Hook should not have been called!"); + assert_eq!( + MockHook::get_number_of_available_sol_nonce_accounts_called(), + 0, + "Hook should not have been called!" + ); + }, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_not_called(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_not_called(), + ], + ) + .force_consensus_update(ConsensusStatus::Gained { + new: generate_votes_for_account_range(0..2), + most_recent: None, + }) + // account closure will not initiate since we havent reached time or account limit + .test_on_finalize( + &0, + |_| {}, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_called_twice(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_called_once(), + ], + ) + .force_consensus_update(ConsensusStatus::Gained { + new: generate_votes_for_account_range(2..4), + most_recent: None, + }) + // time limit reached. account closure initiated even though account number limit not + // reached + .test_on_finalize( + &MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS, + |_| {}, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_called_four_times(), + Check::::close_accounts_hook_called_once(), + Check::::get_sol_nonces_hook_called_twice(), + ], + ); +} + +#[test] +fn on_finalize_close_accounts_error() { + let max_batch_size: u64 = MAX_BATCH_SIZE_OF_VAULT_SWAP_ACCOUNT_CLOSURES.try_into().unwrap(); + FAIL_CLOSE_ACCOUNTS.with(|hook_called| hook_called.set(true)); + TestSetup::default() + .with_unsynchronised_state(0) + .build() + .test_on_finalize( + &0u32, + |_| { + assert_eq!( + MockHook::close_accounts_called(), + 0, + "Hook should not have been called!" + ); + assert_eq!(MockHook::init_swap_called(), 0, "Hook should not have been called!"); + assert_eq!( + MockHook::get_number_of_available_sol_nonce_accounts_called(), + 0, + "Hook should not have been called!" + ); + }, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_not_called(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_not_called(), + ], + ) + .force_consensus_update(ConsensusStatus::Gained { + most_recent: None, + new: generate_votes_for_account_range(0..TEST_NUMBER_OF_ACCOUNTS), + }) + .test_on_finalize( + &1u32, + |_| {}, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_called_15_times(), + Check::::close_accounts_hook_called_once(), + Check::::get_sol_nonces_hook_called_once(), + ], + ) + .expect_election_properties_only_election(SolanaVaultSwapsKnownAccounts { + // if close_accounts errors, the accounts are pushed back into open accounts at the end + // of the vector. + witnessed_open_accounts: (max_batch_size..TEST_NUMBER_OF_ACCOUNTS) + .chain(0u64..max_batch_size) + .collect::>(), + closure_initiated_accounts: BTreeSet::new(), + }); +} + +#[test] +fn on_finalize_nonces_below_threshold() { + NO_OF_SOL_NONCES.with(|hook_called| { + hook_called.set(NONCE_AVAILABILITY_THRESHOLD_FOR_INITIATING_SWAP_ACCOUNT_CLOSURES - 1) + }); + TestSetup::default() + .with_unsynchronised_state(0) + .build() + .test_on_finalize( + &0u32, + |_| { + assert_eq!( + MockHook::close_accounts_called(), + 0, + "Hook should not have been called!" + ); + assert_eq!(MockHook::init_swap_called(), 0, "Hook should not have been called!"); + assert_eq!( + MockHook::get_number_of_available_sol_nonce_accounts_called(), + 0, + "Hook should not have been called!" + ); + }, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_not_called(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_not_called(), + ], + ) + .force_consensus_update(ConsensusStatus::Gained { + most_recent: None, + new: generate_votes_for_account_range(0..TEST_NUMBER_OF_ACCOUNTS), + }) + .test_on_finalize( + &1u32, + |_| {}, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_called_15_times(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_called_once(), + ], + ) + .expect_election_properties_only_election(SolanaVaultSwapsKnownAccounts { + witnessed_open_accounts: (0..TEST_NUMBER_OF_ACCOUNTS).collect::>(), + closure_initiated_accounts: BTreeSet::new(), + }); +} + +#[test] +fn on_finalize_invalid_swap() { + TestSetup::default() + .with_unsynchronised_state(0) + .build() + .test_on_finalize( + &0u32, + |_| { + assert_eq!( + MockHook::close_accounts_called(), + 0, + "Hook should not have been called!" + ); + assert_eq!(MockHook::init_swap_called(), 0, "Hook should not have been called!"); + assert_eq!( + MockHook::get_number_of_available_sol_nonce_accounts_called(), + 0, + "Hook should not have been called!" + ); + }, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_not_called(), + Check::::close_accounts_hook_not_called(), + Check::::get_sol_nonces_hook_not_called(), + ], + ) + // we have a new account but it is an invalid swap + .force_consensus_update(ConsensusStatus::Gained { + most_recent: None, + new: SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(0, None)]), + confirm_closed_accounts: BTreeSet::new(), + }, + }) + .test_on_finalize( + &MAX_WAIT_BLOCKS_FOR_SWAP_ACCOUNT_CLOSURE_APICALLS, + |_| {}, + vec![ + Check::::only_one_election(), + Check::::initiate_vault_swap_hook_not_called(), + Check::::close_accounts_hook_called_once(), + Check::::get_sol_nonces_hook_called_once(), + ], + ); +} + +pub const NEW_ACCOUNT_1: u64 = 1u64; +pub const NEW_ACCOUNT_2: u64 = 2u64; +pub const NEW_ACCOUNT_3: u64 = 3u64; + +pub const CLOSED_ACCOUNT_1: u64 = 4u64; +pub const CLOSED_ACCOUNT_2: u64 = 5u64; + +#[test] +fn test_consensus() { + TestSetup::::default() + .build_with_initial_election() + .expect_consensus( + generate_votes_specific_case([80, 80, 0, 0]), + Some(SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([ + (NEW_ACCOUNT_1, Some(())), + (NEW_ACCOUNT_2, Some(())), + ]), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_1]), + }), + ); + + TestSetup::::default() + .build_with_initial_election() + .expect_consensus( + generate_votes_specific_case([0, 80, 80, 80]), + Some(SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([ + (NEW_ACCOUNT_1, Some(())), + (NEW_ACCOUNT_2, Some(())), + (NEW_ACCOUNT_3, Some(())), + ]), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_1]), + }), + ); + + TestSetup::::default() + .build_with_initial_election() + .expect_consensus( + generate_votes_specific_case([0, 0, 80, 80]), + Some(SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(NEW_ACCOUNT_3, Some(()))]), + confirm_closed_accounts: BTreeSet::from([]), + }), + ); + + TestSetup::::default() + .build_with_initial_election() + .expect_consensus(ConsensusVotes { votes: vec![] }, None); + + TestSetup::::default() + .build_with_initial_election() + .expect_consensus(generate_vote_no_consensus(), None); +} + +fn generate_vote_no_consensus() -> ConsensusVotes { + let vote_1 = SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(1, Some(())), (2, Some(()))]), + confirm_closed_accounts: BTreeSet::new(), + }; + + let vote_2 = SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(3, Some(())), (4, Some(()))]), + confirm_closed_accounts: BTreeSet::new(), + }; + + ConsensusVotes { + votes: (0..80) + .map(|_| ConsensusVote { vote: Some(((), vote_1.clone())), validator_id: () }) + .chain( + (0..80) + .map(|_| ConsensusVote { vote: Some(((), vote_2.clone())), validator_id: () }), + ) + .collect::>(), + } +} + +fn generate_votes_specific_case( + no_of_each_vote: [usize; 4], +) -> ConsensusVotes { + let votes = [ + SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([ + (NEW_ACCOUNT_1, Some(())), + (NEW_ACCOUNT_2, Some(())), + (NEW_ACCOUNT_3, Some(())), + ]), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_1, CLOSED_ACCOUNT_2]), + }, + SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(NEW_ACCOUNT_1, Some(())), (NEW_ACCOUNT_2, Some(()))]), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_1]), + }, + SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(NEW_ACCOUNT_1, Some(())), (NEW_ACCOUNT_3, Some(()))]), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_1]), + }, + SolanaVaultSwapsVote { + new_accounts: BTreeSet::from([(NEW_ACCOUNT_2, Some(())), (NEW_ACCOUNT_3, Some(()))]), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_2]), + }, + ]; + ConsensusVotes { + votes: no_of_each_vote + .iter() + .enumerate() + .flat_map(|(i, &count)| { + let vote = votes[i].clone(); + std::iter::repeat_with(move || ConsensusVote { + vote: Some(((), vote.clone())), + validator_id: (), + }) + .take(count) + }) + .collect::>(), + } +} + +fn generate_votes_for_account_range( + r: std::ops::Range, +) -> SolanaVaultSwapsVote { + SolanaVaultSwapsVote { + new_accounts: r.map(|i| (i, Some(()))).collect::>(), + confirm_closed_accounts: BTreeSet::from([CLOSED_ACCOUNT_1, CLOSED_ACCOUNT_2]), + } +} diff --git a/state-chain/pallets/cf-elections/src/lib.rs b/state-chain/pallets/cf-elections/src/lib.rs index dbea9cfb96..d8f8f9864a 100644 --- a/state-chain/pallets/cf-elections/src/lib.rs +++ b/state-chain/pallets/cf-elections/src/lib.rs @@ -124,7 +124,7 @@ use frame_system::pallet_prelude::*; pub use pallet::*; -pub const PALLET_VERSION: StorageVersion = StorageVersion::new(2); +pub const PALLET_VERSION: StorageVersion = StorageVersion::new(3); pub use pallet::UniqueMonotonicIdentifier; @@ -492,7 +492,7 @@ pub mod pallet { /// Stores persistent state the electoral system needs. #[pallet::storage] - pub(crate) type ElectoralUnsynchronisedState, I: 'static = ()> = StorageValue< + pub type ElectoralUnsynchronisedState, I: 'static = ()> = StorageValue< _, ::ElectoralUnsynchronisedState, OptionQuery, diff --git a/state-chain/pallets/cf-elections/src/vote_storage/composite.rs b/state-chain/pallets/cf-elections/src/vote_storage/composite.rs index 88e1099710..3de8dc9e97 100644 --- a/state-chain/pallets/cf-elections/src/vote_storage/composite.rs +++ b/state-chain/pallets/cf-elections/src/vote_storage/composite.rs @@ -274,4 +274,4 @@ macro_rules! generate_vote_storage_tuple_impls { } } -generate_vote_storage_tuple_impls!(tuple_6_impls: (A, B, C, D, EE, FF)); +generate_vote_storage_tuple_impls!(tuple_7_impls: (A, B, C, D, EE, FF, GG)); diff --git a/state-chain/pallets/cf-elections/src/vote_storage/individual/composite.rs b/state-chain/pallets/cf-elections/src/vote_storage/individual/composite.rs index 6258427b69..33accbb882 100644 --- a/state-chain/pallets/cf-elections/src/vote_storage/individual/composite.rs +++ b/state-chain/pallets/cf-elections/src/vote_storage/individual/composite.rs @@ -76,4 +76,4 @@ macro_rules! generate_individual_vote_storage_tuple_impls { } #[cfg(test)] generate_individual_vote_storage_tuple_impls!(tuple_2_impls: (A, B)); -generate_individual_vote_storage_tuple_impls!(tuple_6_impls: (A, B, C, D, EE, FF)); +generate_individual_vote_storage_tuple_impls!(tuple_7_impls: (A, B, C, D, EE, FF, GG)); diff --git a/state-chain/pallets/cf-ingress-egress/src/lib.rs b/state-chain/pallets/cf-ingress-egress/src/lib.rs index aeb2541b95..92cb0cc458 100644 --- a/state-chain/pallets/cf-ingress-egress/src/lib.rs +++ b/state-chain/pallets/cf-ingress-egress/src/lib.rs @@ -2120,7 +2120,7 @@ impl, I: 'static> Pallet { Ok(()) } - fn process_vault_swap_request( + pub fn process_vault_swap_request( source_asset: TargetChainAsset, deposit_amount: ::ChainAmount, destination_asset: Asset, diff --git a/state-chain/primitives/src/lib.rs b/state-chain/primitives/src/lib.rs index 28a1045bde..d7980268cd 100644 --- a/state-chain/primitives/src/lib.rs +++ b/state-chain/primitives/src/lib.rs @@ -389,7 +389,18 @@ pub type Affiliates = BoundedVec, ConstU32>; pub type Beneficiaries = BoundedVec, ConstU32>; #[derive( - Clone, Debug, PartialEq, Eq, MaxEncodedLen, Encode, Decode, TypeInfo, Serialize, Deserialize, + Clone, + Debug, + PartialEq, + Eq, + MaxEncodedLen, + Encode, + Decode, + TypeInfo, + Serialize, + Deserialize, + PartialOrd, + Ord, )] pub struct Beneficiary { pub account: Id, @@ -419,6 +430,8 @@ impl From for Beneficiary { TypeInfo, Serialize, Deserialize, + PartialOrd, + Ord, )] pub struct DcaParameters { /// The number of individual swaps to be executed @@ -426,3 +439,5 @@ pub struct DcaParameters { /// The interval in blocks between each swap. pub chunk_interval: u32, } + +pub type ShortId = u8; diff --git a/state-chain/runtime/src/chainflip/solana_elections.rs b/state-chain/runtime/src/chainflip/solana_elections.rs index 792e52661b..2db0e38335 100644 --- a/state-chain/runtime/src/chainflip/solana_elections.rs +++ b/state-chain/runtime/src/chainflip/solana_elections.rs @@ -1,36 +1,42 @@ use crate::{ - Environment, Offence, Reputation, Runtime, SolanaBroadcaster, SolanaChainTracking, + AccountId, Environment, Offence, Reputation, Runtime, SolanaBroadcaster, SolanaChainTracking, SolanaIngressEgress, SolanaThresholdSigner, }; use cf_chains::{ + address::EncodedAddress, + assets::{any::Asset, sol::Asset as SolAsset}, instances::{ChainInstanceAlias, SolanaInstance}, sol::{ - api::SolanaTransactionType, SolAddress, SolAmount, SolHash, SolSignature, SolTrackedData, - SolanaCrypto, + api::{ + SolanaApi, SolanaTransactionBuildingError, SolanaTransactionType, + VaultSwapAccountAndSender, + }, + SolAddress, SolAmount, SolHash, SolSignature, SolTrackedData, SolanaCrypto, }, - Chain, FeeEstimationApi, ForeignChain, Solana, + CcmDepositMetadata, Chain, ChannelRefundParameters, CloseSolanaVaultSwapAccounts, + FeeEstimationApi, ForeignChain, Solana, }; +use cf_primitives::{AffiliateShortId, Affiliates, Beneficiary, DcaParameters}; use cf_runtime_utilities::log_or_panic; use cf_traits::{ - offence_reporting::OffenceReporter, AdjustedFeeEstimationApi, Chainflip, + offence_reporting::OffenceReporter, AdjustedFeeEstimationApi, Broadcaster, Chainflip, ElectionEgressWitnesser, GetBlockHeight, IngressSource, SolanaNonceWatch, }; - use codec::{Decode, Encode}; use frame_system::pallet_prelude::BlockNumberFor; use pallet_cf_elections::{ electoral_system::{ElectoralReadAccess, ElectoralSystem}, electoral_systems::{ self, - composite::{tuple_6_impls::Hooks, CompositeRunner}, + composite::{tuple_7_impls::Hooks, CompositeRunner}, egress_success::OnEgressSuccess, liveness::OnCheckComplete, monotonic_change::OnChangeHook, monotonic_median::MedianChangeHook, + solana_vault_swap_accounts::SolanaVaultSwapAccountsHook, }, CorruptStorageError, ElectionIdentifier, InitialState, InitialStateOf, RunnerStorageAccess, }; - use scale_info::TypeInfo; use serde::{Deserialize, Serialize}; use sp_runtime::{DispatchResult, FixedPointNumber, FixedU128}; @@ -40,6 +46,8 @@ use sp_std::{collections::btree_set::BTreeSet, vec::Vec}; use cf_chains::benchmarking_value::BenchmarkValue; use sol_prim::SlotNumber; +use super::SolEnvironment; + type Instance = ::Instance; pub type SolanaElectoralSystemRunner = CompositeRunner< @@ -50,6 +58,7 @@ pub type SolanaElectoralSystemRunner = CompositeRunner< SolanaNonceTracking, SolanaEgressWitnessing, SolanaLiveness, + SolanaVaultSwapTracking, ), ::ValidatorId, RunnerStorageAccess, @@ -63,6 +72,7 @@ pub fn initial_state( priority_fee: SolAmount, vault_program: SolAddress, usdc_token_mint_pubkey: SolAddress, + swap_endpoint_data_account_address: SolAddress, ) -> InitialStateOf { InitialState { unsynchronised_state: ( @@ -74,6 +84,7 @@ pub fn initial_state( (), (), (), + 0u32, ), unsynchronised_settings: ( (), @@ -82,6 +93,7 @@ pub fn initial_state( (), (), (), + (), ), settings: ( (), @@ -90,6 +102,7 @@ pub fn initial_state( (), (), LIVENESS_CHECK_DURATION, + SolanaVaultSwapsSettings { swap_endpoint_data_account_address, usdc_token_mint_pubkey }, ), } } @@ -145,6 +158,16 @@ impl OnCheckComplete<::ValidatorId> for OnCheckCompleteHoo Reputation::report_many(Offence::FailedLivenessCheck(ForeignChain::Solana), validator_ids); } } +pub type SolanaVaultSwapTracking = + electoral_systems::solana_vault_swap_accounts::SolanaVaultSwapAccounts< + VaultSwapAccountAndSender, + SolanaVaultSwapDetails, + BlockNumberFor, + SolanaVaultSwapsSettings, + SolanaVaultSwapsHandler, + ::ValidatorId, + SolanaTransactionBuildingError, + >; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, TypeInfo)] pub struct TransactionSuccessDetails { @@ -227,6 +250,7 @@ impl SolanaNonceTracking, SolanaEgressWitnessing, SolanaLiveness, + SolanaVaultSwapTracking, > for SolanaElectionHooks { fn on_finalize( @@ -237,6 +261,7 @@ impl nonce_tracking_identifiers, egress_witnessing_identifiers, liveness_identifiers, + vault_swap_identifiers, ): ( Vec< ElectionIdentifier< @@ -262,8 +287,14 @@ impl >, >, Vec::ElectionIdentifierExtra>>, + Vec< + ElectionIdentifier< + ::ElectionIdentifierExtra, + >, + >, ), ) -> Result<(), CorruptStorageError> { + let current_sc_block_number = crate::System::block_number(); let block_height = SolanaBlockHeightTracking::on_finalize::< DerivedElectoralAccess< _, @@ -273,7 +304,7 @@ impl >(block_height_identifiers, &())?; SolanaLiveness::on_finalize::< DerivedElectoralAccess<_, SolanaLiveness, RunnerStorageAccess>, - >(liveness_identifiers, &(crate::System::block_number(), block_height))?; + >(liveness_identifiers, &(current_sc_block_number, block_height))?; SolanaFeeTracking::on_finalize::< DerivedElectoralAccess< _, @@ -302,6 +333,13 @@ impl RunnerStorageAccess, >, >(ingress_identifiers, &block_height)?; + SolanaVaultSwapTracking::on_finalize::< + DerivedElectoralAccess< + _, + SolanaVaultSwapTracking, + RunnerStorageAccess, + >, + >(vault_swap_identifiers, ¤t_sc_block_number)?; Ok(()) } } @@ -334,7 +372,7 @@ impl BenchmarkValue for SolanaIngressSettings { } } -use pallet_cf_elections::electoral_systems::composite::tuple_6_impls::DerivedElectoralAccess; +use pallet_cf_elections::electoral_systems::composite::tuple_7_impls::DerivedElectoralAccess; pub struct SolanaChainTrackingProvider; impl GetBlockHeight for SolanaChainTrackingProvider { @@ -472,3 +510,102 @@ impl ElectionEgressWitnesser for SolanaEgressWitnessingTrigger { }) } } + +#[derive( + Clone, PartialEq, Eq, Debug, Serialize, Deserialize, TypeInfo, Encode, Decode, PartialOrd, Ord, +)] +pub struct SolanaVaultSwapDetails { + pub from: SolAsset, + pub to: Asset, + pub deposit_amount: SolAmount, + pub destination_address: EncodedAddress, + pub deposit_metadata: Option, + pub swap_account: SolAddress, + pub creation_slot: u64, + pub broker_fee: Beneficiary, + pub refund_params: ChannelRefundParameters, + pub dca_params: Option, + pub boost_fee: u8, + pub affiliate_fees: Affiliates, +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for SolanaVaultSwapDetails { + fn benchmark_value() -> Self { + Self { + from: BenchmarkValue::benchmark_value(), + to: BenchmarkValue::benchmark_value(), + deposit_amount: BenchmarkValue::benchmark_value(), + destination_address: BenchmarkValue::benchmark_value(), + deposit_metadata: Some(BenchmarkValue::benchmark_value()), + swap_account: BenchmarkValue::benchmark_value(), + creation_slot: BenchmarkValue::benchmark_value(), + broker_fee: BenchmarkValue::benchmark_value(), + refund_params: BenchmarkValue::benchmark_value(), + dca_params: Some(BenchmarkValue::benchmark_value()), + boost_fee: BenchmarkValue::benchmark_value(), + affiliate_fees: BenchmarkValue::benchmark_value(), + } + } +} + +pub struct SolanaVaultSwapsHandler; + +impl + SolanaVaultSwapAccountsHook< + VaultSwapAccountAndSender, + SolanaVaultSwapDetails, + SolanaTransactionBuildingError, + > for SolanaVaultSwapsHandler +{ + fn initiate_vault_swap(swap_details: SolanaVaultSwapDetails) { + SolanaIngressEgress::process_vault_swap_request( + swap_details.from, + swap_details.deposit_amount, + swap_details.to, + swap_details.destination_address, + swap_details.deposit_metadata, + (swap_details.swap_account, swap_details.creation_slot), + (), + swap_details.broker_fee, + swap_details.affiliate_fees, + swap_details.refund_params, + swap_details.dca_params, + swap_details.boost_fee.into(), + ); + } + + fn close_accounts( + accounts: Vec, + ) -> Result<(), SolanaTransactionBuildingError> { + as CloseSolanaVaultSwapAccounts>::new_unsigned(accounts).map( + |apicall| { + let _ = >::threshold_sign_and_broadcast( + apicall, + ); + }, + ) + } + + fn get_number_of_available_sol_nonce_accounts() -> usize { + Environment::get_number_of_available_sol_nonce_accounts() + } +} + +#[derive( + Clone, PartialEq, Eq, Debug, Serialize, Deserialize, TypeInfo, Encode, Decode, PartialOrd, Ord, +)] +pub struct SolanaVaultSwapsSettings { + pub swap_endpoint_data_account_address: SolAddress, + pub usdc_token_mint_pubkey: SolAddress, +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkValue for SolanaVaultSwapsSettings { + fn benchmark_value() -> Self { + Self { + swap_endpoint_data_account_address: BenchmarkValue::benchmark_value(), + usdc_token_mint_pubkey: BenchmarkValue::benchmark_value(), + } + } +} diff --git a/state-chain/runtime/src/lib.rs b/state-chain/runtime/src/lib.rs index 419211cebb..77e0b66b75 100644 --- a/state-chain/runtime/src/lib.rs +++ b/state-chain/runtime/src/lib.rs @@ -68,7 +68,7 @@ use cf_traits::{ }; use codec::{alloc::string::ToString, Decode, Encode}; use core::ops::Range; -use frame_support::{derive_impl, instances::*}; +use frame_support::{derive_impl, instances::*, migrations::VersionedMigration}; pub use frame_system::Call as SystemCall; use pallet_cf_governance::GovCallHash; use pallet_cf_ingress_egress::{ @@ -1293,7 +1293,13 @@ type PalletMigrations = ( pallet_cf_cfe_interface::migrations::PalletMigration, ); -type MigrationsForV1_8 = (); +type MigrationsForV1_8 = VersionedMigration< + 2, + 3, + migrations::solana_vault_swaps_migration::SolanaVaultSwapsMigration, + pallet_cf_elections::Pallet, + DbWeight, +>; #[cfg(feature = "runtime-benchmarks")] #[macro_use] diff --git a/state-chain/runtime/src/migrations.rs b/state-chain/runtime/src/migrations.rs index 187dd5164c..b32758552b 100644 --- a/state-chain/runtime/src/migrations.rs +++ b/state-chain/runtime/src/migrations.rs @@ -2,3 +2,4 @@ pub mod housekeeping; pub mod reap_old_accounts; +pub mod solana_vault_swaps_migration; diff --git a/state-chain/runtime/src/migrations/solana_vault_swaps_migration.rs b/state-chain/runtime/src/migrations/solana_vault_swaps_migration.rs new file mode 100644 index 0000000000..539f97bfb2 --- /dev/null +++ b/state-chain/runtime/src/migrations/solana_vault_swaps_migration.rs @@ -0,0 +1,106 @@ +use crate::*; +use chainflip::solana_elections::SolanaVaultSwapsSettings; +use frame_support::{pallet_prelude::Weight, storage::unhashed, traits::UncheckedOnRuntimeUpgrade}; + +use pallet_cf_elections::{ + Config, ElectoralSettings, ElectoralSystemRunner, ElectoralUnsynchronisedState, +}; +#[cfg(feature = "try-runtime")] +use sp_runtime::DispatchError; + +use cf_utilities::bs58_array; +use codec::{Decode, Encode}; + +pub struct SolanaVaultSwapsMigration; + +impl UncheckedOnRuntimeUpgrade for SolanaVaultSwapsMigration { + fn on_runtime_upgrade() -> Weight { + let mut raw_unsynchronised_state = unhashed::get_raw(&ElectoralUnsynchronisedState::< + Runtime, + SolanaInstance, + >::hashed_key()) + .unwrap(); + raw_unsynchronised_state.extend(0u32.encode()); + ElectoralUnsynchronisedState::::put(<>::ElectoralSystemRunner as ElectoralSystemRunner>::ElectoralUnsynchronisedState::decode(&mut &raw_unsynchronised_state[..]).unwrap()); + + let (usdc_token_mint_pubkey, swap_endpoint_data_account_address) = + match cf_runtime_utilities::genesis_hashes::genesis_hash::() { + cf_runtime_utilities::genesis_hashes::BERGHAIN => ( + SolAddress(bs58_array("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")), + SolAddress(bs58_array("5mFsKrqCH5v9Q9uF5o6qrsUi1GV2myuhc23NAi5YFs4M")), + ), + cf_runtime_utilities::genesis_hashes::PERSEVERANCE => ( + SolAddress(bs58_array("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")), + SolAddress(bs58_array("4hD7UM6rQtcqQWtzELvrafpmBYReVXvCpssB6qjY1Sg5")), + ), + cf_runtime_utilities::genesis_hashes::SISYPHOS => ( + SolAddress(bs58_array("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")), + SolAddress(bs58_array("mYabVW1uMXpGqwgHUBQu4Fg6GT9EMYUzYaGYbi3zgT7")), + ), + _ => ( + SolAddress(bs58_array("24PNhTaNtomHhoy3fTRaMhAFCRj4uHqhZEEoWrKDbR5p")), + SolAddress(bs58_array("2tmtGLQcBd11BMiE9B1tAkQXwmPNgR79Meki2Eme4Ec9")), + ), + }; + + for key in ElectoralSettings::::iter_keys() { + let mut raw_storage_at_key = unhashed::get_raw(&ElectoralSettings::< + Runtime, + SolanaInstance, + >::hashed_key_for(key)) + .expect("We just got the keys directly from the storage"); + raw_storage_at_key.extend( + SolanaVaultSwapsSettings { + usdc_token_mint_pubkey, + swap_endpoint_data_account_address, + } + .encode(), + ); + ElectoralSettings::::insert(key, <>::ElectoralSystemRunner as ElectoralSystemRunner>::ElectoralSettings::decode(&mut &raw_storage_at_key[..]).unwrap()); + } + + Weight::zero() + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + assert!(ElectoralUnsynchronisedState::::exists()); + assert!(ElectoralSettings::::iter_keys().next().is_some()); + Ok(Default::default()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), DispatchError> { + let (.., last_block_number) = + ElectoralUnsynchronisedState::::get().unwrap(); + assert_eq!(last_block_number, 0u32); + for ( + .., + SolanaVaultSwapsSettings { usdc_token_mint_pubkey, swap_endpoint_data_account_address }, + ) in ElectoralSettings::::iter_values() + { + assert_eq!( + (usdc_token_mint_pubkey, swap_endpoint_data_account_address), + match cf_runtime_utilities::genesis_hashes::genesis_hash::() { + cf_runtime_utilities::genesis_hashes::BERGHAIN => ( + SolAddress(bs58_array("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")), + SolAddress(bs58_array("5mFsKrqCH5v9Q9uF5o6qrsUi1GV2myuhc23NAi5YFs4M")), + ), + cf_runtime_utilities::genesis_hashes::PERSEVERANCE => ( + SolAddress(bs58_array("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")), + SolAddress(bs58_array("4hD7UM6rQtcqQWtzELvrafpmBYReVXvCpssB6qjY1Sg5")), + ), + cf_runtime_utilities::genesis_hashes::SISYPHOS => ( + SolAddress(bs58_array("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")), + SolAddress(bs58_array("mYabVW1uMXpGqwgHUBQu4Fg6GT9EMYUzYaGYbi3zgT7")), + ), + _ => ( + SolAddress(bs58_array("24PNhTaNtomHhoy3fTRaMhAFCRj4uHqhZEEoWrKDbR5p")), + SolAddress(bs58_array("2tmtGLQcBd11BMiE9B1tAkQXwmPNgR79Meki2Eme4Ec9")), + ), + } + ); + } + Ok(()) + } +}