-
Notifications
You must be signed in to change notification settings - Fork 170
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: Add btclightclient events and hooks #53
Conversation
x/btclightclient/keeper/keeper.go
Outdated
// Trigger TipUpdated hook | ||
k.AfterTipUpdated(ctx, height+1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to remember the discussions we had around this.
- One suggestion was from @fishermanymc who I believe wanted to emit all the blocks that the chains got extended with.
- My suggestion was that we should do the callbacks one by one, but make sure that all heights are emitted, one after the other, so that the listeners who have unstable checkpoints can say "aha, now the difference between the block I am interested in and the tip is exactly
k
orw
, let's see if my block is on the main chain".
The current logic of just emitting the height can skip over some heights, in theory, making that simple rule I laid out unworkable.
It would be a pathological case, but imagine we have a chain like this, where capital letters mean "a lot of work" and small case letter mean "very little work"
B0 - B1 - B2*
\
b1' - b2' - b3' - B4
So, imagine that B2
is the current tip, but b3'
didn't have enough work to take over. Only when B4
arrives does the lower fork become the tip. In that case, the tip height would go from 2 to 4.
To fully and unambiguously describe the chain, we would need two events:
RollBackward(to_hash, to_height)
RollForward(header, height)
When going from B2 to B4 we would see the following callbacks:
RollBackward(hash(B0), 0)
RollForward(b1', 1)
RollForward(b2', 2)
RollForward(b3', 3)
RollForward(B4, 4)
For this you first have to find the common ancestor between the old tip and the new tip. That is easiest to achieve by first going to the same height as the old tip, then walk both forks together until the parents are the same. That's where we have to roll back to.
By telling everyone to roll back, they should be told about a block they already know about. Attaching the height to the event makes it easier for anyone who is already deeper than that to ignore this, as they must still be on the main chain. Then rolling forward one by one means at least one callback will hit the condition that is easy to check, and eventually the whole BTC chain will be emitted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deciding whether this "reorg" logic needs to kick in can be done by looking at the parent of the header you are adding: if it's the tip, just roll forward. If not, and the tip changed (as indicated by the return value from your method), then you have to find the common ancestor and roll forward on the new fork.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To give another illustration to why a rollback is nice, imagine we have this chain and I know my checkpoint has been submitted to BTC in B3
, and the current tips is B4
.
B0 - B1 - B2 - B3* - B4
\
B3' - B4' - B5'
That means the status of my checkpoint is currently SUBMITTED
. But now say we extend the chain to B5'
, so we need a reorg, a rollback to B2
, then a rollforward to B3'
, B4'
and B5'
.
If all we emit to the rest of the application is that height
went from 4 to 5, then in the code monitoring the checkpoint status, I may think that everything is good, or I'm forced to query every time whether I'm still on the main chain.
Why do I want to know? Because if it's no longer on the main chain I want the status to revert to SIGNED, so that submitters can pick it up again and submit to BTC, if need to.
By emitting Rollback(hash(B2), 2)
, I can see that "wait, I was on the main chain at height 3, but now we are rolling back to 2, so my status needs to be reverted as well, if there are no other submissions on the main chain".
cc @gitferry
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the Rollback is cool. But I'm not sure it is critical to us.
I don't think checkpoints should care about status revert. Consider Akosh's example. Although B3*-B4
is no longer the mainchain, I don't think we should revert the status of the checkpoint on B3*
to SIGNED
because in my understanding, these transactions that are forked (e.g., contained in B3* and B4) will be collected back to the miners' mempool and be proposed again. This means the checkpoint will eventually appear on the mainchain without submitters submitting it again. Changing status between SUBMITTED
and SIGNED
does not affect unbonding. So, I don't think we need to revert a checkpoint's status.
Nevertheless, maybe a safe card is to let the submitter re-submit a checkpoint if it notices a fork that could potentially affect a checkpoint. This ensures that a checkpoint will be eventually CONFIRMED
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having those sets of events is cool and would allow to build more complex functionality, but I think we can be super strict about this or follow a more simple approach.
Currently, the btclightclient
module does not have the concept of a rollback or a rollforward. More specifically, when a new header is included, it's accumulative PoW is calculated. If it is more than the accumulative PoW of the tip, then it becomes the tip. We do not go back to check which headers of the previous main chain are not included in the new main chain and which headers are new ones.
The main reason that we want to just output the height of the new tip header is because the other modules care whether a header is k-deep. Using the height of the tip one can check whether they should make a query about whether a particular header is k-deep on the current main-chain. The height that is emitted is only an optimization step. The chain can work even without it by asking each time the tip is updated whether the unconfirmed checkpoints are included on the main-chain.
That's the simple approach. Once the status of a checkpoint is changed to a more "safe" state (e.g. Submitted -> Signed
or ) then it cannot be rolled back (without requerying which the events do not output any useful information about whether it should happen). If we want to be able to roll-back then I agree a more complex set of events is required. However, this would require some more complex operations on the btclightclient
module to compute the new headers on the main chain and the ones that were removed (which would not be that performant, although a re-org would happen quite infrequently). I suggest that we see exactly what is needed by other modules and add this functionality if it is necessary or has an obvious performance or code complexity improvement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I fact this roll back/forward reactive scheme can completely negate the need for downstream modules to query the mainchain.
They know they are on the mainchain now , and they know when they are rolled back by looking at just the height. So the status can be updated accordingly without further queries.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. Implemented and pushed the new methodology -- please take a look. The GetHighestCommonAncestor
is a little bit ugly so improvement suggestions would be appreciated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
Appreciate your open mind here. I know I probably suggested exactly what you implemented, as a rough idea, but it was just an intuition and I think it wasn't complete without emitting all blocks at least once, at which point the rollback becomes necessary for the full picture. Sorry for the confusion.
With such an event stream in place, the btccheckpoint
module that @KonradStaniec is doing can have a hook along the lines of:
- on
RollForward(header)
:- check if any BTC submissions we are tracking are included in that header, their status is turns to
SUBMITTED
; if so the checkpoint they have goes fromSIGNED
toSUBMITTED
(unless already higher status) - check if any BTC submissions we are tracking are in a header whose height is
k
deep relative toheader
; if so their status goes toCONFIRMED
, and the checkpoint they contain also goes toCONFIRMED
unless already higher - if the checkpoint went
CONFIRMED
we can pay out rewards; we can remove all but the winning transaction - check if any BTC submissions we are tracking are in a header whose height is
w
deep relative to header; if so the checkpoint status goes toFINALIZED
and we don't need to track the transactions any more for this epoch
- check if any BTC submissions we are tracking are included in that header, their status is turns to
- on
RollBackward(hash, height)
:- check if any BTC submission we are tracking is included in a header whose height is higher than the one in the event; if so, reset their status to
ORPHANED
- if all submissions for a checkpoint are
ORPHANED
then the status of the checkpoint goes back toSIGNED
.
- check if any BTC submission we are tracking is included in a header whose height is higher than the one in the event; if so, reset their status to
So, no need to do any queries, which should overall make it more efficient. The only part where we need the main chain check is when a checkpoint is first submitted, to determine its starting status.
There could be other ways, but at least it gives the consumer the tools to decide what works best for them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@vitsalis give this change, have you implemented/updated what "X-deep" related query that the btc_ckpt_oracle can make?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added it now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nicely done. I would suggest a more elaborate event emission scheme to allow the rest of the code to be as dumb as possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! Thanks. I only left some discussions with @aakoshh. No blocker from my side.
38849c9
to
88005d4
Compare
x/btclightclient/keeper/state.go
Outdated
// We have already found what we're looking for, no need to proceed further | ||
if found != nil { | ||
if *found == btcdHeader.BlockHash() { | ||
height, err := s.GetHeaderHeight(found) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this part of the keys? It would really help the header with height was available together.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe the height could be looked up at the end, outside the iterator, so it's obvious that it's just an extra return value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still not entirely sure how the height can be retrieved through the (height, hash)
key in a reliable manner. I recall you mentioning something about manipulating the bytes of the key in order to extract it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on issue #57 , I am gonna include more information in storage so that we can avoid doing magic with bytes and improve code quality. Let's leave this for the next PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, if you're already enhancing it. What I said was that there must be a way to parse the height from the key because if there wasn't, it could not be used for sorting by height. It's either encoded as a fixed number of bytes, or there's a length prefix. In any case you know the rightmost bytes are 32 long for the hash.
This would be a nice feature to fuzz test. The way I would possibly do it:
Other invariants can surely be tested by different tree generation, e.g.
|
ca2cd13
to
a57f578
Compare
@aakoshh thanks for the insightful comments! Answered some of them and resolved others, please take a look. About fuzzing: Completely agree, we need a methodology for building random trees to test all of the state methods. In a following PR (after some code cleanup) I will implement such a methodology and test all of the related methods. |
// If a starting key has not been set, then the first header is the tip | ||
prevHeader := k.HeadersState(sdkCtx).GetTip() | ||
prevHeaderHash := prevHeader.BlockHash() | ||
prevHeaderHeight, err := k.HeadersState(sdkCtx).GetHeaderHeight(&prevHeaderHash) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This handling of the "previous" doesn't look right. The comment says "if the starting key has not been set" but there is no if
to check it, GetTip
is called every time, and prevHeaderHeight
is set to be that of the tip. Then, if the key is not 0 (is that the right check to do?) we retrieve the header, but not the corresponding height - that stays the height of the tip.
Can we either:
- delay retrieving the height until we know which header we are dealing with, or
- return the "info" struct which has the height, so there's no chance to mix it up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Working on that on the refactoring PR for #57
// tipHeight + 1 - len(addedToMainChain) -> height of the highest common ancestor | ||
addedHeight := tipHeight - uint64(len(addedToMainChain)) + 1 + uint64(idx) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// tipHeight + 1 - len(addedToMainChain) -> height of the highest common ancestor | |
addedHeight := tipHeight - uint64(len(addedToMainChain)) + 1 + uint64(idx) | |
addedHeight := hcaHeight + 1 + uint64(idx) |
Since it's already available as a variable, this is much easier to grok.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will entirely be removed on the PR that resolves #57
// HeaderKDeep returns true if a header is at least k-deep on the main chain | ||
func (k Keeper) HeaderKDeep(ctx sdk.Context, header *wire.BlockHeader, depth uint64) bool { | ||
// TODO: optimize to not traverse the entire mainchain by storing the height along with the header | ||
mainchain := k.HeadersState(ctx).GetMainChain() | ||
if depth > uint64(len(mainchain)) { | ||
return false | ||
} | ||
// k-deep -> k headers built on top of the BTC header | ||
// Discard the first `depth` headers | ||
kDeepMainChain := mainchain[depth:] | ||
for _, mainChainHeader := range kDeepMainChain { | ||
if sameBlock(header, mainChainHeader) { | ||
return true | ||
} | ||
} | ||
return false | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We really need to rationalise how we handle headers. The wire format is that - a wire format. Since we already store the height when we insert the data into the store, there's little excuse for not using it to our advantage.
I see this retrieves the whole mainchain, which is much more than what we needed if just used the height of the header we are interested in. Based on that knowledge we would know exactly how far we have to traverse back on the main chain: if we are beyond that height, and we have not reached the header in question, it's not on the main chain.
So, a query like func MainChainDepth(blockHash: BTCBlockHash) -> uint64
can express what we need:
- Retrieve the header; if it can't be found, return -1 (or an error)
- Retrieve the tip
- Traverse the main chain up to tip.height - header.height + 1 deep (should be an input to a variant of
GetMainChain
) - Check that the last block is the one we are looking for, if so, return the depth, otherwise -1 (0 would be the tip itself I suppose, there are 0 blocks building on it)
The caller can project the "at least k-deep" query by getting the depth and comparing.
|
||
return ancestors |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This iteration is not guaranteed to return a path to the ancestor.
Once again, it would be much better to work with the "info" versions that have height, so you could quit the iteration early if you are past the point where you have a chance of meeting the ancestor.
If I ask the impossible, e.g. by mixing up the order of child
and ancestor
, or asking with a non-existent ancestor, or passing blocks which are on different forks, this query will dutifully iterate all the way back to the current oldest block and return the whole path to a block which is not the ancestor.
Ideally it should:
- Panic if the height of the ancestor is not less than the height of the child.
- Abandon the iteration if we went lower than the height of the ancestor without finding it and return an empty array.
- Check that the last block in the array has the ancestor as its parent, otherwise return empty array. (That means we reached the root without visiting the ancestor; maybe they share the height).
defer iter.Close() | ||
|
||
for ; iter.Valid(); iter.Next() { | ||
btcdHeader := blockHeaderFromStoredBytes(iter.Value()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They height is in the key, but ideally we should just store the "info" as the Value
so we can put more stuff in it than what we can retrieve from the key. Or you can pass the header and the height to the function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, working on that for #57
Thanks for the updated comments @aakoshh ! I resolved the minor ones, but left the other ones to be addressed on a PR that I'm currently working on to resolve #57 . This PR involves a major refactoring that entirely abstracts the If there are no further comments, I suggest we merge this PR and resolve the rest in the next PR that involves the major refactoring. |
a67398c
to
19ecc81
Compare
Sure, if you want to do some of the refactorings as part of the other issue, that's totally fine. Strictly speaking #53 (comment) is not a refactoring, the code is incorrect as it is. I added a link in the issue as a reminder. |
19ecc81
to
ff8aaaf
Compare
Fixes BM-56.
Mostly influenced by similar pull requests by @SebastianElvis and @gitferry