Currently, in this branch, the lightwalletd knows a pre-determined range of 101 blocks, 663150 - 663250. These are real mainnet blocks that contain several transactions that include shielded payments to or from the "developer wallet".
From now on, we'll just abbreviate these heights by dropping the 663
prefix.
When lightwalltd starts up, it pretends that the latest and only block
it knows about is 150, which it also advertises as the Sapling activation
height. The wallets initially can fetch only this one block (using either
GetBlock()
or GetBlockRange()
); attempts to fetch at heights beyond
than this return a no-such-block error, simulating that this is the
blockchain tip, no more blocks have been mined.
While in "darkside" mode, the lightwalletd accepts a SetState()
gRPC
that takes two arguments:
LatestHeight
ReorgHeight
Wallet test code (probably not within the wallet executable itself,
but in the form of a script that can run a program like grpcurl
)
calls this gRPC.
The LatestHeight
argument advances the simulated tip height; the
call
SetState(LatestHeight=200, ReorgHeight=0)
makes blocks 150 through 200 available to GetBlock()
and
GetBlockRange()
. If ReorgHeight
is zero, then all we're doing is
advancing the tip. The maximum LatestHeight
argument is 250 since that's
the highest height that the darkwalletd knows about. (The range can easily
be changed, but requires a commit in the lightwalletd GitHub repository.)
If ReorgHeight
is non-zero, then we're simulating a reorg. The block
at the given height is replaced by a new version, but its parent (at
ReorgHeight-1
) is unchanged. Also, there are new versions of all blocks
out to LatestHeight
. And if LatestHeight
is later increased, those
blocks will be different from before. In other words, it acts exactly
like a real reorg.
It's important to know that when doing a reorg, the LatestBlock
value
can be either less than, greater than, or the same as the previous
LatestBlock
. For example, we can do the following sequence:
SetState(LatestHeight=200, ReorgHeight=0)
SetState(LatestHeight=180, ReorgHeight=200)
This means we first extend the chain out to height 200, then reorg back to (and including) 180, then immediately back out to 200, so we end up at the same tip height.
Or, we can do:
SetState(LatestHeight=200, ReorgHeight=0)
SetState(LatestHeight=180, ReorgHeight=160)
This time, we first extend to 200, then reorg back to 180, then extend out only to 160 (so the chain got shorter, perhaps not as likely in real life but it definitely can happen). Or, the reorged chain can be longer:
SetState(LatestHeight=200, ReorgHeight=0)
SetState(LatestHeight=180, ReorgHeight=210)
It's not possible to "back up" the chain without doing a reorg because that really doesn't make sense (this doesn't work):
SetState(LatestHeight=200, ReorgHeight=0)
SetState(LatestHeight=180, ReorgHeight=0)
Another restriction is that ReorgHeight
(if nonzero) must be less
than or equal to the current tip height, and greater or equal to the new
LatestHeight
.
When darkwalletd receives unexpected arguments like this happens, it
crashes rather than return an error, because this indicates a bug in the
test and we want to make sure it's not papered over. The debug.log
file should make it clear exactly what caused the crash.
Here's a possible test sequence using SetState()
. There are almost
limiteless ways to use SetState()
; here are some ideas to give you
a flavor.
The wallet-side test code could have a static, manually-crafted table that records the expected state (for example, balance) at each height. The test would advance the height one block at a time (no reorg) and verify that the wallet state is as expected:
assert(state() == expectedstate[150])
SetState(LatestHeight=151, ReorgHeight=0)
assert(state() == expectedstate[151])
SetState(LatestHeight=152, ReorgHeight=0)
assert(state() == expectedstate[152])
...
SetState(LatestHeight=250, ReorgHeight=0)
assert(state() == expectedstate[250])
Then reorg (replace) only the latest block and make sure the state doesn't change:
SetState(LatestHeight=250, ReorgHeight=250)
assert(state() == expectedstate[250])
(From now on, to reduce clutter in this document, I'll stop showing the
assert
s, but it's implied that after each SetState()
, we assert that
the current state is equal to the expected state at that LatestHeight
.
Now maybe reorg such that the chain backs up by one block:
SetState(LatestHeight=249, ReorgHeight=249)
SetState(LatestHeight=248, ReorgHeight=248)
...
SetState(LatestHeight=150, ReorgHeight=150)
Let's do longer reorgs, such as a series of 10-block reorgs:
SetState(LatestHeight=160, ReorgHeight=0)
SetState(LatestHeight=170, ReorgHeight=150)
SetState(LatestHeight=180, ReorgHeight=170)
SetState(LatestHeight=190, ReorgHeight=180)
...
Reset back to 150, then maybe we do a 50-block reorgs:
SetState(LatestHeight=150, ReorgHeight=150)
SetState(LatestHeight=200, ReorgHeight=0)
SetState(LatestHeight=250, ReorgHeight=150)
...
Here's another idea, we could fuzz-test this by calling SetState()
with random arguments, and assert that the state is as expected after
each call. That would test all kinds of scenarios that we can't even
think of. If the test runs a few thousand iterations of that, it's hard
to imagine there could be any reorg bugs.
What I've described so far doesn't have reorg move transactions to different block heights. The transactions initially in block height N will always be in block height N (never N-1 or N+1). I would think this is okay; it's hard to imagine a bug that would allow the testing just described to pass, but if a transaction moves to a different block height following a reorg, the bug triggers. The wallets should be processing (adding) transactions as they appear in a block and undoing (removing) the effects of transactions when a block gets reorged away, and they should not behave differently when a transaction that previously was in block N is now in block N-1 or N+1, or even know that's happened.
It wouldn't be hard for darkwalletd to move transactions around (to
different block heights) randomly as part of a reorg. But the problem
I see is that the wallet test code won't know which state to expect at
each height. Instead of moving transactions randomly, we could add an
argument to SetState
that tells darkwalletd which heights to move
which transactions to. But that seems pretty complicated for limited
or no benefit.