feat(api): tx fund api endpoint (#3100)

This commit is contained in:
Petar Radovic
2026-07-08 07:46:31 +02:00
committed by GitHub
parent 663ad6996e
commit 8669d471c9
11 changed files with 440 additions and 5 deletions
+27
View File
@@ -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
+35
View File
@@ -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<HeaderId>,
pub tx_builder: MantleTxBuilder,
pub change_public_key: ZkPublicKey,
pub funding_public_keys: Vec<ZkPublicKey>,
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<OpProof>,
}
}
pub mod sign {
use lb_core::mantle::TxHash;
use lb_key_management_system_keys::keys::{
+1
View File
@@ -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 {
+4
View File
@@ -345,6 +345,10 @@ where
paths::wallet::SIGN_TX_ZK,
routing::post(wallet::sign_tx_zk::<WalletService, MempoolStorageAdapter, _>),
)
.route(
paths::wallet::FUND,
routing::post(wallet::fund::<WalletService, MempoolStorageAdapter, _>),
)
.route(
paths::admin::TRACING_FILTER,
routing::put(reload_tracing_filter::<RuntimeServiceId>),
+106 -4
View File
@@ -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<WalletService, StorageAdapter, RuntimeServiceId>(
State(handle): State<OverwatchHandle<RuntimeServiceId>>,
Json(req): Json<WalletFundRequestBody>,
) -> Response
where
WalletService: WalletServiceData,
StorageAdapter: lb_tx_service::storage::MempoolStorageAdapter<
RuntimeServiceId,
Item = SignedMantleTx,
Key = <SignedMantleTx as Transaction>::Hash,
> + Send
+ Sync
+ Clone
+ 'static,
StorageAdapter::Error: Debug,
RuntimeServiceId: Debug
+ Display
+ Send
+ Sync
+ 'static
+ AsServiceId<WalletService>
+ AsServiceId<
TxMempoolService<
MempoolNetworkAdapter<
SignedMantleTx,
<SignedMantleTx as Transaction>::Hash,
RuntimeServiceId,
>,
Mempool<
HeaderId,
SignedMantleTx,
<SignedMantleTx as Transaction>::Hash,
StorageAdapter,
RuntimeServiceId,
>,
StorageAdapter,
RuntimeServiceId,
>,
>,
{
make_request_and_return_response!(async {
let wallet = WalletApi::<WalletService, RuntimeServiceId>::new(
handle.relay::<WalletService>().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<ZkPublicKey> = 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)]
+1
View File
@@ -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)),
+19 -1
View File
@@ -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<WalletFundResponseBody, Error> {
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(
@@ -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
+1
View File
@@ -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";
+206
View File
@@ -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<WalletFundResponseBody, StepError> {
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),
})
}
@@ -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<WalletFundResponseBody, Error> {
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<Vec<Declaration>, Error> {
self.get_sdp_declarations_at(self.base_url.clone()).await
}