From 08d98bceb7107f80a71266f741de08da26fb553d Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Tue, 16 Jun 2026 17:21:11 +0300 Subject: [PATCH] Handle vaultless deployment operations --- .../raindex_order_builder/order_operations.rs | 182 ++++++++++++++++-- packages/raindex/test/js_api/builder.test.ts | 60 +++++- .../deployment/ButtonSelectOption.svelte | 2 +- .../components/deployment/SelectToken.svelte | 14 +- 4 files changed, 233 insertions(+), 25 deletions(-) diff --git a/crates/common/src/raindex_order_builder/order_operations.rs b/crates/common/src/raindex_order_builder/order_operations.rs index 2723d8e9be..ad54483012 100644 --- a/crates/common/src/raindex_order_builder/order_operations.rs +++ b/crates/common/src/raindex_order_builder/order_operations.rs @@ -94,7 +94,7 @@ pub struct ApprovalCalldata { pub calldata: Bytes, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct VaultAndDeposit { pub order_io: OrderIOCfg, pub deposit_amount: Float, @@ -169,6 +169,38 @@ impl RaindexOrderBuilder { Ok(results) } + async fn get_vault_backed_vaults_and_deposits( + &self, + deployment: &OrderBuilderDeploymentCfg, + ) -> Result, RaindexOrderBuilderError> { + self.get_vaults_and_deposits(deployment) + .await + .map(|vaults_and_deposits| { + vaults_and_deposits + .into_iter() + .filter(|vault_and_deposit| !vault_and_deposit.order_io.vaultless) + .collect() + }) + } + + async fn get_output_deposits_by_token( + &self, + deployment: &OrderBuilderDeploymentCfg, + ) -> Result, RaindexOrderBuilderError> { + self.get_vaults_and_deposits(deployment) + .await? + .into_iter() + .map(|vault_and_deposit| { + vault_and_deposit + .order_io + .token + .as_ref() + .map(|token| (token.address, vault_and_deposit.clone())) + .ok_or(RaindexOrderBuilderError::SelectTokensNotSet) + }) + .collect() + } + pub fn prepare_calldata_generation( &mut self, calldata_function: CalldataFunction, @@ -232,8 +264,9 @@ impl RaindexOrderBuilder { &mut self, owner: String, ) -> Result { - let deposits_map = self.get_deposits_as_map().await?; - if deposits_map.is_empty() { + let deployment = self.prepare_calldata_generation(CalldataFunction::Allowance)?; + let output_deposits = self.get_output_deposits_by_token(&deployment).await?; + if output_deposits.is_empty() { return Ok(ApprovalCalldataResult::NoDeposits); } @@ -241,29 +274,34 @@ impl RaindexOrderBuilder { let mut calldatas = Vec::new(); - for (token_address, deposit_amount) in &deposits_map { + for VaultAndDeposit { + order_io, + deposit_amount, + index: _, + } in output_deposits.into_values() + { let tx_args = self.get_transaction_args()?; let rpcs = tx_args .rpcs .iter() .map(|rpc| Url::parse(rpc)) .collect::, _>>()?; + let token = order_io + .token + .as_ref() + .ok_or(RaindexOrderBuilderError::SelectTokensNotSet)?; - let erc20 = ERC20::new(rpcs, *token_address); + let erc20 = ERC20::new(rpcs, token.address); let decimals = erc20.decimals().await?; // An allowance read needs only the token, owner and raindex spender - // no deposit context. - let allowance = read_allowance( - &tx_args.rpcs, - *token_address, - owner, - tx_args.raindex_address, - ) - .await?; + let allowance = + read_allowance(&tx_args.rpcs, token.address, owner, tx_args.raindex_address) + .await?; let allowance_float = Float::from_fixed_decimal(allowance, decimals)?; - if !allowance_float.eq(*deposit_amount)? { + if !allowance_float.eq(deposit_amount)? { let calldata = approveCall { spender: tx_args.raindex_address, amount: deposit_amount.to_fixed_decimal(decimals)?, @@ -271,7 +309,7 @@ impl RaindexOrderBuilder { .abi_encode(); calldatas.push(ApprovalCalldata { - token: *token_address, + token: token.address, calldata: Bytes::copy_from_slice(&calldata), }); } @@ -335,7 +373,9 @@ impl RaindexOrderBuilder { ) -> Result { let deployment = self.prepare_calldata_generation(CalldataFunction::Deposit)?; - let vaults_and_deposits = self.get_vaults_and_deposits(&deployment).await?; + let vaults_and_deposits = self + .get_vault_backed_vaults_and_deposits(&deployment) + .await?; if vaults_and_deposits.is_empty() { return Ok(DepositCalldataResult::NoDeposits); } @@ -657,7 +697,7 @@ impl RaindexOrderBuilder { mod tests { use super::*; use crate::raindex_order_builder::tests::{ - initialize_builder, initialize_builder_with_select_tokens, + get_yaml, initialize_builder, initialize_builder_with_select_tokens, }; use rain_metadata::{types::dotrain::source_v1::DotrainSourceV1, RainMetaDocumentV1Item}; @@ -705,6 +745,116 @@ mod tests { } } + #[tokio::test] + async fn test_generate_deposit_calldatas_skips_vaultless_output() { + let mut builder = initialize_builder(Some("other-deployment".to_string())).await; + builder + .set_vaultless(VaultType::Output, "token1".to_string(), true) + .unwrap(); + builder + .set_deposit("token1".to_string(), "1200".to_string()) + .await + .unwrap(); + + let res = builder.generate_deposit_calldatas().await.unwrap(); + match res { + DepositCalldataResult::NoDeposits => {} + DepositCalldataResult::Calldatas(calldatas) => { + assert!(calldatas.is_empty()); + } + } + } + + #[tokio::test] + async fn test_generate_deposit_calldatas_keeps_mixed_vault_backed_outputs() { + let yaml = get_yaml() + .replace( + " deposits:\n - token: token1\n min: 0\n presets:\n - \"0\"\n fields:", + " deposits:\n - token: token1\n min: 0\n presets:\n - \"0\"\n - token: token2\n min: 0\n presets:\n - \"0\"\n fields:", + ) + .replace( + " outputs:\n - token: token1\n rainlang: some-deployer", + " outputs:\n - token: token1\n - token: token2\n vault-id: 2\n rainlang: some-deployer", + ); + let mut builder = + RaindexOrderBuilder::new_with_deployment(yaml, None, "other-deployment".to_string()) + .await + .unwrap(); + builder + .set_vaultless(VaultType::Output, "token1".to_string(), true) + .unwrap(); + builder + .set_deposit("token1".to_string(), "1200".to_string()) + .await + .unwrap(); + builder + .set_deposit("token2".to_string(), "3400".to_string()) + .await + .unwrap(); + + let res = builder.generate_deposit_calldatas().await.unwrap(); + match res { + DepositCalldataResult::Calldatas(calldatas) => { + assert_eq!(calldatas.len(), 1); + assert_eq!(calldatas[0].len(), 164); + } + DepositCalldataResult::NoDeposits => { + panic!("should keep vault-backed output deposit"); + } + } + } + + #[tokio::test] + async fn test_output_deposits_by_token_deduplicates_repeated_outputs() { + let yaml = get_yaml().replace( + " outputs:\n - token: token1\n rainlang: some-deployer", + " outputs:\n - token: token1\n - token: token1\n rainlang: some-deployer", + ); + let mut builder = + RaindexOrderBuilder::new_with_deployment(yaml, None, "other-deployment".to_string()) + .await + .unwrap(); + builder + .set_deposit("token1".to_string(), "1200".to_string()) + .await + .unwrap(); + + let deployment = builder.get_current_deployment().unwrap(); + let output_deposits = builder + .get_output_deposits_by_token(&deployment) + .await + .unwrap(); + + assert_eq!(output_deposits.len(), 1); + assert!(output_deposits.contains_key( + &Address::from_str("0xc2132d05d31c914a87c6611c10748aeb04b58e8f").unwrap() + )); + } + + #[tokio::test] + async fn test_output_deposits_by_token_ignores_input_only_deposits() { + let yaml = get_yaml().replace( + " outputs:\n - token: token1\n rainlang: some-deployer", + " outputs:\n - token: token2\n rainlang: some-deployer", + ); + let mut builder = + RaindexOrderBuilder::new_with_deployment(yaml, None, "other-deployment".to_string()) + .await + .unwrap(); + builder + .set_deposit("token1".to_string(), "1200".to_string()) + .await + .unwrap(); + + let deployment = builder.get_current_deployment().unwrap(); + let output_deposits = builder + .get_output_deposits_by_token(&deployment) + .await + .unwrap(); + + assert!(output_deposits.is_empty()); + } + #[tokio::test] async fn test_missing_select_tokens() { let mut builder = initialize_builder_with_select_tokens().await; diff --git a/packages/raindex/test/js_api/builder.test.ts b/packages/raindex/test/js_api/builder.test.ts index 8a34f7e5c3..62be56cb30 100644 --- a/packages/raindex/test/js_api/builder.test.ts +++ b/packages/raindex/test/js_api/builder.test.ts @@ -541,6 +541,16 @@ describe("Rain Raindex JS API Package Bindgen Tests - Builder", async function ( }, ] as const; + const multicallAbi = [ + { + type: "function", + name: "multicall", + inputs: [{ name: "data", type: "bytes[]" }], + outputs: [], + stateMutability: "payable", + }, + ] as const; + it("should return available deployments", async () => { const result = await RaindexOrderBuilder.getDeploymentKeys(dotrainWithBuilder); @@ -1522,6 +1532,45 @@ ${dotrain}`; ); }); + it("approves but does not deposit vaultless output amounts", async () => { + // decimal call + await mockServer + .forPost("/rpc-url") + .once() + .thenSendJsonRpcResult( + "0x0000000000000000000000000000000000000000000000000000000000000012", + ); + // allowance - 1000 * 10^18 + await mockServer + .forPost("/rpc-url") + .once() + .thenSendJsonRpcResult( + "0x00000000000000000000000000000000000000000000003635C9ADC5DEA00000", + ); + + builder.setVaultless("output", "token2", true); + await builder.setDeposit("token2", "5000"); + + const approvals = extractWasmEncodedData( + await builder.generateApprovalCalldatas( + "0x1234567890abcdef1234567890abcdef12345678", + ), + ); + + // @ts-expect-error - result is valid + assert.equal(approvals.Calldatas.length, 1); + assert.equal( + // @ts-expect-error - result is valid + approvals.Calldatas[0].token, + "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063", + ); + + const deposits = extractWasmEncodedData( + await builder.generateDepositCalldatas(), + ); + assert.equal(deposits, "NoDeposits"); + }); + it("generates deposit calldatas", async () => { await mockServer .forPost("/rpc-url") @@ -1797,7 +1846,16 @@ ${dotrainWithoutVaultIds}`; const calldata = extractWasmEncodedData( await builder.generateDepositAndAddOrderCalldatas(), ); - assert.equal(calldata.length, 3914); + const decoded = decodeFunctionData({ + abi: multicallAbi, + data: calldata as `0x${string}`, + }); + const [calls] = decoded.args as [`0x${string}`[]]; + assert.equal(calls.length, 2); + assert.equal( + calls.filter((call) => call.startsWith("0x2fbc4ba0")).length, + 1, + ); const currentDeployment = extractWasmEncodedData( diff --git a/packages/ui-components/src/lib/components/deployment/ButtonSelectOption.svelte b/packages/ui-components/src/lib/components/deployment/ButtonSelectOption.svelte index c96bfb0f82..57040c8f51 100644 --- a/packages/ui-components/src/lib/components/deployment/ButtonSelectOption.svelte +++ b/packages/ui-components/src/lib/components/deployment/ButtonSelectOption.svelte @@ -2,7 +2,7 @@ import { Button } from 'flowbite-svelte'; export let active: boolean; export let buttonText: string; - export let clickHandler: () => void; + export let clickHandler: () => void | Promise; export let dataTestId: string; diff --git a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte index 3faa11315a..9a7f7005db 100644 --- a/packages/ui-components/src/lib/components/deployment/SelectToken.svelte +++ b/packages/ui-components/src/lib/components/deployment/SelectToken.svelte @@ -11,7 +11,7 @@ import type { TokenBalance } from '$lib/types/tokenBalance'; export let token: OrderBuilderSelectTokensCfg; - export let onSelectTokenSelect: (key: string) => void; + export let onSelectTokenSelect: (key: string) => void | Promise; export let tokenBalances: Map = new Map(); let inputValue: string | null = null; @@ -32,7 +32,7 @@ tokenInfo = result.value; if (result.value.address) { inputValue = result.value.address; - onSelectTokenSelect(token.key); + await onSelectTokenSelect(token.key); } } catch { // do nothing @@ -43,7 +43,7 @@ inputValue = tokenInfo.address; } - function setMode(mode: 'dropdown' | 'custom') { + async function setMode(mode: 'dropdown' | 'custom') { selectionMode = mode; error = ''; @@ -52,7 +52,7 @@ tokenInfo = null; inputValue = ''; error = ''; - clearTokenSelection(); + await clearTokenSelection(); } } @@ -72,14 +72,14 @@ const errorMessage = (e as Error).message || 'Invalid token address.'; error = errorMessage; } finally { + await onSelectTokenSelect(token.key); checking = false; - onSelectTokenSelect(token.key); } } - function clearTokenSelection() { + async function clearTokenSelection() { builder.unsetSelectToken(token.key); - onSelectTokenSelect(token.key); + await onSelectTokenSelect(token.key); } async function getInfoForSelectedToken() {