diff --git a/CHANGELOG.md b/CHANGELOG.md index 55afb26..a78323d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ All notable changes to this project are documented here. The format follows ### Added +- `/wallet/sendbtcbegin` and `/wallet/sendbtcend` send BTC from the wallet's + vanilla side with a PSBT signed by the client; the end call returns the + broadcast `txid`. +- `/wallet/witnessreceive` creates an RGB invoice paid to a new output of the + sender's transaction, so the wallet needs no colorable UTXO up front. It + takes the same body as `/wallet/blindreceive`. - Opt-in BFA support: set `bfa_enabled = true` and `eth_rpc_url` in `[wallet]`. Wallets then accept the BFA schema and validate BFA consignments against the EVM RPC. BFA stays disabled without the opt-in. diff --git a/README.md b/README.md index ca80363..08e12fa 100644 --- a/README.md +++ b/README.md @@ -70,13 +70,16 @@ The begin/end pattern allows clients to sign PSBTs externally: | POST | `/listunspents` | List unspent outputs | | POST | `/listtransactions` | List BTC transactions | | POST | `/listtransfers` | List RGB transfers for an asset | -| POST | `/blindreceive` | Generate RGB invoice | +| POST | `/blindreceive` | Generate RGB invoice (blinded UTXO) | +| POST | `/witnessreceive` | Generate RGB invoice paid to a new output of the sender's transaction | | POST | `/issueassetnia` | Issue new NIA token | | POST | `/issueassetifa` | Issue IFA tokens and reserve inflation rights | | POST | `/inflatebegin` | Create additional IFA tokens (unsigned PSBT) | | POST | `/inflateend` | Finalize IFA inflation (signed PSBT) | | POST | `/createutxosbegin` | Create UTXOs (unsigned PSBT) | | POST | `/createutxosend` | Finalize UTXO creation (signed PSBT) | +| POST | `/sendbtcbegin` | Send BTC (unsigned PSBT) | +| POST | `/sendbtcend` | Finalize BTC send (signed PSBT) | | POST | `/sendbegin` | Send RGB token (unsigned PSBT) | | POST | `/sendend` | Finalize token send (signed PSBT) | | POST | `/burnbegin` | Burn BFA or IFA tokens (unsigned PSBT) | diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 3c8b545..a19c30d 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -659,6 +659,106 @@ paths: $ref: '#/components/responses/InternalError' '503': $ref: '#/components/responses/ServiceUnavailable' + /wallet/sendbtcbegin: + post: + summary: Send BTC Begin + description: >- + Prepare a BTC send from the wallet's vanilla side. Sign the returned + PSBT client-side and submit it to `/wallet/sendbtcend`. + operationId: send_btc_begin_wallet_sendbtcbegin_post + parameters: + - name: xpub-van + in: header + required: true + schema: + type: string + title: Xpub Van + - name: xpub-col + in: header + required: true + schema: + type: string + title: Xpub Col + - name: master-fingerprint + in: header + required: true + schema: + type: string + title: Master Fingerprint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendBtcBeginRequestModel' + responses: + '200': + description: The unsigned PSBT to be signed by the client. + content: + application/json: + schema: + $ref: '#/components/schemas/PsbtResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /wallet/sendbtcend: + post: + summary: Send BTC End + description: Finalize and broadcast a BTC send with the client-signed PSBT. + operationId: send_btc_end_wallet_sendbtcend_post + parameters: + - name: xpub-van + in: header + required: true + schema: + type: string + title: Xpub Van + - name: xpub-col + in: header + required: true + schema: + type: string + title: Xpub Col + - name: master-fingerprint + in: header + required: true + schema: + type: string + title: Master Fingerprint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendAssetEndRequestModel' + responses: + '200': + description: The broadcast transaction. + content: + application/json: + schema: + $ref: '#/components/schemas/SendBtcResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/ServiceUnavailable' /wallet/burnbegin: post: summary: Burn Begin @@ -865,6 +965,61 @@ paths: $ref: '#/components/responses/InternalError' '503': $ref: '#/components/responses/ServiceUnavailable' + /wallet/witnessreceive: + post: + summary: Generate Witness Invoice + description: >- + Like `/wallet/blindreceive`, but the invoice is paid to a new output of + the sender's transaction (a `wvout:` recipient) instead of a blinded + UTXO this wallet already holds, so no colorable UTXO is needed up front. + The sender funds that output (`witness_data.amount_sat`). + operationId: generate_witness_invoice_wallet_witnessreceive_post + parameters: + - name: xpub-van + in: header + required: true + schema: + type: string + title: Xpub Van + - name: xpub-col + in: header + required: true + schema: + type: string + title: Xpub Col + - name: master-fingerprint + in: header + required: true + schema: + type: string + title: Master Fingerprint + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RgbInvoiceRequestModel' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ReceiveData' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/ServiceUnavailable' /blindreceive: post: summary: Generate Invoice (deprecated alias) @@ -2091,6 +2246,36 @@ components: type: object title: RgbInvoiceRequestModel description: Request model for RGB invoices. + SendBtcBeginRequestModel: + properties: + address: + type: string + title: Address + description: Bitcoin address to pay. + amount: + type: integer + title: Amount + description: Amount to send, in sats. + fee_rate: + type: integer + title: Fee Rate + description: Bitcoin transaction fee rate in sat/vB. + type: object + required: + - address + - amount + - fee_rate + title: SendBtcBeginRequestModel + SendBtcResponse: + properties: + txid: + type: string + title: Txid + description: TXID of the broadcast transaction. + type: object + required: + - txid + title: SendBtcResponse SendAssetBeginRequestModel: properties: recipient_map: diff --git a/src/api/mod.rs b/src/api/mod.rs index 5c15589..2091f73 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -64,6 +64,11 @@ impl ApiService for WalletSrv { resource("/getconsignment").route(post().to(node_api::get_consignment)), ) .service(resource("/blindreceive").route(post().to(node_api::blind_receive))) + .service( + resource("/witnessreceive").route(post().to(node_api::witness_receive)), + ) + .service(resource("/sendbtcbegin").route(post().to(node_api::send_btc_begin))) + .service(resource("/sendbtcend").route(post().to(node_api::send_btc_end))) .service(resource("/failtransfers").route(post().to(node_api::fail_transfers))) .service( resource("/listtransactions").route(post().to(node_api::list_transactions)), diff --git a/src/api/node_api.rs b/src/api/node_api.rs index 6f40c26..694593b 100644 --- a/src/api/node_api.rs +++ b/src/api/node_api.rs @@ -166,6 +166,12 @@ pub struct CreateUtxosRes { pub created: usize, } +#[derive(serde::Serialize)] +pub struct SendBtcRes { + /// TXID of the broadcast transaction. + pub txid: String, +} + #[derive(serde::Serialize)] pub struct FailTransferRes { /// Whether any transfer changed to the failed status. @@ -418,10 +424,49 @@ pub async fn blind_receive( req: Json, ) -> Result, ApiError> { let ctx = derive_wctx(&ctx, wallet_key).await?; - let res = ctx.receive_token(req.0).await.map_err(map_rgb_error)?; + let res = ctx + .receive_token(ReceiveKind::Blind, req.0) + .await + .map_err(map_rgb_error)?; + Ok(Json(res)) +} + +pub async fn witness_receive( + ctx: Data, + wallet_key: XWalletKey, + req: Json, +) -> Result, ApiError> { + let ctx = derive_wctx(&ctx, wallet_key).await?; + let res = ctx + .receive_token(ReceiveKind::Witness, req.0) + .await + .map_err(map_rgb_error)?; Ok(Json(res)) } +pub async fn send_btc_begin( + ctx: Data, + wallet_key: XWalletKey, + req: Json, +) -> Result, ApiError> { + let ctx = derive_wctx(&ctx, wallet_key).await?; + let psbt = ctx.send_btc_begin(req.0).await.map_err(map_rgb_error)?; + Ok(Json(PsbtRes { psbt })) +} + +pub async fn send_btc_end( + ctx: Data, + wallet_key: XWalletKey, + req: Json, +) -> Result, ApiError> { + let ctx = derive_wctx(&ctx, wallet_key).await?; + let txid = ctx + .send_btc_end(req.0.signed_psbt) + .await + .map_err(map_rgb_error)?; + Ok(Json(SendBtcRes { txid })) +} + pub async fn refresh( ctx: Data, wallet_key: XWalletKey, @@ -471,6 +516,10 @@ mod tests { }) .unwrap(), serde_json::to_string(&CreateUtxosRes { created: 3 }).unwrap(), + serde_json::to_string(&SendBtcRes { + txid: "f00d".to_string(), + }) + .unwrap(), serde_json::to_string(&FailTransferRes { changed: true }).unwrap(), serde_json::to_string(&AddressRes { address: "bcrt1qxy".to_string(), @@ -484,6 +533,7 @@ mod tests { [ r#"{"psbt":"cHNidP8B"}"#, r#"{"created":3}"#, + r#"{"txid":"f00d"}"#, r#"{"changed":true}"#, r#"{"address":"bcrt1qxy"}"#, r#"{}"#, diff --git a/src/wallet/context.rs b/src/wallet/context.rs index 676ebd6..e02645d 100644 --- a/src/wallet/context.rs +++ b/src/wallet/context.rs @@ -196,10 +196,12 @@ impl WalletCtx { pub async fn receive_token( &self, + kind: ReceiveKind, req: ReceiveReq, ) -> Result { let (resp, recv) = oneshot::channel(); let cmd = WalletCmd::Receive { + kind, req: req.to_owned(), resp, }; diff --git a/src/wallet/models.rs b/src/wallet/models.rs index 18e3e8e..93ef8e4 100644 --- a/src/wallet/models.rs +++ b/src/wallet/models.rs @@ -56,6 +56,17 @@ fn default_duration_seconds() -> Option { Some(3600) } +/// Which kind of RGB invoice a receive creates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReceiveKind { + /// The sender assigns to a blinded UTXO this wallet already holds. + Blind, + /// The sender's transaction creates a new output paying this wallet, so + /// no colorable UTXO is needed up front. + Witness, +} + +/// `SendBtcBeginRequestModel` in the API spec. #[derive(Clone, serde::Deserialize, serde::Serialize)] pub struct SendBtcReq { pub address: String, @@ -275,3 +286,31 @@ mod burn_tests { } } } + +#[cfg(test)] +mod request_tests { + use super::*; + + #[test] + fn send_btc_requires_address_amount_and_fee_rate() { + let req: SendBtcReq = serde_json::from_value(serde_json::json!({ + "address": "bcrt1qxy", + "amount": 1000, + "fee_rate": 2, + })) + .unwrap(); + assert_eq!( + (req.address.as_str(), req.amount, req.fee_rate), + ("bcrt1qxy", 1000, 2) + ); + for missing in ["address", "amount", "fee_rate"] { + let mut body = + serde_json::json!({ "address": "bcrt1qxy", "amount": 1000, "fee_rate": 2 }); + body.as_object_mut().unwrap().remove(missing); + assert!( + serde_json::from_value::(body).is_err(), + "{missing}" + ); + } + } +} diff --git a/src/wallet/state.rs b/src/wallet/state.rs index 914badb..312ddd7 100644 --- a/src/wallet/state.rs +++ b/src/wallet/state.rs @@ -6,6 +6,7 @@ use rgb_lib::wallet::{ }; use super::Config; +use super::ReceiveKind; fn unix_now() -> u64 { std::time::SystemTime::now() @@ -70,8 +71,9 @@ impl RgbWalletState { }) } - pub fn blind_receive_token( + pub fn receive_token( &mut self, + kind: ReceiveKind, asset_id: Option, assigment: rgb_lib::Assignment, duration_seconds: Option, @@ -87,13 +89,22 @@ impl RgbWalletState { let expiration_timestamp = unix_now().saturating_add(u64::from(duration_seconds.unwrap_or(3600))); - let rd = self.wallet.blind_receive( - asset_id, - assigment, - expiration_timestamp, - transport_endpoints, - min_confirmations, - )?; + let rd = match kind { + ReceiveKind::Blind => self.wallet.blind_receive( + asset_id, + assigment, + expiration_timestamp, + transport_endpoints, + min_confirmations, + )?, + ReceiveKind::Witness => self.wallet.witness_receive( + asset_id, + assigment, + expiration_timestamp, + transport_endpoints, + min_confirmations, + )?, + }; Ok(rd) } diff --git a/src/wallet/wallet_thread.rs b/src/wallet/wallet_thread.rs index 41c98ae..5335b9b 100644 --- a/src/wallet/wallet_thread.rs +++ b/src/wallet/wallet_thread.rs @@ -73,6 +73,7 @@ pub(crate) enum WalletCmd { resp: ResultChan, }, Receive { + kind: ReceiveKind, req: ReceiveReq, resp: ResultChan, }, @@ -500,14 +501,15 @@ fn handle_wallet_cmd(wallet_state: Arc>, id: String, cmd: } } } - WalletCmd::Receive { req, resp } => { + WalletCmd::Receive { kind, req, resp } => { // A missing amount and an explicit 0 both mean "no amount // restriction": an invoice demanding exactly 0 units is useless. let assignment = match req.amount { Some(amount) if amount > 0 => rgb_lib::Assignment::Fungible(amount), _ => rgb_lib::Assignment::Any, }; - match wallet_state.lock().unwrap().blind_receive_token( + match wallet_state.lock().unwrap().receive_token( + kind, req.asset_id, assignment, req.duration_seconds, @@ -515,7 +517,7 @@ fn handle_wallet_cmd(wallet_state: Arc>, id: String, cmd: ) { Ok(val) => resp.send(Ok(val)).is_ok(), Err(err) => { - log_rgb_err!(err, "blind recieve: wid={id} error={:?}", err); + log_rgb_err!(err, "receive: wid={id} kind={kind:?} error={:?}", err); resp.send(Err(err)).is_ok() } }