Open thunderbiscuit opened 1 month ago
The above is just throwing my notes here to start he conversation. But attempting to write down what I was thinking about the new bdk_kyoto client opened up a few questions I'll leave here:
ldk_node
forces you to acknowledge events; not sure if that's too heavy-handed for us here.Unfortunately I cannot speak very well for Kotlin developers, but I can walk through how my PR on the bindings works, which of course uses this crate. This crate, as well as the bindings, prioritizes the user that simply wants to use the Wallet
struct, and I try to build my assumptions around this class of user. Other developers may reach for kyoto
directly if they require something more detailed. As such, we can and do offer an opinionated way to construct the Node
, which uses much of the information found in the Wallet
. When using the builder
module, you may pass a Wallet
reference along with your Node
configurations, and get an opinionated bdk_kyoto::Client
. This Client
may shutdown the node and broadcast transactions, as well as call a crucial update
function. This function breaks events down into two parts, which I feel is appropriate for this fast-tracked Wallet
set-up:
NodeMessageHandler
. This trait has some implementations provided by the library, but production applications will certainly use their own implementation. In my WIP branch of the BDKSwiftExampleWallet, I define one of these traits to make UI updates and print some messages:class MessageHandler: ObservableObject, NodeMessageHandler {
@Published var progress: Double = 20
@Published var height: UInt32? = nil
func blocksDisconnected(blocks: [UInt32]) {}
func connectionsMet() {}
func dialog(dialog: String) {
print(dialog)
}
func stateChanged(state: BitcoinDevKit.NodeState) {
DispatchQueue.main.async { [self] in
switch state {
case .behind:
progress = 20
case .headersSynced:
progress = 40
case .filterHeadersSynced:
progress = 60
case .filtersSynced:
progress = 80
case .transactionsSynced:
progress = 100
}
}
}
func synced(tip: UInt32) {
print("Synced to \(tip)")
}
func txFailed(txid: BitcoinDevKit.Txid) {}
func txSent(txid: BitcoinDevKit.Txid) {}
func warning(warning: BitcoinDevKit.Warning) {
switch warning {
case .notEnoughConnections:
print("Searching for connections")
case .peerTimedOut:
print("A peer timed out")
case .unsolicitedMessage:
print("A peer sent an unsolicited message")
case .couldNotConnect:
print("The node reached out to a peer and could not connect")
case .corruptedHeaders:
print("The loaded headers do not link together")
case .transactionRejected:
print("A transaction was rejected")
case .failedPersistance(warning: let warning):
print(warning)
case .evaluatingFork:
print("Evaluating a potential fork")
case .emptyPeerDatabase:
print("The peer database is empty")
case .unexpectedSyncError(warning: let warning):
print(warning)
case .noCompactFilters:
print("A connected peer does not serve compact block filters")
case .potentialStaleTip:
print("The node has not seen a new block for a long duration")
case .unlinkableAnchor:
print("The configured recovery does not link to block headers stored in the database")
}
}
}
Client
. This is so we may construct a Wallet
update. I think conforming to the FullScanResult
here is correct, as we simply want to pass a single update to the Wallet
every time we sync to the tip of the chain. In the WIP branch, I do this like so: Task {
while true {
try await bdkClient.sync(logger)
self.walletSyncState = .synced
self.getBalance()
self.getTransactions()
}
}
With this design, I think we are preserving the event-based style of API, where the user must define the NodeMessageHandler
(could use a rename perhaps). However, asking them to process the blocks themselves does not offer any clear benefit if they are just using the Wallet
. Instead, we return an update when we have it, from Client::update
. Each time we get a value here, we would want to update the UI as well.
So in summary:
Wallet
updates are constructed by Client::update
Client::update
must be called with a &dyn NodeMessageHandler
, forcing the user to respond to noncritical events the node is issuingAs far as missing events, the channel receiver is popping events off in a loop, so the only way that would happen is to have more than 32 events yet to be processed. Since most of these are printing to the console or calling a simple method, its an extremely fringe and unlikely scenario to miss anything
Thanks for the detailed response. I will implement Kyoto in the Android example wallet this week and will be able to provide more extensive feedback!
Some of those questions might pertain more to the way the API is wrapped in the bindings, not sure. Will likely post/comment on your bitcoindevkit/bdk-ffi#591 PR. For example, at first glance the LightClient.update()
method's role is to return an Update
which I assume can then be applied to the Wallet
, but it also has the role of taking in this list of callbacks that the client will use for its other class of events. This group of callbacks is provided for each iteration of the loop in the sync logic
Task {
while true {
try await bdkClient.sync(logger)
}
instead of configured once on the client (but maybe there is a reason to change the callbacks we want applied over time? In which case it might be better to receive the event as an event and apply our logic there instead of the callback).
Anyway I'm about halfway done on the Android side, will keep digging!
Yeah, good observation. We had this designed as you described before, where the callback trait that was configured once. I found it very annoying when integrating with the Swift wallet because the UI component that is responding to minor changes in state must be passed all the way down to the app initialization code. I decided it may be easier for the app developer to simply pass a UI component at the callsite. I conceptualize update
as "here is everything that happened to the wallet" and the argument passed as "here is everything that happened to the node along the way"
Also, on Wednesday and throughout the rest of the week I will be on a continental US timezone if you wanted my live feedback as you work on the Android wallet
I implemented the client in this branch of the example Android wallet. It works! The node + UI pick up the new blocks as I mine them on regtest super fast. It's snappy and super cool in comparison to the electrum workflow. 🚀
A few quick thoughts (will come back to this tomorrow).
client.udpate()
is null
. Is that related to when one of the callbacks is triggered, i.e. and event happened but no Update
is produced?Overall, super work! I love to see it in action.
kyoto
for this however. If the node is running for 30 minutes and has not found a block, then the node should issue a warning and begin looking for new peers. I think that is pretty close to what we are going for here?SocketAddr
variant, but I am going to rework this in the bindings soon. (edit: updated with a more robust Peer
type)NodeEventHandler
. I think better?Receiver<NodeMessage>
out in the wild (used within Client::update
) and one is not popping off the events (calling Receiver::recv
), then the event will remain on the stack forever. In this case, every call to update
would issue the same event over and over. That is my best guess as to what happenedQuick question. I don't understand what WalletUpdate
is doing here or what it is. The point of a CBF system is to emit blocks that are relevant against a query. So shouldn't it just be BlockMatched
and then give the height and hash of the matched block.
The point of a CBF system is to emit blocks that are relevant against a query.
Agree. The kyoto
library emits a block when determined relevant, and existing BDK structures like KeychainTxOutIndex
or Wallet
can already process these. The way I currently see this crate is basically an aggregation of the blocks into a single FullScanResult
, which is particularly useful for the bindings layer where we cannot serialize blocks at the moment. The WalletUpdate
event as described in the example is a FullScanResult
that has interpreted all blocks of relevance up to the tip of the current chain. If a user is working directly in Rust, they may find it easier to just use bdk_wallet
and kyoto
as dependencies and the workflow would be as you described.
What's the problem with serializing a block? If the problem is too much data then I guess kyoto could widdle down the block data to what's relevant I suppose. I'm guessing iit has a list of spks (etc) it's interested in. This seems like a neater idea than forcing things into FullScanResult
.
Another question: FullScanResult
would mean that you are also updating a LocalChain
from kyoto but it would also be possible to implement ChainOracle
for the kyoto client itself so we didn't have to keep a LocalChain
. Maybe this won't work if kyoto would respond to this kind of query asynchronously.
This seems like a neater idea than forcing things into FullScanResult.
Indeed, you can use kyoto
with Wallet
, KeychainTxOutIndex
, etc already. I would encourage Rust developers to use those APIs if they find them better than this approach. In early work on integrating with ldk-node
, this is exactly what I'm doing. This crate is to curate the block events into a single update, which from my perspective was more convenient than handling the blocks.
would mean that you are also updating a LocalChain from kyoto
kyoto
has no BDK related dependencies. bdk_kyoto
is handling the LocalChain
to construct the update.
What's the problem with serializing a block?
With how UniFFI is made it's either needless serialization and deserialization operations or heap allocations.
I've been writing down some notes while looking at the library and implementing it in the example Android wallet.
Here are some thoughts, meant to start discussions mostly to sharpen my understanding of Kyoto and what's maybe possible vs maybe not. I could have opened this issue in the Kyoto repo too, I just figured it was mostly related to how I would use Kyoto through BDK as one of the blockchain clients.
My Dream Kyoto API?
I'm trying to clean up my mental model for kyoto, bdk_kyoto, and the bindings for both. This is very much in brainstorming mode but I've been meaning to write it down so I can iterate. Here is my dream API for Kyoto from the Kotlin perspective (I'm not familiar enough with tokio to write it in Rust).
In this workflow, there are no full scans nor syncs, no sync button to press.
The Node emits events, and the users use those as triggers for different kinds of wallet and UI operations.
Question for consideration: is it better to pass in a lambda to be triggered by the Node whenever an event happens on is it better to listen for events and trigger our own operations? In general I think passing callbacks is less flexible and increases reading complexity; on the other hand on simple tasks (say events that are simply logs) that's a good approach, because you just let the node know what to do with this event upon construction and don't need to think about it afterwards. For anything that needs more complex domain logic it's not great though. Anyway just a thought.
Top API: the Node emits a flow of events which I react to through wallet updates, persistence, UI messages, etc. Unfortunately I don't think we can expose
Flow
directly because it's a Kotlin coroutines' construct that doesn't have a direct equivalent in Swift and Python. But I do think that as is (see my option 2, standard suspension in awhile
loop) I can probably refactor this loop into emitting these events so they can be collected as flows by the application.1. Kotlin Flows
2. Standard
while
loop awaiting the updates (I think this is roughly the current approach?)