Skip to content

silent payments: add sending support - #64

Merged
rustaceanrob merged 2 commits into
kernel-node:masterfrom
pzafonte:sp-send-to-address
Jun 18, 2026
Merged

silent payments: add sending support#64
rustaceanrob merged 2 commits into
kernel-node:masterfrom
pzafonte:sp-send-to-address

Conversation

@pzafonte

Copy link
Copy Markdown
Contributor

Adds a sendToAddress RPC and CLI command that take a silent payment address, an amount, and a fee rate, then build, sign, and broadcast the transaction.

Notes:

  • Coins chosen for a spend are reserved until the broadcast succeeds.
  • Coin selection uses bdk_coin_select. It runs branch and bound to minimize the fee and falls back to a largest first pass when no solution is found.
  • Inputs and outputs are shuffled so the change output is not identifiable by position.

@yancyribbens

Copy link
Copy Markdown
Contributor

Coin selection uses bdk_coin_select. It runs branch and bound to minimize the fee and falls back to a largest first pass when no solution is found.

Any particular reason to use bdk_coin_select instead of bitcoin_coin_select? FWIW bitcoin_coin_select has an interface select_coins which mirros that of bitcoin core. That is, run Branch and Bound first, then, run Single Random Draw if BnB finds no solution. Also, I maintain the 0.7.x branch which uses rust-bitcoin 0.32.x as a dependency to maintain compatibility.

@pzafonte

Copy link
Copy Markdown
Contributor Author

Any particular reason to use bdk_coin_select instead of bitcoin_coin_select?

I was considering some beneficial aspects of bdk-tx and bdk-sp that might be useful to this project later on, but are not immediately useful in this PR, so there would be potential alignment in the future. Beyond that, I didn't do any comparison between coin selection libraries.

@yancyribbens

Copy link
Copy Markdown
Contributor

I was considering some beneficial aspects of bdk-tx and bdk-sp that might be useful to this project later on, but are not immediately useful in this PR, so there would be potential alignment in the future. Beyond that, I didn't do any comparison between coin selection libraries.

I see. Well I admit the 0.7.x version is pretty hacky compared to more recent versions. Although, I could try drafting a PR to use bitcoin-coin-selection at some point if you're open to it. Also, there is the option to use coin-grinder as well with bitcoin-coin-selction which can do nifty optimizations dependent on fee environment for cost savings.

@rustaceanrob

Copy link
Copy Markdown
Contributor

Is there any reason to not upstream algos like coingrinder to bdk_coin_select? As much as possible I would like to use the most well reviewed and used crates in the ecosystem. When using BDK crates, we get all of the benefits when their users report bugs and add features to the crates at no cost to us, which is why I would opt to use bdk_coin_select. As Peter already mentioned as well, the integration with bdk-tx and hopefully bdk-sp will be tightly coupled.

@yancyribbens

Copy link
Copy Markdown
Contributor

Is there any reason to not upstream algos like coingrinder to bdk_coin_select

Well, when I started work on the first coin-selection lib, there was no such thing as BDK.

As much as possible I would like to use the most well reviewed and used crates in the ecosystem

I would argue that bitcoin-coin-selction in some ways is better reviewed than bdk-coin-select. Murch has been kind enough to review my BnB, SRD and Coin-Grinder implementations over the years (and other areas as well).

When using BDK crates, we get all of the benefits when their users report bugs and add features to the crates at no cost to us, which is why I would opt to use bdk_coin_select

For sure, BDK is a big project and popular framework (in bitcoin land anyway), especially if you just want results fast. I've worked for a few startups in the past that used BDK, and for them it made sense because they just wanted to get stuff off the ground, quick.

As Peter already mentioned as well, the integration with bdk-tx and hopefully bdk-sp will be tightly coupled.

For sure. I'm not super familiar with bdk-tx nor bdk-sp.. Understandably keeping within the bdk ecosystem may have some ease of use benefits. Although, an open source project like this could use more specialized tooling than one might use if running a startup.

@yancyribbens

Copy link
Copy Markdown
Contributor

Is there any reason to not upstream algos like coingrinder to bdk_coin_select

And I guess lastly I'd add that I upstream improvements back to bitcoin-core instead, which there have been a few here and there.

Comment thread crates/wallet/src/silentpayments/sending.rs Outdated
Comment thread crates/wallet/src/silentpayments/sending.rs Outdated
@pzafonte
pzafonte force-pushed the sp-send-to-address branch from 967e3c8 to 9fd92f7 Compare June 12, 2026 19:55
@pzafonte

Copy link
Copy Markdown
Contributor Author

Pushed an update, folded into the two commits:

  • build_transaction now takes any standard bitcoin address.
  • The largest first sort moved into the branch and bound fallback.
  • nLockTime set to the chain tip for anti fee sniping

Comment thread capnp/wallet.capnp Outdated
}

fn cs_feerate(fee_rate: FeeRate) -> bdk_coin_select::FeeRate {
bdk_coin_select::FeeRate::from_sat_per_wu(fee_rate.to_sat_per_kwu() as f32 / 1000.0)

@rustaceanrob rustaceanrob Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What version of bitcoin are they on? Is this not the bitcoin::FeeRate type?

Not your fault, but this reads horrible and looks prone to a fee-high footgun.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are on 0.32. I've also noticed they have been slow to use Bitcoin types. In someways that has been a frustration for me (using bitcoin types) since things like Amount don't perform well compared to native types. I've spent a lot of time in optimizations so that the boundary points are Bitcoin types but internally sometimes revert to more primitive types when performance matters. The other annoying thing is bdk select has no bench-marking or fuzz testing either.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding this right, it looks like they use their own separate FeeRate (not a rust-bitcoin type): https://github.com/bitcoindevkit/coin-select/blob/master/src/feerate.rs

@pzafonte
pzafonte force-pushed the sp-send-to-address branch from 9fd92f7 to ae5409c Compare June 13, 2026 20:13
Comment thread capnp/wallet.capnp Outdated
let spendable: Vec<&SpendableCoin> = coins.iter().collect();
let candidates: Vec<Candidate> = spendable
.iter()
.map(|c| Candidate::new_tr_keyspend(c.coin.value.to_sat()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've read about Silent payments, the receiver is always a taproot output. However, I believe the sender can use any output type to send, right? Why then is every candidate output using tr_keyspending (assuming that tr is Tap Root here). In effect, I think you are telling the selection algo that all inputs are taproot inputs, but I don't think this is necessarily correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is true in general but for this wallet the coins being spent here are exclusively silent payment outputs received by scanning, so every coin in this wallet is new_tr_keyspend, but if the wallet later holds other input types the candidate weight would need to vary per input.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Good to know, that does make the spend case in this wallet easier to handle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also verify that this is default taproot sighash size vs non-default. It's only a byte difference, but if the wallet is for some reason creating non-default, that could add up in the case where lots of inputs are used.

bdk_coin_select::FeeRate::from_sat_per_wu(fee_rate.to_sat_per_kwu() as f32 / 1000.0)
}

fn output_weight(script_len: usize) -> u64 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity and type safety, you might consider returning a Weight type here: https://github.com/rust-bitcoin/rust-bitcoin/blob/master/units/src/weight.rs. A u64 usually indicates you're returning something measured in vB (virtual bytes), although you are multiplying by 4, so I think this is actually a Weight Unit type.


fn output_weight(script_len: usize) -> u64 {
// 8-byte value, 1-byte length prefix, then the script, times 4 weight units per byte.
((8 + 1 + script_len) * 4) as u64

@yancyribbens yancyribbens Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as looks ok here, but generally it can be sketchy to use as since you may truncate bytes and the program would silently continue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Weight::from_wu_usize will naturally resolve this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from_vb is what you actually want I believe (as commented bellow)

fee: TargetFee::from_feerate(feerate),
outputs: TargetOutputs::fund_outputs([(recipient_weight, amount.to_sat())]),
};
let change_policy = ChangePolicy::min_value(DrainWeights::TR_KEYSPEND, change_dust.to_sat());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused by the use of change_dust here. Generally, the ChangePolicy (see also cost_of_change in bitcoin core) is the cost to create a change output. The reason this matters is that the bnb solution can be exceeded by at most cost_of_change before the solution needs to worry about what to do with the excess. If a solution is found that is greater than the target and less than target + cost to produce a change output, then the solution is considered acceptable since there is not enough leftover to worry creating a change output. If target + cost of change is exceeded, then a different method can be used, like SRD. Instead, you are using change_dust and I'm wondering if this was intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, my intention was just to avoid creating dust change outputs but it looks like I can use ChangePolicy::min_value_and_waste

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can use ChangePolicy::min_value_and_waste

here is what the README.md says about that min_value_and_waste.

// We use a change policy that introduces a change output if doing so reduces
// the "waste" (i.e. adding change doesn't increase the fees we'd pay if we factor in the cost to spend the output later on).

That does sound like the correct behavior, since the ultimate goal of coin-selection is to reduce the waste metric. Murch wrote a nice blog post about this: https://murch.one/posts/waste-metric/.

BTW, the waste metric to work "properly" needs to know the correct long_term_fee_rate. Currently, it looks like you have that hard coded as: const LONG_TERM_FEERATE_SAT_PER_VB: f32 = 1.0;. So, that will give you wildly inaccurate results in some cases because the algorithm will most likely always think it's in a high fee rate environment when in fact it may not be. Hopefully that all makes sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does makes sense. Setting aside an implementation / integration of a fee estimator for a follow up, perhaps it then makes sense to do long_term_fee_rate = current_fee_rate so the waste metric is neutral rather than an assumed future.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah if it's not handy to find long_term_fee_rate now, some note to followup would be great. In the very least maybe in the commit message.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any yes, long_term_fee_rate = current_fee_rate would at least be better than hardcoded 1.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be best to just use a selection process that doesn't require knowledge of long_term_fee_rate. If the goal is just to get something out there that works, SRD (single random draw) would be simple, fast and doesn't require knowledge of cost_of_change nor long_term_fee_rate. Then at some later point BnB and/or coin-grinder could be added.

Recipient::SilentPayment(sp) => sp_output_script(&derived, sp)?,
};
let mut output = vec![TxOut {
value: amount,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If im not mistaken, you are creating a change output that is the size of the transaction target amount. The function build_transaction takes a variable amount, and I don't see that amount be changed, then it's used to create a change output. This should either be a changeless transaction in which case there is no change output, or the change should be the excess of what was found by the selection algorithm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/be changed/is changed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the recipient output, not the change. The first output pays the recipient, so it's amount:

let mut output = vec![TxOut {
    value: amount,
    script_pubkey: recipient_script,
}];

The change is the next output, with value change_value:

if let Some(value) = change_value {
    output.push(TxOut {
        value,
        script_pubkey: sp_output_script(&derived, change_address)?,
    });
}

change_value is the drain value from the selector:
let change_value = drain.is_some().then(|| Amount::from_sat(drain.value));

which comes from selector.drain:
let drain = selector.drain(target, change_policy);

So when drain is None, change_value is None and no change output is added, which is the changeless case. When drain is Some, the change output value is drain.value, the leftover from selection, not amount.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the recipient output, not the change. The first output pays the recipient, so it's amount:

Ah got it. Thanks, that makes sense.

@yancyribbens yancyribbens Jun 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yes, that all tracks with me as well. You'll have a changeless solution, which has only the amount sent to the recipient. Then in the case of a change, there will be a second output that goes back to sender. The address that goes back to sender looks like what's provided here sp_output_script(&derived, sp) sp_output_script(&derived, change_address)? and the recipient goes to the scriptpub key here address.script_pubkey()?

@pzafonte pzafonte Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The recipient is address.script_pubkey() for a plain address, or sp_output_script(&derived, sp) for a silent payment one.

@pzafonte
pzafonte force-pushed the sp-send-to-address branch from ae5409c to 71cfe92 Compare June 16, 2026 15:47

fn output_weight(script_len: usize) -> Weight {
// 8-byte value, 1-byte length prefix, then the script, times 4 weight units per byte.
Weight::from_wu_usize((8 + 1 + script_len) * 4)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably not depend on from_wu_usize mostly because it is not available in units https://github.com/rust-bitcoin/rust-bitcoin/blob/master/units/src/weight.rs. Hopefully units 1.0 is around the corner.

Anyway, assuming this is only for 32 and 64 bit architectures, id probably do something like

    let vb = u64::try_from(8 + 1 + script_len).unwrap();
    Weight::from_vb_unchecked(vb)

There should probably be some policy that says we don't guarantee no panic paths if someone is using an arch with more than 64 bits.

@rustaceanrob

Copy link
Copy Markdown
Contributor

Were you able to test that this works? I tried broadcasting a transaction and the CLI returns, but looking the logs (in DEBUG), I'm not seeing any reject messages, yet mempool.space is not picking it up and I haven't been included in a block.

It would be nice to have some transaction validation prior to broadcast so we know the broadcast code is buggy. Something like testmempoolaccept.

@rustaceanrob

rustaceanrob commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Also probably good for people to know, you can get signet sats here. (please send them back once we figure this out)

@pzafonte

Copy link
Copy Markdown
Contributor Author

Were you able to test that this works?

The testing I did prior was pushing a transaction through broadcast-raw-tx on a live signet node, and the log confirmed it connected to a peer and sent the tx. I am testing propagation now with a valid transaction and checking mempool.space.

It would be nice to have some transaction validation prior to broadcast so we know the broadcast code is buggy. Something like testmempoolaccept.

Good call, for right now I will add a check that verifies each signature against the output it spends before broadcast.

@pzafonte

pzafonte commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

What I'm finding is that broadcast_transaction opens a connection to a peer, sends the transaction, but closes the connection immediately. send_message writes the message to the socket, so closing right away tares down the connection before the peer is able to read, validate, and relay the transaction, and so it gets dropped before reaching the mempool.

Faucet payment into the wallet, received and scanned, block 309134:
https://mempool.space/signet/tx/9f506b3e4b5e32caa32665f422aecbd33f06707d1ca431079e8b7f152b71c45f

silently dropped on the immediate disconnect and then propagated once the connection was held open, block 309194:
https://mempool.space/signet/tx/79c6b5eb46516fabf467838fdb5d76918199b1884e81838540bd3ada635b836f

same but with a hack-y "fix" I was testing, propagated and mined in block 309195:
https://mempool.space/signet/tx/b4d6acf43c833696a13c5e363375bd460c83b10580bf9115ed3d417edb8ccb7f

@rustaceanrob

Copy link
Copy Markdown
Contributor

same but with a hack-y "fix" I was testing, propagated and mined in block 309195

What was the fix? I think we can do something like send a ping and wait for the pong. This is done in private broadcast to my knowledge.

@pzafonte

Copy link
Copy Markdown
Contributor Author

same but with a hack-y "fix" I was testing, propagated and mined in block 309195

What was the fix? I think we can do something like send a ping and wait for the pong. This is done in private broadcast to my knowledge.

I'm holding the connection open for a few seconds after sending so the peer can process and relay:
pzafonte@da901d1

Seems ping-pong might accomplish the same thing but in a more signal-driven way.

Two larger improvements that are not immediately pertinent, but are worth tracking separately 1) announcing the transaction before sending it or 2) relaying over the connection the node already keeps open. I can do a short write up in #51

@rustaceanrob

Copy link
Copy Markdown
Contributor

1/ is a nice-to-have but technically not required and I don't think would help in this problem 2/ was deliberately avoided. I plan on adding a Socks5 proxy so we can connect to peers over Tor, however we will likely want to do IBD without Tor, so the current design is flexible to broadcast transactions privately without slowing down IBD.

@pzafonte

Copy link
Copy Markdown
Contributor Author

Makes sense on both. I started thinking along the lines of "how long do I keep the socket open."

I implemented the ping-pong approach you described in #66 .

Tested on signet, the spend propagated and confirmed in block 309302:
https://mempool.space/signet/tx/06c111cd5f26ce92e6adff1a5329c053

@rustaceanrob

Copy link
Copy Markdown
Contributor

Can be rebased on #66

pzafonte added 2 commits June 18, 2026 11:25
Spending a received output needs its private key, the spend secret plus
the tweak recorded when the coin was scanned.

Store the spend secret, normalised to the even Y form the receiver
derives against, so the spend secret plus the stored tweak reconstructs
each coin key. Add build_transaction, which selects coins, builds the
recipient and change outputs, and signs each input as a taproot key
spend. The recipient may be a silent payment address or a normal bitcoin
address. The transaction is locked to the current chain tip to
discourage fee sniping. Coins chosen for a spend are reserved so a later
spend cannot reuse them.

Coin selection uses bdk_coin_select. The transaction is assembled and
signed with the bitcoin crate.
Add a sendToAddress RPC and matching CLI command that take a recipient
address, an amount, and a fee rate, build the transaction, broadcast it,
and return the txid. The recipient may be a silent payment address or a
normal bitcoin address.

Keep the spend secret at startup when the keys file carries one so the
running wallet can sign. A file holding only the public spend key stays
watch only and the RPC reports that it cannot sign.

The broadcast worker releases the reserved coins when delivery fails, so
a transaction that never reached a peer does not lock its inputs until
the node restarts.
@pzafonte
pzafonte force-pushed the sp-send-to-address branch from 71cfe92 to b2c35e4 Compare June 18, 2026 17:43
@rustaceanrob

Copy link
Copy Markdown
Contributor

Nice: https://mempool.space/signet/tx/9ec8598fd29b364f322eb78dc54ac79b47c01de622293b82f233362307ecb609

@pzafonte

Copy link
Copy Markdown
Contributor Author

For reference:

  • As suggested, I changed coin selection to single random draw, which greatly simplifies things for the time being, and changed weight conversion to from_vb_unchecked.
  • Each input's signature is now verified against the output it spends before the transaction is returned, which partially addresses pre-broadcast validation by catching faulty signing but not whether the transaction is otherwise valid in other ways.

@pzafonte

Copy link
Copy Markdown
Contributor Author

Yes, also tested it: https://mempool.space/signet/tx/002ea86e3a01d394f040c4dbf58ddd0f525cbe1257559d6aeb85cc1e3c2a6b34

Will send signet sats back. :)

@rustaceanrob rustaceanrob left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to continue moving things along. As a follow up we can:

  • Implement RBF, this a big one because locked coins can be annoying
  • Make the coin selection more robust.
  • Add roundtrip tests with Bitcoin Core to make sure we're all square

Great work @pzafonte!

@rustaceanrob
rustaceanrob merged commit cd9ce27 into kernel-node:master Jun 18, 2026
2 checks passed
@yancyribbens

Copy link
Copy Markdown
Contributor

As suggested, I changed coin selection to single random draw

It doesn't look like that's the case. Also the LONG_TERM_FEE_RATE is still hard coded as 1.0.

@pzafonte

pzafonte commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Oh shoot, that push didn't go through. I pushed up on the same branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants