Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: receive payjoin #445

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft

feat: receive payjoin #445

wants to merge 7 commits into from

Conversation

DanGould
Copy link

@DanGould DanGould commented Jan 7, 2024

This is my hacking around trying to copy admin mod into a payjoin server that could trigger payouts when it senses a payjoin incoming. It does not build yet.

I'm going to have to link Bria & Payjoin services because payjoin needs utxos and access to a wallet. I think it makes sense for bria to be a dependency of the payjoin service that hosts the endpoint rather than vice-versa and I'll be digging into it to find out.

Totally possible I'm going down the wrong path, but I figure I've got to start somewhere to get a server running based on our conversation in #266

@bodymindarts
Copy link
Member

Totally possible I'm going down the wrong path, but I figure I've got to start somewhere to get a server running based on our conversation in #266

I think this is the right approach - get something running, figure out the boilerplate and perhaps e2e testing and we can talk about the details of the integration later. Its always easier to move code around / refactor to the right design with a working skeleton than on a blank slate.

Copy link
Author

@DanGould DanGould left a comment

Choose a reason for hiding this comment

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

This skeleton is getting closer. It attempts to run a payjoin v1 server coupled to the bria api server. I imagine in prod you have a very specific access architecture in mind, which this prototype naturally ignores.

Since this is an early prototype it does have excessive imports, probably unnecessary clones, and dirty access patterns. Errors are handled but will definitely need to be combed over at some point.

One major confusion sticks out. Payjoin needs to be able to interface with some 'wallet' but I'm not sure which one(s) bria makes available. I'm ignorant to the profile / keychain abstraction that needs to be navigated in order to contribute utxos to payjoins. Does each blink client have a bria-custodies Profile? Or do they interface with a large counterparty Profile which would have access to all payjoin funds? Or both? If my framing sounds uncertain it is because it is.

I'm also still missing a few components of the Bria api necessary to get a skeleton working which I address directly in self review comments below

Comment on lines 41 to 55
let proposal = proposal.check_broadcast_suitability(None, |tx| {
let _raw_tx = bitcoin::consensus::encode::serialize_hex(&tx);
// TODO test_mempool_accept e.g.:
//
// let mempool_results =
// bitcoind.test_mempool_accept(&[raw_tx]).map_err(|e| Error::Server(e.into()))?;
// match mempool_results.first() {
// Some(result) => Ok(result.allowed),
// None => Err(Error::Server(
// anyhow!("No mempool results returned on broadcast check").into(),
// )),
// }
Ok(true)
})?;
tracing::trace!("check1");
Copy link
Author

Choose a reason for hiding this comment

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

What's the most straightforward way to access a testmempoolaccept function like bitcoind's?

Copy link
Author

Choose a reason for hiding this comment

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

Since bria uses Fulcrum and Fulcrum does not have such a function, we'll need to devise a new way to check mempool acceptance so that we know the original_psbt proposal is acceptable fallback collateral if the sender disappears to prevent probing attacks.

Copy link
Member

Choose a reason for hiding this comment

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

I’m okay adding a dependency to bitcoind for this as we want to eventually get rid of fulcrum anyhow.

Comment on lines 63 to 105
// Create a channel
let (tx, rx) = mpsc::channel::<(ScriptBuf, Sender<Result<bool, ApplicationError>>)>();
let tx = Arc::new(Mutex::new(tx));

let app = self.app.clone();
let profile = self.profile.clone();
let network = network.clone();

// Receive Check 2: receiver can't sign for proposal inputs
tokio::spawn(async move {
while let Ok((input, response_tx)) = rx.recv() {
let result = check_script_owned(&app, &profile, network, &input).await;
let _ = response_tx.send(result);
}
});

async fn check_script_owned(
app: &App,
profile: &Profile,
network: bitcoin::Network,
input: &Script,
) -> Result<bool, ApplicationError> {
let address = bitcoin::BdkAddress::from_script(&input, network)?;
match app
.find_address(&profile.clone(), address.to_string())
.await
{
Err(ApplicationError::AddressError(e)) => Err(ApplicationError::AddressError(e)),
Err(_) => Ok(false), // good address script, but not found
Ok(_) => Ok(true),
}
}

let proposal = proposal.check_inputs_not_owned(|input| {
let (response_tx, response_rx) = mpsc::channel::<Result<bool, ApplicationError>>();
{
let tx = tx.lock().expect("lock"); // TODO Handle lock error if needed
tx.send((input.to_owned(), response_tx))
.map_err(|e| Error::Server(e.into()))?;
}
let recv_res = response_rx.recv().map_err(|e| Error::Server(e.into()))?;
Ok(recv_res.map_err(|e| Error::Server(e.into()))?)
})?;
Copy link
Author

Choose a reason for hiding this comment

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

This hack looks ugly but is necessary with the current payjoin library since proposal.check_* functions take closures, async closures are unstable, and the payjoin app needs to make async db calls for various checks if e.g. a particular address is ours or not in this case.

Open to suggestions for improvements here in either implementation approach or upstream api.

Copy link
Author

Choose a reason for hiding this comment

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

The most straightforward solution is to block_on a tokio::runtime::Handle
Justin and I explored async_trait too which will expand the scope of the library. I'd like to avoid that for now.

        // Receive Check 2: receiver can't sign for proposal inputs
        let proposal = proposal.check_inputs_not_owned(|input| {
            self.rt.block_on(async {
                let address = bitcoin::BdkAddress::from_script(&input, network)
                    .map_err(|e| Error::Server(e.into()))?;
                match self.addresses.find_address(address.to_string()).await {
                    Ok(_) => Ok(true),
                    Err(AddressError::AddressNotFound(_)) => Ok(false),
                    Err(e) => Err(Error::Server(e.into())),
                }
            })
        })?;

Comment on lines 113 to 118
let payjoin = proposal.check_no_inputs_seen_before(|input| {
// TODO implement input_seen_before database check
// Ok(!self.insert_input_seen_before(*input).map_err(|e| Error::Server(e.into()))?)
Ok(false)
})?;
tracing::trace!("check4");
Copy link
Author

Choose a reason for hiding this comment

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

Since clients can POST payjoin requests which return proposal_psbt including bria utxos, they should be limited with this check. If an adversary were to submit many requests and never sign the proposal_psbt then they would be able to see many utxos within bria's wallets. This is mitigated with this check to only reply with bria utxos for request inputs the first time they are seen and also by broadcasting original_psbt included in the POST request after some timeout if the proposal_psbt does is not signed and broadcast.

Copy link
Author

Choose a reason for hiding this comment

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

This will require a new repo that keeps track of seen inputs

Copy link
Member

Choose a reason for hiding this comment

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

Anything Utxo related (selection, filter, tagging) should be put in the utxo module. There is already an entity for utxo.

Comment on lines 145 to 163
// Don't throw an error. Continue optimistic process even if we can't contribute inputs.
self.try_contributing_inputs(&mut provisional_payjoin)
.await
.map_err(|e| tracing::warn!("Failed to contribute inputs: {}", e));

// Output substitution could go here

let payjoin_proposal = provisional_payjoin.finalize_proposal(
|psbt: &bitcoin::psbt::Psbt| {
Err(Error::Server(anyhow!("TODO sign psbt").into()))
// TODO sign proposal psbt with our inputs & subbed outputs e.g.:
//
// bitcoind
// .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false))
// .map(|res| bitcoin::psbt::Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into())))
// .map_err(|e| Error::Server(e.into()))?
},
None, // TODO set to bitcoin::FeeRate::MIN or similar
)?;
Copy link
Author

Choose a reason for hiding this comment

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

How can the payjoin app submit a psbt for signing? I'm guessing this is where either submit_payout / trigger_payout would need to be called with the logic that a payjoin preempts an existing batch to include its contents in a payjoin. If it's preempting a batch, coin contributions will need to include those batch inputs as well.

@DanGould
Copy link
Author

DanGould commented Jan 13, 2024

One way of identifying which account an inbound payjoin is headed for would be to use a subdirectory. Rather than receiving all payjoins to a pj.galoy.com/ endpoint each account could show a bip21 including a pj.galoy.com/account_id/ endpoint for each tenant.

Of course, privacy of the account should be taken into consideration. One could also save payjoin-specific bitcoin addresses to a repo and check against those on proposal receipt, or generate linked id subdirectory that wouldn't reveal the whole account id.

@bodymindarts
Copy link
Member

One way of identifying which account an inbound payjoin is headed for would be to use a subdirectory. Rather than receiving all payjoins to a pj.galoy.com/ endpoint each account could show a bip21 including a pj.galoy.com/account_id/ endpoint for each tenant.

Of course, privacy of the account should be taken into consideration. One could also save payjoin-specific bitcoin addresses to a repo and check against those on proposal receipt, or generate linked id subdirectory that wouldn't reveal the whole account id.

Perhaps an account can have a ‘public_alias’ that can be used as a path (instead of account id).

Though we should probably discuss the merit of activating pj on account vs wallet vs address

@DanGould
Copy link
Author

DanGould commented Jan 13, 2024

payjoin v2 receivers listen to sessions identified by an authentication/encryption keypair on a subdirectory. This public alias problem sounds like one that the v2 protocol can solve. Each payjoin session would be associated with an account and saved in a repository.

@bodymindarts
Copy link
Member

payjoin v2 receivers listen to sessions identified by an authentication/encryption keypair on a subdirectory. This public alias problem sounds like one that the v2 protocol can solve. Each payjoin session would be associated with an account and saved in a repository.

Yes a PayjoinSessionRepo makes a lot of sense

@DanGould
Copy link
Author

I peeled another layer back today addressing payjoin integration with the payout_queue and related abstractions.

Once a sender's proposal is checked to be a suitable as a fallback to turn into a payjoin, we can think of it as being added to bria's mempool. This is distinct from our instance of bitcoind's mempool, since it's only in bria's section of memory, but we know it still is able to pay us if broadcast, gives us some funds via control of a foreign utxo to partially account for (since some will be paid back to the sender as change), and specifies at least one Payout, including a self-spend to one of our wallets that we may update and perhaps change that the sender has specified. It seems to me the foreign utxo may be accounted for by being already encumbered with value owed to the sender's change address as a precondition. TL;DR account for the sender's original_psbt input as our utxo rather than the original_psbt output. Either way bria should spend this utxo in the original_psbt fallback or a payjoin.

We must first account for the sender's utxo(s) we can spend and then queue the fallback transaction extracted from the original_psbt for broadcast after an interval. After accounting we may construct payouts in a payjoin psbt that are dependent on those specific foreign utxos.

Once utxo and payout accounting is set up on the fallback, we initialize a PsbtBuilderConfig that includes a new field payjoin_sender_utxos: Vec<OutPoint> (or foreign_utxos) for which we have no KeychainId. Or, perhaps we create a new KeychainId for each specific payjoin since they must be spent exclusive of other foreign payjoin inputs, since a valid response must only be missing signatures from a payjoin sender's own inputs. This new set of utxos represents neither reserved nor cpfp utxos, but those spent in the payjoin's original_psbt. By making a request, the sender effectively promised to sign a psbt containing those utxos upon receiving a valid response. The PsbtBuilderConfig's fee_rate must also consider the payjoin request's fee parameters. Perhaps the signer for payjoin_sender_utxos, the sender, can be thought of as another RemoteSigningClient valid within the context of a payjoin session. If signing fails, the batch may be cancelled and the original_psbt should be broadcast.

I believe if the Payout and utxo repositories are update appropriately according to this logic, the existing PsbtBuilder::construct_psbt and accounting might work for us without any major rework.

@bodymindarts
Copy link
Member

Okay if I understood it correctly I like the general approach. Just a note on the accounting - I would figure that bit out last. When we have the capability to receive / process / sign /broadcast a pj tx e2e it should be a lot more straightforward to see what is missing on the accounting side. I find it hard to conceive upfront what the right way to deal with it is...

So my recommendation would be to code the end-to-end process accepting that accounting will be off and then come back to that later.

Regarding adding additional capabilities to the PsbtBuilder - it would be advisable to add a test here.

@DanGould
Copy link
Author

I think my point is that there are 2 moments of accounting rather than 1. Upon request, and then mempool update. Should it be possible to add a utxo to the repository without accounting for it?

@DanGould DanGould changed the title Add Payjoin Server feat: receive payjoin Apr 3, 2024
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.

2 participants