diff --git a/crates/js_api/src/gui/mod.rs b/crates/js_api/src/gui/mod.rs index bea81611d4..58b1028d32 100644 --- a/crates/js_api/src/gui/mod.rs +++ b/crates/js_api/src/gui/mod.rs @@ -651,6 +651,8 @@ pub enum GuiError { TokenNotInSelectTokens(String), #[error("JavaScript error: {0}")] JsError(String), + #[error("Invalid U256 value: {0}")] + InvalidU256(String), #[error(transparent)] DotrainOrderError(#[from] DotrainOrderError), #[error(transparent)] @@ -744,6 +746,8 @@ impl GuiError { format!("The token '{}' is not in the list of selectable tokens defined in the YAML configuration.", token), GuiError::JsError(msg) => format!("A JavaScript error occurred: {}", msg), + GuiError::InvalidU256(value) => + format!("The value '{}' is not a valid U256 number.", value), GuiError::DotrainOrderError(err) => format!("Order configuration error in YAML: {}", err), GuiError::ParseGuiConfigSourceError(err) => @@ -901,6 +905,52 @@ gui: - key: token4 name: Token 4 description: Token 4 description + vaultless-deployment: + name: Vaultless test + description: Vaultless test + deposits: + - token: token1 + min: 0 + presets: + - "0" + fields: + - binding: binding-1 + name: Field 1 name + description: Field 1 description + presets: + - name: Preset 1 + value: "0" + - binding: binding-2 + name: Field 2 name + description: Field 2 description + min: 100 + presets: + - value: "0" + mixed-vault-deployment: + name: Mixed vault test + description: Mixed vault test + deposits: + - token: token1 + min: 0 + presets: + - "0" + - token: token2 + min: 0 + presets: + - "0" + fields: + - binding: binding-1 + name: Field 1 name + description: Field 1 description + presets: + - name: Preset 1 + value: "0" + - binding: binding-2 + name: Field 2 name + description: Field 2 description + min: 100 + presets: + - value: "0" networks: some-network: rpcs: @@ -968,6 +1018,26 @@ orders: - token: token1 deployer: some-deployer orderbook: some-orderbook + vaultless-order: + inputs: + - token: token1 + vault-id: 1 + outputs: + - token: token1 + vaultless: true + deployer: some-deployer + orderbook: some-orderbook + mixed-vault-order: + inputs: + - token: token1 + vault-id: 1 + outputs: + - token: token1 + vault-id: 1 + - token: token2 + vaultless: true + deployer: some-deployer + orderbook: some-orderbook deployments: some-deployment: scenario: some-scenario @@ -978,6 +1048,12 @@ deployments: select-token-deployment: scenario: some-scenario order: some-order + vaultless-deployment: + scenario: some-scenario.sub-scenario + order: vaultless-order + mixed-vault-deployment: + scenario: some-scenario.sub-scenario + order: mixed-vault-order --- #test-binding ! #another-binding ! @@ -1267,7 +1343,9 @@ _ _: 0 0; vec![ "some-deployment", "other-deployment", - "select-token-deployment" + "select-token-deployment", + "vaultless-deployment", + "mixed-vault-deployment" ] ); } @@ -1617,7 +1695,7 @@ _ _: 0 0; #[wasm_bindgen_test] fn test_get_deployment_details() { let deployment_details = DotrainOrderGui::get_deployment_details(get_yaml()).unwrap(); - assert_eq!(deployment_details.len(), 3); + assert_eq!(deployment_details.len(), 5); let deployment_detail = deployment_details.get("some-deployment").unwrap(); assert_eq!(deployment_detail.name, "Buy WETH with USDC on Base."); assert_eq!( diff --git a/crates/js_api/src/gui/order_operations.rs b/crates/js_api/src/gui/order_operations.rs index 6d0e3106b2..84b3a2455e 100644 --- a/crates/js_api/src/gui/order_operations.rs +++ b/crates/js_api/src/gui/order_operations.rs @@ -12,7 +12,10 @@ use rain_orderbook_bindings::{ IOrderBookV6::deposit4Call, OrderBook::multicallCall, IERC20::approveCall, }; use rain_orderbook_common::{ - add_order::AddOrderArgs, deposit::DepositArgs, erc20::ERC20, transaction::TransactionArgs, + add_order::AddOrderArgs, + deposit::{DepositArgs, DepositError}, + erc20::ERC20, + transaction::TransactionArgs, }; use std::{collections::HashMap, str::FromStr, sync::Arc}; use url::Url; @@ -65,6 +68,12 @@ pub struct IOVaultIds( ); impl_wasm_traits!(IOVaultIds); +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, Tsify)] +pub struct VaultlessApprovalAmounts( + #[tsify(type = "Map")] pub HashMap, +); +impl_wasm_traits!(VaultlessApprovalAmounts); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Tsify)] pub struct WithdrawCalldataResult(#[tsify(type = "Hex[]")] Vec); impl_wasm_traits!(WithdrawCalldataResult); @@ -278,13 +287,30 @@ impl DotrainOrderGui { /// Generates approval calldatas for tokens that need increased allowances. /// - /// Automatically checks current allowances and generates approval calldata - /// whenever the on-chain allowance differs from the planned deposit amount. + /// Automatically checks current allowances and generates approval calldata for: + /// - Deposits: whenever the on-chain allowance differs from the planned deposit amount + /// - Vaultless outputs: tokens with vault_id = 0 need approval to orderbook + /// + /// ## Parameters + /// + /// - `owner` - Wallet address that will approve the tokens + /// - `vaultlessApprovalAmounts` - Optional map of token keys to approval amounts for + /// vaultless outputs. Each key is a token key (e.g. "token1"), each value is the + /// approval amount as a string. Tokens not in the map default to infinite approval + /// (max uint256). /// /// ## Examples /// /// ```javascript + /// // With default infinite approval for all vaultless outputs /// const result = await gui.generateApprovalCalldatas(walletAddress); + /// + /// // With custom approval amounts per vaultless token key + /// const result = await gui.generateApprovalCalldatas(walletAddress, new Map([ + /// ["token1", "1000000000000000000"], + /// ["token2", "2000000000000000000"] + /// ])); + /// /// if (result.error) { /// console.error("Error:", result.error.readableMsg); /// return; @@ -308,23 +334,46 @@ impl DotrainOrderGui { &mut self, #[wasm_export(param_description = "Wallet address that will approve the tokens")] owner: String, + #[wasm_export( + js_name = "vaultlessApprovalAmounts", + param_description = "Optional map of token keys to approval amounts for vaultless outputs. Defaults to infinite (max uint256) for tokens not in the map" + )] + vaultless_approval_amounts: Option, ) -> Result { let deposits_map = self.get_deposits_as_map().await?; - if deposits_map.is_empty() { + let deployment = self.get_current_deployment()?; + + let vaultless_outputs: Vec<_> = deployment + .deployment + .order + .outputs + .iter() + .filter(|output| output.vaultless == Some(true)) + .collect(); + let vaultless_addresses: Vec
= vaultless_outputs + .iter() + .filter_map(|output| output.token.as_ref().map(|t| t.address)) + .collect(); + + if deposits_map.is_empty() && vaultless_outputs.is_empty() { return Ok(ApprovalCalldataResult::NoDeposits); } let mut calldatas = Vec::new(); + let owner_address = Address::from_str(&owner)?; + let tx_args = self.get_transaction_args()?; + let rpcs: Vec = tx_args + .rpcs + .iter() + .map(|rpc| Url::parse(rpc)) + .collect::, _>>()?; for (token_address, deposit_amount) in &deposits_map { - let tx_args = self.get_transaction_args()?; - let rpcs = tx_args - .rpcs - .iter() - .map(|rpc| Url::parse(rpc)) - .collect::, _>>()?; + if vaultless_addresses.contains(token_address) { + continue; + } - let erc20 = ERC20::new(rpcs, *token_address); + let erc20 = ERC20::new(rpcs.clone(), *token_address); let decimals = erc20.decimals().await?; let deposit_args = DepositArgs { @@ -335,9 +384,9 @@ impl DotrainOrderGui { }; let token_allowance = self.check_allowance(&deposit_args, &owner).await?; - let allowance_float = Float::from_fixed_decimal(token_allowance.allowance, decimals)?; + let deposit_amount_fixed = deposit_amount.to_fixed_decimal(decimals)?; - if !allowance_float.eq(*deposit_amount)? { + if token_allowance.allowance < deposit_amount_fixed { let calldata = approveCall { spender: tx_args.orderbook_address, amount: deposit_amount.to_fixed_decimal(decimals)?, @@ -351,6 +400,38 @@ impl DotrainOrderGui { } } + for output in vaultless_outputs { + let token = output.token.as_ref().ok_or(GuiError::SelectTokensNotSet)?; + + let vaultless_amount = match &vaultless_approval_amounts { + Some(amounts) => match amounts.0.get(&token.key) { + Some(amount) => { + U256::from_str(amount).map_err(|_| GuiError::InvalidU256(amount.clone()))? + } + None => U256::MAX, + }, + None => U256::MAX, + }; + + let erc20 = ERC20::new(rpcs.clone(), token.address); + let current_allowance = erc20 + .allowance(owner_address, tx_args.orderbook_address) + .await?; + + if current_allowance < vaultless_amount { + let calldata = approveCall { + spender: tx_args.orderbook_address, + amount: vaultless_amount, + } + .abi_encode(); + + calldatas.push(ApprovalCalldata { + token: token.address, + calldata: Bytes::copy_from_slice(&calldata), + }); + } + } + Ok(ApprovalCalldataResult::Calldatas(calldatas)) } @@ -420,6 +501,10 @@ impl DotrainOrderGui { continue; } + if order_io.vaultless == Some(true) { + continue; + } + let token = order_io .token .as_ref() @@ -447,10 +532,11 @@ impl DotrainOrderGui { vault_id: vault_id.into(), decimals, }; - let calldata = deposit4Call::try_from(deposit_args) - .map_err(rain_orderbook_common::deposit::DepositError::from)? - .abi_encode(); - calldatas.push(Bytes::copy_from_slice(&calldata)); + match deposit4Call::try_from(deposit_args) { + Ok(call) => calldatas.push(Bytes::copy_from_slice(&call.abi_encode())), + Err(DepositError::InvalidVaultIdZero) => continue, + Err(e) => return Err(e.into()), + } } Ok(DepositCalldataResult::Calldatas(calldatas)) @@ -687,6 +773,12 @@ impl DotrainOrderGui { /// an order: approval calldatas, the main deployment transaction, and metadata. /// Use this for full transaction orchestration. /// + /// # Parameters + /// + /// - `owner` - Wallet address that will deploy the order + /// - `vaultlessApprovalAmounts` - Optional map of token keys to approval amounts for + /// vaultless outputs. Tokens not in the map default to infinite approval (max uint256). + /// /// # Transaction Package /// /// - `approvals` - Token approval calldatas with symbols for UI @@ -697,7 +789,15 @@ impl DotrainOrderGui { /// # Examples /// /// ```javascript + /// // With default infinite approval for all vaultless outputs /// const result = await gui.getDeploymentTransactionArgs(walletAddress); + /// + /// // With custom approval amounts per vaultless token key + /// const result = await gui.getDeploymentTransactionArgs(walletAddress, new Map([ + /// ["token1", "1000000000000000000"], + /// ["token2", "2000000000000000000"] + /// ])); + /// /// if (result.error) { /// console.error("Error:", result.error.readableMsg); /// return; @@ -724,11 +824,18 @@ impl DotrainOrderGui { &mut self, #[wasm_export(param_description = "Wallet address that will deploy the order")] owner: String, + #[wasm_export( + js_name = "vaultlessApprovalAmounts", + param_description = "Optional map of token keys to approval amounts for vaultless outputs. Defaults to infinite (max uint256) for tokens not in the map" + )] + vaultless_approval_amounts: Option, ) -> Result { let deployment = self.prepare_calldata_generation(CalldataFunction::DepositAndAddOrder)?; let mut approvals = Vec::new(); - let approval_calldata = self.generate_approval_calldatas(owner).await?; + let approval_calldata = self + .generate_approval_calldatas(owner, vaultless_approval_amounts) + .await?; if let ApprovalCalldataResult::Calldatas(calldatas) = approval_calldata { let mut output_token_infos = HashMap::new(); for output in deployment.deployment.order.outputs.clone() { @@ -970,4 +1077,298 @@ mod tests { assert_eq!(deployment.deployment.scenario.bindings["binding-1"], "100"); assert_eq!(deployment.deployment.scenario.bindings["binding-2"], "200"); } + + #[wasm_bindgen_test] + async fn test_generate_deposit_calldatas_skips_vaultless() { + let mut gui = initialize_gui(Some("vaultless-deployment".to_string())).await; + + gui.set_deposit("token1".to_string(), "1000".to_string()) + .await + .unwrap(); + + let res = gui.generate_deposit_calldatas().await.unwrap(); + + match res { + DepositCalldataResult::Calldatas(calldatas) => { + assert!(calldatas.is_empty(), "vaultless outputs should be skipped"); + } + DepositCalldataResult::NoDeposits => {} + } + } + + #[wasm_bindgen_test] + async fn test_generate_deposit_calldatas_mixed_vaulted_vaultless() { + let mut gui = initialize_gui(Some("mixed-vault-deployment".to_string())).await; + + gui.set_deposit("token1".to_string(), "1000".to_string()) + .await + .unwrap(); + gui.set_deposit("token2".to_string(), "2000".to_string()) + .await + .unwrap(); + + let res = gui.generate_deposit_calldatas().await.unwrap(); + + match res { + DepositCalldataResult::Calldatas(calldatas) => { + assert_eq!( + calldatas.len(), + 1, + "only vaulted output (token1) should generate calldata" + ); + } + DepositCalldataResult::NoDeposits => { + panic!("should have one deposit for vaulted output"); + } + } + } + + #[wasm_bindgen_test] + async fn test_get_vault_ids_vaultless() { + let gui = initialize_gui(Some("vaultless-deployment".to_string())).await; + let res = gui.get_vault_ids().unwrap(); + assert_eq!(res.0["input"]["token1"], Some(U256::from(1))); + assert_eq!(res.0["output"]["token1"], None); + } + + #[wasm_bindgen_test] + async fn test_get_vault_ids_mixed() { + let gui = initialize_gui(Some("mixed-vault-deployment".to_string())).await; + let res = gui.get_vault_ids().unwrap(); + assert_eq!(res.0["input"]["token1"], Some(U256::from(1))); + assert_eq!(res.0["output"]["token1"], Some(U256::from(1))); + assert_eq!(res.0["output"]["token2"], None); + } + + #[cfg(not(target_family = "wasm"))] + mod non_wasm_tests { + use super::super::*; + use alloy::sol_types::SolCall; + use httpmock::MockServer; + use rain_orderbook_app_settings::spec_version::SpecVersion; + use rain_orderbook_bindings::IERC20::approveCall; + use serde_json::json; + + const TEST_YAML_VAULTLESS: &str = r#" +version: {spec_version} +gui: + name: Vaultless approval test + description: Test vaultless approval generation + deployments: + vaultless-deployment: + name: Vaultless test + description: Vaultless test + deposits: + - token: token1 + min: 0 + presets: + - "0" + fields: + - binding: binding-1 + name: Field 1 name + description: Field 1 description + presets: + - name: Preset 1 + value: "0" + - binding: binding-2 + name: Field 2 name + description: Field 2 description + min: 100 + presets: + - value: "0" +networks: + some-network: + rpcs: + - {rpc_url} + chain-id: 123 + network-id: 123 + currency: ETH +subgraphs: + some-sg: https://www.some-sg.com +metaboards: + test: https://metaboard.com +deployers: + some-deployer: + network: some-network + address: 0xF14E09601A47552De6aBd3A0B165607FaFd2B5Ba +orderbooks: + some-orderbook: + address: 0xc95A5f8eFe14d7a20BD2E5BAFEC4E71f8Ce0B9A6 + network: some-network + subgraph: some-sg + deployment-block: 12345 +tokens: + token1: + network: some-network + address: 0xc2132d05d31c914a87c6611c10748aeb04b58e8f + decimals: 6 + label: Token 1 + symbol: T1 +scenarios: + some-scenario: + deployer: some-deployer + bindings: + test-binding: 5 + scenarios: + sub-scenario: + bindings: + another-binding: 300 +orders: + vaultless-order: + inputs: + - token: token1 + vault-id: 1 + outputs: + - token: token1 + vaultless: true + deployer: some-deployer + orderbook: some-orderbook +deployments: + vaultless-deployment: + scenario: some-scenario.sub-scenario + order: vaultless-order +--- +#test-binding ! +#another-binding ! +#calculate-io +_ _: 0 0; +#handle-io +:; +#handle-add-order +:; +"#; + + #[tokio::test] + async fn test_generate_approval_calldatas_vaultless_default_infinite() { + let server = MockServer::start_async().await; + let yaml = TEST_YAML_VAULTLESS + .replace("{rpc_url}", &server.url("/rpc")) + .replace("{spec_version}", &SpecVersion::current().to_string()); + + server.mock(|when, then| { + when.method("POST").path("/rpc").body_contains("dd62ed3e"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000000", + })); + }); + + let mut gui = DotrainOrderGui::new_with_deployment( + yaml.to_string(), + "vaultless-deployment".to_string(), + None, + ) + .await + .unwrap(); + + let res = gui + .generate_approval_calldatas(Address::random().to_string(), None) + .await + .unwrap(); + + match res { + ApprovalCalldataResult::Calldatas(calldatas) => { + assert_eq!(calldatas.len(), 1); + let decoded = + approveCall::abi_decode(&calldatas[0].calldata[4..], true).unwrap(); + assert_eq!(decoded.amount, U256::MAX); + } + ApprovalCalldataResult::NoDeposits => { + panic!("expected calldatas for vaultless output"); + } + } + } + + #[tokio::test] + async fn test_generate_approval_calldatas_vaultless_custom_amount() { + let server = MockServer::start_async().await; + let yaml = TEST_YAML_VAULTLESS + .replace("{rpc_url}", &server.url("/rpc")) + .replace("{spec_version}", &SpecVersion::current().to_string()); + + server.mock(|when, then| { + when.method("POST").path("/rpc").body_contains("dd62ed3e"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000000", + })); + }); + + let mut gui = DotrainOrderGui::new_with_deployment( + yaml.to_string(), + "vaultless-deployment".to_string(), + None, + ) + .await + .unwrap(); + + let custom_amount = U256::from(1000000000000000000u128); + let mut amounts = HashMap::new(); + amounts.insert("token1".to_string(), custom_amount.to_string()); + + let res = gui + .generate_approval_calldatas( + Address::random().to_string(), + Some(VaultlessApprovalAmounts(amounts)), + ) + .await + .unwrap(); + + match res { + ApprovalCalldataResult::Calldatas(calldatas) => { + assert_eq!(calldatas.len(), 1); + let decoded = + approveCall::abi_decode(&calldatas[0].calldata[4..], true).unwrap(); + assert_eq!(decoded.amount, custom_amount); + } + ApprovalCalldataResult::NoDeposits => { + panic!("expected calldatas for vaultless output"); + } + } + } + + #[tokio::test] + async fn test_generate_approval_calldatas_vaultless_already_approved() { + let server = MockServer::start_async().await; + let yaml = TEST_YAML_VAULTLESS + .replace("{rpc_url}", &server.url("/rpc")) + .replace("{spec_version}", &SpecVersion::current().to_string()); + + server.mock(|when, then| { + when.method("POST").path("/rpc").body_contains("dd62ed3e"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + })); + }); + + let mut gui = DotrainOrderGui::new_with_deployment( + yaml.to_string(), + "vaultless-deployment".to_string(), + None, + ) + .await + .unwrap(); + + let res = gui + .generate_approval_calldatas(Address::random().to_string(), None) + .await + .unwrap(); + + match res { + ApprovalCalldataResult::Calldatas(calldatas) => { + assert!( + calldatas.is_empty(), + "no approval needed when already at max" + ); + } + ApprovalCalldataResult::NoDeposits => { + panic!("expected empty calldatas, not NoDeposits"); + } + } + } + } } diff --git a/crates/js_api/src/gui/state_management.rs b/crates/js_api/src/gui/state_management.rs index 537f8f6a00..9e49b9bd8e 100644 --- a/crates/js_api/src/gui/state_management.rs +++ b/crates/js_api/src/gui/state_management.rs @@ -413,8 +413,6 @@ mod tests { use rain_orderbook_app_settings::order::VaultType; use wasm_bindgen_test::wasm_bindgen_test; - const SERIALIZED_STATE: &str = "H4sIAAAAAAAA_21QTWvCQBDN2tJS6EkKPRX6A7ok2dSPCF6MgqBIDlGMF9F1MZp1N8QVFf-EP1misxGDc5j3Zt_bmWFKxi0-AOcrsViJJbYNHS-AtmUVTQTBg2XkTJM3QCVjJpxn3Z47H6tPqLZyw7Bgai_TWP_7AYyUShqmySWd8UhuVaNu1StmmlC8S_kpc6AsIz26E3S_gJb_R4dzIaEyegc5yHb4ddCrrnsDp2Tc42FXOx9guy4qqiRXiev-Aa22xwOCQz_0PH-CCUv9Nd3Von0rkQEPqex5cX-6psEwHDa_9SUYZ1Tha1O8YAmXxw0T6gLtkqFGyAEAAA=="; - #[wasm_bindgen_test] async fn test_serialize_state() { let mut gui = initialize_gui_with_select_tokens().await; @@ -449,12 +447,43 @@ mod tests { let state = gui.serialize_state().unwrap(); assert!(!state.is_empty()); - assert_eq!(state, SERIALIZED_STATE); } #[wasm_bindgen_test] async fn test_new_from_state() { - let gui = DotrainOrderGui::new_from_state(get_yaml(), SERIALIZED_STATE.to_string(), None) + let mut gui = initialize_gui_with_select_tokens().await; + + gui.add_record_to_yaml( + "token3".to_string(), + "some-network".to_string(), + "0x1234567890123456789012345678901234567890".to_string(), + "18".to_string(), + "Token 3".to_string(), + "TKN3".to_string(), + ); + gui.set_deposit("token3".to_string(), "100".to_string()) + .await + .unwrap(); + gui.set_field_value("binding-1".to_string(), "100".to_string()) + .unwrap(); + gui.set_field_value("binding-2".to_string(), "0".to_string()) + .unwrap(); + gui.set_vault_id( + VaultType::Input, + "token1".to_string(), + Some("199".to_string()), + ) + .unwrap(); + gui.set_vault_id( + VaultType::Output, + "token2".to_string(), + Some("299".to_string()), + ) + .unwrap(); + + let serialized_state = gui.serialize_state().unwrap(); + + let gui = DotrainOrderGui::new_from_state(get_yaml(), serialized_state.to_string(), None) .await .unwrap(); @@ -489,19 +518,19 @@ mod tests { #[wasm_bindgen_test] async fn test_new_from_state_invalid_dotrain() { - let dotrain = r#" + let gui = initialize_gui_with_select_tokens().await; + let serialized_state = gui.serialize_state().unwrap(); + + let different_dotrain = r#" dotrain: name: Test description: Test "#; - let err = DotrainOrderGui::new_from_state( - dotrain.to_string(), - SERIALIZED_STATE.to_string(), - None, - ) - .await - .unwrap_err(); + let err = + DotrainOrderGui::new_from_state(different_dotrain.to_string(), serialized_state, None) + .await + .unwrap_err(); assert_eq!(err.to_string(), GuiError::DotrainMismatch.to_string()); assert_eq!( err.to_readable_msg(), diff --git a/packages/orderbook/README.md b/packages/orderbook/README.md index 987d1e0d86..38d42027e9 100644 --- a/packages/orderbook/README.md +++ b/packages/orderbook/README.md @@ -10,6 +10,7 @@ Raindex is an **onchain orderbook contract** that enables users to deploy comple - **Dynamic Orders**: Unlike traditional orderbooks, Raindex orders contain algorithms that determine token movements based on real-time conditions - **Vault System**: Users deposit tokens into vaults (virtual accounts) instead of using token approvals +- **Vaultless Mode**: Alternatively, orders can use `vaultless: true` to draw funds directly from the user's wallet via approvals - **Multi-Token Strategies**: Orders can reference multiple input/output vaults for sophisticated trading scenarios - **Perpetual Execution**: Strategies remain active until explicitly removed by the owner - **Decentralized Execution**: Third-party fillers execute trades by capitalizing on arbitrage opportunities @@ -459,9 +460,18 @@ const allowances = allowancesResult.value; const approvalCalldatasResult = await gui.generateApprovalCalldatas('0xOwner'); if (approvalCalldatasResult.error) throw new Error(approvalCalldatasResult.error.readableMsg); +// For vaultless outputs, you can optionally specify custom approval amounts per token. +// By default, vaultless outputs use infinite approval (max uint256). +// const vaultlessApprovalAmounts = new Map([['output-token', '1000000000000000000']]); +// const approvalCalldatasResult = await gui.generateApprovalCalldatas('0xOwner', vaultlessApprovalAmounts); + const depositCalldatasResult = await gui.generateDepositCalldatas(); if (depositCalldatasResult.error) throw new Error(depositCalldatasResult.error.readableMsg); +// For vaultless outputs, pass an optional Map of token keys to custom approval amounts. +// Omit or pass undefined for infinite approval (max uint256) on vaultless tokens. +// const vaultlessApprovalAmounts = new Map([['output-token', '1000000000000000000']]); +// const deploymentArgsResult = await gui.getDeploymentTransactionArgs('0xOwner', vaultlessApprovalAmounts); const deploymentArgsResult = await gui.getDeploymentTransactionArgs('0xOwner'); if (deploymentArgsResult.error) throw new Error(deploymentArgsResult.error.readableMsg); const { approvals, deploymentCalldata, orderbookAddress, chainId } = deploymentArgsResult.value; diff --git a/packages/orderbook/test/js_api/gui.test.ts b/packages/orderbook/test/js_api/gui.test.ts index 0fc70c15aa..c23404cce1 100644 --- a/packages/orderbook/test/js_api/gui.test.ts +++ b/packages/orderbook/test/js_api/gui.test.ts @@ -462,6 +462,106 @@ ${guiConfig} ${dotrain} `; +const dotrainWithVaultless = ` +version: 5 +networks: + some-network: + rpcs: + - http://localhost:8085/rpc-url + chain-id: 123 + network-id: 123 + currency: ETH + +subgraphs: + some-sg: https://www.some-sg.com +metaboards: + test: https://metaboard.com + +deployers: + some-deployer: + network: some-network + address: 0xF14E09601A47552De6aBd3A0B165607FaFd2B5Ba + +orderbooks: + some-orderbook: + address: 0xc95A5f8eFe14d7a20BD2E5BAFEC4E71f8Ce0B9A6 + network: some-network + subgraph: some-sg + deployment-block: 12345 + +tokens: + token1: + network: some-network + address: 0xc2132d05d31c914a87c6611c10748aeb04b58e8f + decimals: 6 + label: Token 1 + symbol: T1 + token2: + network: some-network + address: 0x8f3cf7ad23cd3cadbd9735aff958023239c6a063 + decimals: 18 + label: Token 2 + symbol: T2 + +scenarios: + some-scenario: + deployer: some-deployer + bindings: + test-binding: 5 + +orders: + vaultless-order: + inputs: + - token: token1 + vault-id: 1 + outputs: + - token: token2 + vaultless: true + deployer: some-deployer + orderbook: some-orderbook + +deployments: + vaultless-deployment: + scenario: some-scenario + order: vaultless-order +--- +#test-binding ! +#calculate-io +_ _: 0 0; +#handle-io +:; +#handle-add-order +:; +`; + +const guiConfigVaultless = ` +gui: + name: Vaultless test + description: Vaultless test + deployments: + vaultless-deployment: + name: Vaultless deployment + description: Vaultless deployment + deposits: + - token: token2 + min: 0 + presets: + - "0" + fields: + - binding: test-binding + name: Test binding + description: Test binding + presets: + - value: "10" + default: "10" +`; + +const dotrainWithVaultlessGui = ` +${guiConfigVaultless} + +${dotrainWithVaultless} +`; + describe('Rain Orderbook JS API Package Bindgen Tests - Gui', async function () { const mockServer = getLocal(); beforeAll(async () => { @@ -1230,7 +1330,7 @@ ${dotrain}`; assert.equal(emptyResult, 'NoDeposits'); }); - it('overwrites approvals when allowance is higher than deposit', async () => { + it('skips approval when allowance is higher than deposit', async () => { // decimal call await mockServer .forPost('/rpc-url') @@ -1253,15 +1353,64 @@ ${dotrain}`; await gui.generateApprovalCalldatas('0x1234567890abcdef1234567890abcdef12345678') ); + // @ts-expect-error - result is valid + assert.equal(result.Calldatas.length, 0); + }); + + it('generates approval when allowance is zero', async () => { + // decimal call + await mockServer + .forPost('/rpc-url') + .once() + .thenSendJsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000012' + ); + // allowance - 0 + await mockServer + .forPost('/rpc-url') + .once() + .thenSendJsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ); + + gui.unsetDeposit('token1'); + await gui.setDeposit('token2', '1000'); + + const result = extractWasmEncodedData( + await gui.generateApprovalCalldatas('0x1234567890abcdef1234567890abcdef12345678') + ); + // @ts-expect-error - result is valid assert.equal(result.Calldatas.length, 1); // @ts-expect-error - result is valid assert.equal(result.Calldatas[0].token, '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063'); - assert.equal( - // @ts-expect-error - result is valid - result.Calldatas[0].calldata, - '0x095ea7b3000000000000000000000000c95a5f8efe14d7a20bd2e5bafec4e71f8ce0b9a600000000000000000000000000000000000000000000006c6b935b8bbd400000' + }); + + it('skips approval when allowance equals deposit', async () => { + // decimal call + await mockServer + .forPost('/rpc-url') + .once() + .thenSendJsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000012' + ); + // allowance - 1000 * 10^18 + await mockServer + .forPost('/rpc-url') + .once() + .thenSendJsonRpcResult( + '0x00000000000000000000000000000000000000000000003635c9adc5dea00000' + ); + + gui.unsetDeposit('token1'); + await gui.setDeposit('token2', '1000'); + + const result = extractWasmEncodedData( + await gui.generateApprovalCalldatas('0x1234567890abcdef1234567890abcdef12345678') ); + + // @ts-expect-error - result is valid + assert.equal(result.Calldatas.length, 0); }); it('generates deposit calldatas', async () => { @@ -2285,4 +2434,103 @@ ${dotrainWithoutVaultIds}`; assert.ok(gui.getCurrentDeployment()); }); }); + + describe('vaultless order operations', () => { + let vaultlessGui: DotrainOrderGui; + + beforeEach(async () => { + mockServer.reset(); + vaultlessGui = extractWasmEncodedData( + await DotrainOrderGui.newWithDeployment(dotrainWithVaultlessGui, 'vaultless-deployment') + ); + }); + + it('generates approval calldatas for vaultless outputs with infinite approval', async () => { + await mockServer + .forPost('/rpc-url') + .withBodyIncluding('0xdd62ed3e') + .thenSendJsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ); + + const result = extractWasmEncodedData( + await vaultlessGui.generateApprovalCalldatas('0x1234567890abcdef1234567890abcdef12345678') + ); + + // @ts-expect-error - result is valid + assert.equal(result.Calldatas.length, 1); + // @ts-expect-error - result is valid + assert.equal(result.Calldatas[0].token, '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063'); + // @ts-expect-error - result is valid + expect(result.Calldatas[0].calldata).toContain( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + }); + + it('generates approval calldatas for vaultless outputs with custom amount', async () => { + await mockServer + .forPost('/rpc-url') + .withBodyIncluding('0xdd62ed3e') + .thenSendJsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ); + + const vaultlessApprovalAmounts = new Map([['token2', '1000000000000000000']]); + + const result = extractWasmEncodedData( + await vaultlessGui.generateApprovalCalldatas( + '0x1234567890abcdef1234567890abcdef12345678', + vaultlessApprovalAmounts + ) + ); + + // @ts-expect-error - result is valid + assert.equal(result.Calldatas.length, 1); + // @ts-expect-error - result is valid + expect(result.Calldatas[0].calldata).toContain('0de0b6b3a7640000'); + }); + + it('skips approval for vaultless outputs when already at max', async () => { + await mockServer + .forPost('/rpc-url') + .withBodyIncluding('0xdd62ed3e') + .thenSendJsonRpcResult( + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + + const result = extractWasmEncodedData( + await vaultlessGui.generateApprovalCalldatas('0x1234567890abcdef1234567890abcdef12345678') + ); + + // @ts-expect-error - result is valid + assert.equal(result.Calldatas.length, 0); + }); + + it('skips deposits for vaultless outputs', async () => { + await mockServer + .forPost('/rpc-url') + .withBodyIncluding('0x82ad56cb') + .thenSendJsonRpcResult( + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000754656b656e203200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000025432000000000000000000000000000000000000000000000000000000000000' + ); + + await vaultlessGui.setDeposit('token2', '1000'); + + const result = extractWasmEncodedData( + await vaultlessGui.generateDepositCalldatas() + ); + + // @ts-expect-error - result is valid + assert.equal(result.Calldatas.length, 0); + }); + + it('returns vault_id as undefined for vaultless outputs in getVaultIds', () => { + const vaultIds = extractWasmEncodedData>>( + vaultlessGui.getVaultIds() + ); + + assert.equal(vaultIds.get('input')?.get('token1'), '0x1'); + assert.equal(vaultIds.get('output')?.get('token2'), undefined); + }); + }); });