diff --git a/bouncer/shared/fund_redeem.ts b/bouncer/shared/fund_redeem.ts index 58d23e3999..dfc9f78b55 100644 --- a/bouncer/shared/fund_redeem.ts +++ b/bouncer/shared/fund_redeem.ts @@ -1,29 +1,85 @@ +import assert from 'assert'; +import { randomBytes } from 'crypto'; import { HexString } from '@polkadot/util/types'; -import { newAddress, observeBalanceIncrease } from '../shared/utils'; +import { assetDecimals } from '@chainflip-io/cli'; +import { + fineAmountToAmount, + newAddress, + observeBalanceIncrease, + getChainflipApi, +} from '../shared/utils'; import { getBalance } from '../shared/get_balance'; import { fundFlip } from '../shared/fund_flip'; -import { redeemFlip } from '../shared/redeem_flip'; +import { redeemFlip, RedeemAmount } from '../shared/redeem_flip'; import { newStatechainAddress } from '../shared/new_statechain_address'; +// Submitting the `redeem` extrinsic will cost a small amount of gas +// TODO: Send the redeem extrinsic from a different account to avoid compensating for this fee in the test. +const expectedRedeemGasFeeFlip = 0.0000125; + +/// Redeems the flip and observed the balance increase +async function redeemAndObserve( + seed: string, + redeemEthAddress: HexString, + redeemAmount: RedeemAmount, +): Promise { + const initBalance = await getBalance('FLIP', redeemEthAddress); + console.log(`Initial ERC20-FLIP balance: ${initBalance}`); + + await redeemFlip(seed, redeemEthAddress, redeemAmount); + + const newBalance = await observeBalanceIncrease('FLIP', redeemEthAddress, initBalance); + const balanceIncrease = newBalance - parseInt(initBalance); + console.log( + `Redemption success! New balance: ${newBalance.toString()}, Increase: ${balanceIncrease}`, + ); + + return balanceIncrease; +} + // Uses the seed to generate a new SC address and ETH address. // It then funds the SC address with FLIP, and redeems the FLIP to the ETH address -// checking that the balance has increased. -export async function testFundRedeem(seed: string) { +// checking that the balance has increased the expected amount. +// If no seed is provided, a random one is generated. +export async function testFundRedeem(providedSeed?: string) { + const chainflip = await getChainflipApi(); + const redemptionTax = await chainflip.query.funding.redemptionTax(); + const redemptionTaxAmount = parseInt( + fineAmountToAmount(redemptionTax.toString(), assetDecimals.FLIP), + ); + console.log(`Redemption tax: ${redemptionTax} = ${redemptionTaxAmount} FLIP`); + + const seed = providedSeed ?? randomBytes(32).toString('hex'); + const fundAmount = 1000; const redeemSCAddress = await newStatechainAddress(seed); const redeemEthAddress = await newAddress('ETH', seed); console.log(`FLIP Redeem address: ${redeemSCAddress}`); console.log(`ETH Redeem address: ${redeemEthAddress}`); - const initBalance = await getBalance('FLIP', redeemEthAddress); - console.log(`Initial ERC20-FLIP balance: ${initBalance.toString()}`); - const amount = 1000; - // We fund to a specific SC address. - await fundFlip(redeemSCAddress, amount.toString()); - - // The ERC20 FLIP is sent back to an ETH address, and the registered redemption can only be executed by that address. - await redeemFlip(seed, redeemEthAddress as HexString, (amount / 2).toString()); - console.log('Observed RedemptionSettled event'); - const newBalance = await observeBalanceIncrease('FLIP', redeemEthAddress, initBalance); - console.log(`Redemption success! New balance: ${newBalance.toString()}`); - console.log('=== Fund/Redeem Test Success ==='); - process.exit(0); + + // Fund the SC address for the tests + await fundFlip(redeemSCAddress, fundAmount.toString()); + + // Test redeeming an exact amount with a portion of the funded flip + const exactAmount = fundAmount / 4; + const exactRedeemAmount = { Exact: exactAmount.toString() }; + console.log(`Testing redeem exact amount: ${exactRedeemAmount.Exact}`); + const redeemedExact = await redeemAndObserve( + seed, + redeemEthAddress as HexString, + exactRedeemAmount, + ); + assert.strictEqual(redeemedExact, exactAmount, `Unexpected balance increase amount`); + + // Test redeeming the rest of the flip with a 'Max' redeem amount + console.log(`Testing redeem all`); + const redeemedAll = await redeemAndObserve(seed, redeemEthAddress as HexString, 'Max'); + // We expect to redeem the entire amount minus the exact amount redeemed above + tax & gas for both redemptions + const expectedRedeemAllAmount = fundAmount - redeemedExact - redemptionTaxAmount * 2; + assert( + redeemedAll >= expectedRedeemAllAmount - expectedRedeemGasFeeFlip * 2 && + redeemedAll <= expectedRedeemAllAmount, + `Unexpected balance increase amount: ${redeemedAll}. Expected between: ${ + expectedRedeemAllAmount - expectedRedeemGasFeeFlip * 2 + } - ${expectedRedeemAllAmount}. Did fees change?`, + ); } diff --git a/bouncer/shared/redeem_flip.ts b/bouncer/shared/redeem_flip.ts index 555e83c1fd..be7d9f9d71 100644 --- a/bouncer/shared/redeem_flip.ts +++ b/bouncer/shared/redeem_flip.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import { assetDecimals, executeRedemption, getRedemptionDelay } from '@chainflip-io/cli'; import { HexString } from '@polkadot/util/types'; import { Wallet, ethers } from 'ethers'; @@ -14,19 +15,47 @@ import { observeEVMEvent, } from './utils'; +export type RedeemAmount = 'Max' | { Exact: string }; + +function intoFineAmount(amount: RedeemAmount): RedeemAmount { + if (typeof amount === 'object' && amount.Exact) { + const fineAmount = amountToFineAmount(amount.Exact, assetDecimals.FLIP); + return { Exact: fineAmount }; + } + return amount; +} + const gatewayAbi = await getGatewayAbi(); -export async function redeemFlip(flipSeed: string, ethAddress: HexString, flipAmount: string) { +export async function redeemFlip( + flipSeed: string, + ethAddress: HexString, + flipAmount: RedeemAmount, +): Promise { const chainflip = await getChainflipApi(); const keyring = new Keyring({ type: 'sr25519' }); keyring.setSS58Format(2112); const flipWallet = keyring.createFromUri('//' + flipSeed); - const accountIdHex = `0x${Buffer.from(flipWallet.publicKey).toString('hex')}`; - const flipperinoAmount = amountToFineAmount(flipAmount, assetDecimals.FLIP); + const accountIdHex: HexString = `0x${Buffer.from(flipWallet.publicKey).toString('hex')}`; const ethWallet = new Wallet( process.env.ETH_USDC_WHALE ?? '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', ).connect(ethers.getDefaultProvider(process.env.ETH_ENDPOINT ?? 'http://127.0.0.1:8545')); + const networkOptions = { + signer: ethWallet, + network: 'localnet', + stateChainGatewayContractAddress: getEthContractAddress('GATEWAY'), + flipContractAddress: getEthContractAddress('FLIP'), + } as const; + + const pendingRedemption = await chainflip.query.flip.pendingRedemptionsReserve( + flipWallet.publicKey, + ); + // If a redemption is already in progress, the request will fail. + assert( + pendingRedemption.toString().length === 0, + `A redemption is already in progress for this account: ${accountIdHex}, amount: ${pendingRedemption}`, + ); console.log('Requesting redemption'); const redemptionRequestHandle = observeEvent( @@ -34,21 +63,19 @@ export async function redeemFlip(flipSeed: string, ethAddress: HexString, flipAm chainflip, (event) => event.data.accountId === flipWallet.address, ); + const flipperinoRedeemAmount = intoFineAmount(flipAmount); await chainflip.tx.funding - .redeem({ Exact: flipperinoAmount }, ethAddress, null) + .redeem(flipperinoRedeemAmount, ethAddress, null) .signAndSend(flipWallet, { nonce: -1 }, handleSubstrateError(chainflip)); - await redemptionRequestHandle; - const networkOptions = { - signer: ethWallet, - network: 'localnet', - stateChainGatewayContractAddress: getEthContractAddress('GATEWAY'), - flipContractAddress: getEthContractAddress('FLIP'), - } as const; + const redemptionRequestEvent = await redemptionRequestHandle; + console.log('Redemption requested: ', redemptionRequestEvent.data.amount); + console.log('Waiting for redemption to be registered'); + const observeEventAmount = flipperinoRedeemAmount === 'Max' ? '*' : flipperinoRedeemAmount.Exact; await observeEVMEvent(gatewayAbi, getEthContractAddress('GATEWAY'), 'RedemptionRegistered', [ accountIdHex, - flipperinoAmount, + observeEventAmount, ethAddress, '*', '*', @@ -69,7 +96,14 @@ export async function redeemFlip(flipSeed: string, ethAddress: HexString, flipAm (event) => event.data[0] === flipWallet.address, ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await executeRedemption(accountIdHex as any, networkOptions, { nonce: BigInt(nonce) }); - await redemptionExecutedHandle; + await executeRedemption(accountIdHex, networkOptions, { nonce: BigInt(nonce) }); + const redemptionExecutedAmount = (await redemptionExecutedHandle).data[1]; + console.log('Observed RedemptionSettled event: ', redemptionExecutedAmount); + assert.strictEqual( + redemptionExecutedAmount, + redemptionRequestEvent.data.amount, + "RedemptionSettled amount doesn't match RedemptionRequested amount", + ); + + return redemptionExecutedAmount; } diff --git a/bouncer/tests/fund_redeem.ts b/bouncer/tests/fund_redeem.ts index f3a8516f7f..49d4448a19 100755 --- a/bouncer/tests/fund_redeem.ts +++ b/bouncer/tests/fund_redeem.ts @@ -2,7 +2,14 @@ import { testFundRedeem } from '../shared/fund_redeem'; import { runWithTimeout } from '../shared/utils'; -runWithTimeout(testFundRedeem('redeem'), 600000).catch((error) => { +async function main(): Promise { + console.log('=== Starting Fund/Redeem test ==='); + await testFundRedeem(); + console.log('=== Fund/Redeem test complete ==='); + process.exit(0); +} + +runWithTimeout(main(), 800000).catch((error) => { console.error(error); process.exit(-1); });