diff --git a/core/src/mantle/transactions/builder.rs b/core/src/mantle/transactions/builder.rs index 544cdcfdb..20bb29ff3 100644 --- a/core/src/mantle/transactions/builder.rs +++ b/core/src/mantle/transactions/builder.rs @@ -309,6 +309,33 @@ mod tests { sdp::{DeclarationId, Locator, ProviderId, ServiceType}, }; + #[test] + fn serde_round_trip() { + // The builder crosses the HTTP boundary (e.g. the wallet fund + // endpoint), so a serialized builder must deserialize back to the + // same transaction. + let builder = MantleTxBuilder::new() + .push_op(Op::ChannelInscribe(InscriptionOp { + channel_id: [0; 32].into(), + inscription: b"hello".into(), + parent: [1; 32].into(), + signer: Ed25519Key::from_bytes(&[0; 32]).public_key(), + })) + .unwrap() + .add_ledger_input(Utxo::new([0u8; 32], 0, Note::new(50, ZkPublicKey::zero()))) + .unwrap() + .add_ledger_output(Note::new(40, ZkPublicKey::zero())) + .unwrap(); + + let json = serde_json::to_string(&builder).expect("builder should serialize"); + let restored: MantleTxBuilder = + serde_json::from_str(&json).expect("builder should deserialize"); + + assert_eq!(restored.net_balance(), builder.net_balance()); + assert_eq!(restored.ledger_inputs(), builder.ledger_inputs()); + assert_eq!(restored.build().unwrap(), builder.build().unwrap()); + } + #[test] fn inscription_op() { // Build an operation diff --git a/nodes/api-common/src/bodies/wallet.rs b/nodes/api-common/src/bodies/wallet.rs index 3ac0207c6..56761f20b 100644 --- a/nodes/api-common/src/bodies/wallet.rs +++ b/nodes/api-common/src/bodies/wallet.rs @@ -143,6 +143,41 @@ pub mod transfer_funds { } } +pub mod fund { + use lb_core::{ + header::HeaderId, + mantle::{ + OpProof, + gas::GasCost, + transactions::{MantleTx, builder::MantleTxBuilder}, + }, + }; + use lb_key_management_system_keys::keys::ZkPublicKey; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize)] + pub struct WalletFundRequestBody { + pub tip: Option, + pub tx_builder: MantleTxBuilder, + pub change_public_key: ZkPublicKey, + pub funding_public_keys: Vec, + pub max_tx_fee: GasCost, + } + + #[derive(Serialize, Deserialize)] + pub struct WalletFundResponseBody { + /// Tip the transaction was funded against. + pub tip: HeaderId, + /// The funded transaction, with the fee transfer appended as the last + /// op. All ops are still unsigned. + pub funded_tx: MantleTx, + /// Proof for the appended fee transfer, signed over the funded + /// transaction hash. `None` if funding required no transfer (zero + /// fee and no inputs pulled in). + pub transfer_proof: Option, + } +} + pub mod sign { use lb_core::mantle::TxHash; use lb_key_management_system_keys::keys::{ diff --git a/nodes/api-common/src/paths.rs b/nodes/api-common/src/paths.rs index e41c9cb69..a87c52428 100644 --- a/nodes/api-common/src/paths.rs +++ b/nodes/api-common/src/paths.rs @@ -34,6 +34,7 @@ pub mod wallet { pub const TRANSACTIONS_TRANSFER_FUNDS: &str = "/wallet/transactions/transfer-funds"; pub const SIGN_TX_ED25519: &str = "/wallet/sign/ed25519"; pub const SIGN_TX_ZK: &str = "/wallet/sign/zk"; + pub const FUND: &str = "/wallet/fund"; } pub mod admin { diff --git a/nodes/node/binary/src/api/backend.rs b/nodes/node/binary/src/api/backend.rs index a886f9df5..547df1bca 100644 --- a/nodes/node/binary/src/api/backend.rs +++ b/nodes/node/binary/src/api/backend.rs @@ -345,6 +345,10 @@ where paths::wallet::SIGN_TX_ZK, routing::post(wallet::sign_tx_zk::), ) + .route( + paths::wallet::FUND, + routing::post(wallet::fund::), + ) .route( paths::admin::TRACING_FILTER, routing::put(reload_tracing_filter::), diff --git a/nodes/node/binary/src/api/handlers.rs b/nodes/node/binary/src/api/handlers.rs index 160f52cde..7451d76ec 100644 --- a/nodes/node/binary/src/api/handlers.rs +++ b/nodes/node/binary/src/api/handlers.rs @@ -26,7 +26,7 @@ use lb_core::{ events::Events, header::HeaderId, mantle::{ - Op, SignedMantleTx, Transaction, TxHash, ops::channel::ChannelId, + Op, OpProof, SignedMantleTx, Transaction, TxHash, ops::channel::ChannelId, transactions::MantleTxBuilder, }, }; @@ -1568,9 +1568,12 @@ where } pub mod wallet { - use lb_http_api_common::bodies::wallet::sign::{ - WalletSignTxEd25519RequestBody, WalletSignTxEd25519ResponseBody, WalletSignTxZkRequestBody, - WalletSignTxZkResponseBody, + use lb_http_api_common::bodies::wallet::{ + fund::{WalletFundRequestBody, WalletFundResponseBody}, + sign::{ + WalletSignTxEd25519RequestBody, WalletSignTxEd25519ResponseBody, + WalletSignTxZkRequestBody, WalletSignTxZkResponseBody, + }, }; use lb_key_management_system_service::keys::ZkPublicKey; @@ -1875,6 +1878,105 @@ pub mod wallet { Ok::<_, DynError>(WalletSignTxZkResponseBody { sig }) }) } + + #[utoipa::path( + post, + path = paths::wallet::FUND, + responses( + (status = 200, description = "Funded transaction with fee transfer proof"), + (status = 500, description = "Internal server error", body = String), + ) + )] + pub async fn fund( + State(handle): State>, + Json(req): Json, + ) -> Response + where + WalletService: WalletServiceData, + StorageAdapter: lb_tx_service::storage::MempoolStorageAdapter< + RuntimeServiceId, + Item = SignedMantleTx, + Key = ::Hash, + > + Send + + Sync + + Clone + + 'static, + StorageAdapter::Error: Debug, + RuntimeServiceId: Debug + + Display + + Send + + Sync + + 'static + + AsServiceId + + AsServiceId< + TxMempoolService< + MempoolNetworkAdapter< + SignedMantleTx, + ::Hash, + RuntimeServiceId, + >, + Mempool< + HeaderId, + SignedMantleTx, + ::Hash, + StorageAdapter, + RuntimeServiceId, + >, + StorageAdapter, + RuntimeServiceId, + >, + >, + { + make_request_and_return_response!(async { + let wallet = WalletApi::::new( + handle.relay::().await?, + ); + + let lb_wallet_service::TipResponse { + tip, + response: funded_tx_builder, + } = wallet + .fund_tx( + req.tip, + req.tx_builder, + req.change_public_key, + req.funding_public_keys, + ) + .await?; + + let tx_fee = funded_tx_builder.tx_fee()?; + if tx_fee > req.max_tx_fee { + return Err(overwatch::DynError::from(format!( + "tx_fee({tx_fee}) exceeds max_tx_fee({})", + req.max_tx_fee + ))); + } + + // Owners of the funding inputs, in input order — the ledger + // verifies the transfer proof against this exact list. + let funding_note_pks: Vec = funded_tx_builder + .ledger_inputs() + .iter() + .map(|utxo| utxo.note.pk) + .collect(); + + let funded_tx = funded_tx_builder.build()?; + let transfer_proof = if funding_note_pks.is_empty() { + None + } else { + let tx_hash = funded_tx.hash(); + Some(OpProof::ZkSig( + wallet.sign_tx_with_zk(tx_hash, funding_note_pks).await?, + )) + }; + + Ok::<_, DynError>(WalletFundResponseBody { + tip, + funded_tx, + transfer_proof, + }) + }) + } } #[cfg(test)] diff --git a/nodes/node/binary/src/api/openapi.rs b/nodes/node/binary/src/api/openapi.rs index bdd8bb3fa..b519ae5db 100644 --- a/nodes/node/binary/src/api/openapi.rs +++ b/nodes/node/binary/src/api/openapi.rs @@ -31,6 +31,7 @@ use utoipa::OpenApi; crate::api::handlers::wallet::get_claimable_vouchers, crate::api::handlers::get_gas_prices, crate::api::handlers::wallet::post_transactions_transfer_funds, + crate::api::handlers::wallet::fund, crate::api::tracing::reload_tracing_filter, ), components(schemas(schema::Status, schema::MempoolMetrics)), diff --git a/nodes/node/http-client/src/lib.rs b/nodes/node/http-client/src/lib.rs index 4191c1236..b575a9bb8 100644 --- a/nodes/node/http-client/src/lib.rs +++ b/nodes/node/http-client/src/lib.rs @@ -21,6 +21,7 @@ use lb_http_api_common::{ wallet::{ balance::WalletBalanceResponseBody, claimable_vouchers::WalletClaimableVouchersResponseBody, + fund::{WalletFundRequestBody, WalletFundResponseBody}, transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody}, }, }, @@ -28,7 +29,7 @@ use lb_http_api_common::{ BLEND_JOIN_NETWORK, BLOCK_EVENTS, BLOCKS, BLOCKS_DETAIL, BLOCKS_RANGE_STREAM, BLOCKS_STREAM, CHANNEL, CRYPTARCHIA_INFO, CRYPTARCHIA_LIB_STREAM, LEADER_CLAIM_VOUCHERS, MANTLE_GAS_PRICES, MEMPOOL_ADD_TX, SDP_POST_DECLARATION, TIME_INFO, - wallet::{BALANCE, TRANSACTIONS_TRANSFER_FUNDS}, + wallet::{BALANCE, FUND, TRANSACTIONS_TRANSFER_FUNDS}, }, queries::BlocksStreamQuery, settings::default_max_body_size, @@ -582,6 +583,23 @@ impl CommonHttpClient { self.post(request_url, &body).await } + /// Post a request to fund a transaction from the node's wallet. + /// + /// The node adds fee inputs and change from its own wallet, signs only + /// the appended fee transfer, and returns the funded (still unsigned) + /// transaction together with the transfer proof. + pub async fn fund_tx( + &self, + base_url: Url, + body: WalletFundRequestBody, + ) -> Result { + let request_url = base_url + .join(FUND.trim_start_matches('/')) + .map_err(Error::Url)?; + + self.post(request_url, &body).await + } + /// Post a request via an SDP declaration to join the blend network and /// returns its declaration ID if successful. pub async fn join_blend_network( diff --git a/tests/cucumber_tests/features/fees.feature b/tests/cucumber_tests/features/fees.feature index 6efe171c4..b68b1b794 100644 --- a/tests/cucumber_tests/features/fees.feature +++ b/tests/cucumber_tests/features/fees.feature @@ -12,3 +12,31 @@ Feature: Fees When node "NODE_1" is at height 1 in 180 seconds Then gas prices on node "NODE_1" equal the genesis gas prices Then I stop all nodes + + @transactions_ci + Scenario: Wallet fund endpoint funds a payment and returns a transfer proof + Given the genesis block has the following wallet resources: + | account_index | token_count | token_amount | + | 1 | 1 | 1000 | + And I have a cluster with capacity of 1 nodes + And I start nodes with wallet resources: + | node_name | account_index | wallet_name | connected_to | + | NODE_1 | 1 | WALLET_1A | | + When node "NODE_1" is at height 2 in 240 seconds + And I fund a transaction paying 10 LGO from node "NODE_1" wallet to wallet "WALLET_1A" as "FUNDED_PAYMENT" + Then transaction "FUNDED_PAYMENT" is included on node "NODE_1" in 120 seconds + Then I stop all nodes + + @transactions_ci + Scenario: Wallet fund endpoint leaves a feeless transaction unchanged + Given the genesis block has the following wallet resources: + | account_index | token_count | token_amount | + | 1 | 1 | 1000 | + And I have a cluster with capacity of 1 nodes + And I start nodes with wallet resources: + | node_name | account_index | wallet_name | connected_to | + | NODE_1 | 1 | WALLET_1A | | + When node "NODE_1" is at height 2 in 240 seconds + And I fund an inscription transaction on node "NODE_1" as "FUNDED_INSCRIPTION" + Then transaction "FUNDED_INSCRIPTION" is included on node "NODE_1" in 120 seconds + Then I stop all nodes diff --git a/tests/src/cucumber/steps/mod.rs b/tests/src/cucumber/steps/mod.rs index a7f7be65e..f5969816d 100644 --- a/tests/src/cucumber/steps/mod.rs +++ b/tests/src/cucumber/steps/mod.rs @@ -9,5 +9,6 @@ pub mod manual_mempool; pub mod manual_nodes; pub mod manual_transactions; pub mod manual_zone; +pub mod wallet_fund; const TARGET: &str = "cucumber_steps"; diff --git a/tests/src/cucumber/steps/wallet_fund.rs b/tests/src/cucumber/steps/wallet_fund.rs new file mode 100644 index 000000000..92b5df8ce --- /dev/null +++ b/tests/src/cucumber/steps/wallet_fund.rs @@ -0,0 +1,206 @@ +//! Steps exercising the node's `/wallet/fund` HTTP endpoint: fund a +//! transaction from the node's wallet, assemble the returned proofs and +//! submit the result to the mempool. + +use cucumber::{gherkin::Step, when}; +use lb_core::mantle::{ + Note, Op, OpProof, SignedMantleTx, Transaction as _, + gas::GasCost, + ops::channel::{ + ChannelId, MsgId, + inscribe::{Inscription, InscriptionOp}, + }, + transactions::builder::MantleTxBuilder, +}; +use lb_http_api_common::bodies::wallet::fund::{WalletFundRequestBody, WalletFundResponseBody}; +use lb_key_management_system_service::keys::{Ed25519Key, ZkPublicKey}; +use lb_testing_framework::NodeHttpClient; +use tracing::info; + +use crate::cucumber::{ + error::{StepError, StepResult}, + steps::TARGET, + world::CucumberWorld, +}; + +/// Fund a payment transaction from the node's wallet: the fund endpoint must +/// pull wallet inputs to cover the output, append the fee transfer and return +/// a transfer proof that the ledger accepts. +#[when( + expr = "I fund a transaction paying {int} LGO from node {string} wallet to wallet {string} as {string}" +)] +async fn step_fund_payment_transaction( + world: &mut CucumberWorld, + step: &Step, + amount: u64, + node_name: String, + receiver_wallet_name: String, + transaction_alias: String, +) -> StepResult { + let receiver_pk = world.resolve_wallet(&receiver_wallet_name)?.public_key()?; + let funding_wallet = world.resolve_wallet(&format!("{node_name}_WALLET"))?; + let funding_pk = funding_wallet.public_key()?; + let client = world.resolve_node_http_client(&node_name)?; + + let tx_builder = MantleTxBuilder::new() + .add_ledger_output(Note::new(amount, receiver_pk)) + .map_err(|source| StepError::LogicalError { + message: format!( + "Step `{}` error: failed to add output: {source}", + step.value + ), + })?; + + let response = fund_via_node(&client, step, tx_builder, funding_pk).await?; + + let Some(transfer_proof) = response.transfer_proof else { + return Err(StepError::LogicalError { + message: format!( + "Step `{}` error: funding a payment must return a transfer proof", + step.value + ), + }); + }; + let has_transfer = response + .funded_tx + .ops() + .iter() + .any(|op| matches!(op, Op::Transfer(_))); + if !has_transfer { + return Err(StepError::LogicalError { + message: format!( + "Step `{}` error: funded payment must contain a transfer op", + step.value + ), + }); + } + + let signed_tx = + SignedMantleTx::new(response.funded_tx, vec![transfer_proof]).map_err(|source| { + StepError::LogicalError { + message: format!( + "Step `{}` error: assembling the funded transaction failed: {source:?}", + step.value + ), + } + })?; + let tx_hash = signed_tx.hash(); + + world + .submit_transaction(&funding_wallet, &signed_tx, &client) + .await?; + world.remember_submitted_transaction(transaction_alias.clone(), tx_hash); + + info!( + target: TARGET, + "Submitted funded payment `{transaction_alias}` of {amount} LGO from node `{node_name}` wallet" + ); + + Ok(()) +} + +/// Fund an inscription-only transaction: with zero gas prices the transaction +/// owes no fee, so funding must be a no-op — no transfer appended and no +/// proof returned — and the caller signs the channel op over the funded hash. +#[when(expr = "I fund an inscription transaction on node {string} as {string}")] +async fn step_fund_inscription_transaction( + world: &mut CucumberWorld, + step: &Step, + node_name: String, + transaction_alias: String, +) -> StepResult { + let funding_wallet = world.resolve_wallet(&format!("{node_name}_WALLET"))?; + let funding_pk = funding_wallet.public_key()?; + let client = world.resolve_node_http_client(&node_name)?; + + // Deterministic sequencer key claiming a fresh channel; every scenario + // runs on a fresh chain, so the channel cannot pre-exist. + let signing_key = Ed25519Key::from_bytes(&[7u8; 32]); + let channel_id = ChannelId::from(signing_key.public_key().to_bytes()); + let inscription = + Inscription::try_from(b"wallet fund endpoint".to_vec()).map_err(|source| { + StepError::LogicalError { + message: format!( + "Step `{}` error: failed to build inscription: {source}", + step.value + ), + } + })?; + let inscription_op = InscriptionOp { + channel_id, + inscription, + parent: MsgId::root(), + signer: signing_key.public_key(), + }; + + let tx_builder = MantleTxBuilder::new() + .push_op(Op::ChannelInscribe(inscription_op)) + .map_err(|source| StepError::LogicalError { + message: format!( + "Step `{}` error: failed to push inscription op: {source}", + step.value + ), + })?; + + let response = fund_via_node(&client, step, tx_builder, funding_pk).await?; + + if response.transfer_proof.is_some() { + return Err(StepError::LogicalError { + message: format!( + "Step `{}` error: feeless transaction must not get a transfer proof", + step.value + ), + }); + } + if response.funded_tx.ops().len() != 1 { + return Err(StepError::LogicalError { + message: format!( + "Step `{}` error: feeless transaction must keep its single op, got {}", + step.value, + response.funded_tx.ops().len() + ), + }); + } + + let tx_hash = response.funded_tx.hash(); + let signature = signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()); + let signed_tx = SignedMantleTx::new(response.funded_tx, vec![OpProof::Ed25519Sig(signature)]) + .map_err(|source| StepError::LogicalError { + message: format!( + "Step `{}` error: assembling the funded transaction failed: {source:?}", + step.value + ), + })?; + + world + .submit_transaction(&funding_wallet, &signed_tx, &client) + .await?; + world.remember_submitted_transaction(transaction_alias.clone(), tx_hash); + + info!( + target: TARGET, + "Submitted funded inscription `{transaction_alias}` via node `{node_name}` fund endpoint" + ); + + Ok(()) +} + +async fn fund_via_node( + client: &NodeHttpClient, + step: &Step, + tx_builder: MantleTxBuilder, + funding_pk: ZkPublicKey, +) -> Result { + client + .fund_tx(WalletFundRequestBody { + tip: None, + tx_builder, + change_public_key: funding_pk, + funding_public_keys: vec![funding_pk], + max_tx_fee: GasCost::new(u64::MAX), + }) + .await + .map_err(|source| StepError::StepFail { + message: format!("Step `{}` error: fund request failed: {source}", step.value), + }) +} diff --git a/tests/testing_framework/src/node/http_client.rs b/tests/testing_framework/src/node/http_client.rs index 2680f9f56..d14d7d2d7 100644 --- a/tests/testing_framework/src/node/http_client.rs +++ b/tests/testing_framework/src/node/http_client.rs @@ -17,6 +17,7 @@ use lb_http_api_common::{ mantle::GasPricesResponseBody, wallet::{ balance::WalletBalanceResponseBody, + fund::{WalletFundRequestBody, WalletFundResponseBody}, transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody}, }, }, @@ -182,6 +183,17 @@ impl NodeHttpClient { .await } + pub async fn fund_tx( + &self, + body: WalletFundRequestBody, + ) -> Result { + self.with_timeout( + "Fund transaction request", + self.http_client.fund_tx(self.base_url.clone(), body), + ) + .await + } + pub async fn get_sdp_declarations(&self) -> Result, Error> { self.get_sdp_declarations_at(self.base_url.clone()).await }