Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ The most recent changes are listed first.
shielded, or a combination) of blocks (`GetBlockRange`) and transactions
(`GetMempoolTx`).

- Add Ironwood compact block, tree state, subtree root, and Ironwood / NU6.3 V6
transaction parsing support. Empty `poolTypes` requests now include Ironwood
shielded data.


### Changed

Expand Down
19 changes: 17 additions & 2 deletions common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ type (
}
SkipHash string
}
Ironwood struct {
Commitments struct {
FinalState string
}
SkipHash string
}
}

// zcashd rpc "getrawtransaction txid 1" (1 means verbose), there are
Expand Down Expand Up @@ -172,6 +178,9 @@ type (
Orchard struct {
Size uint32
}
Ironwood struct {
Size uint32
}
}
}

Expand Down Expand Up @@ -398,6 +407,7 @@ func getBlockFromRPC(height int) (*walletrpc.CompactBlock, error) {
r := block.ToCompact()
r.ChainMetadata.SaplingCommitmentTreeSize = block1.Trees.Sapling.Size
r.ChainMetadata.OrchardCommitmentTreeSize = block1.Trees.Orchard.Size
r.ChainMetadata.IronwoodCommitmentTreeSize = block1.Trees.Ironwood.Size
return r, nil
}

Expand Down Expand Up @@ -540,11 +550,15 @@ func FilterTxPool(tx *walletrpc.CompactTx, poolTypes []walletrpc.PoolType) *wall
if slices.Contains(poolTypes, walletrpc.PoolType_ORCHARD) {
r.Actions = tx.Actions
}
if slices.Contains(poolTypes, walletrpc.PoolType_IRONWOOD) {
r.IronwoodActions = tx.IronwoodActions
}
if len(r.Vin) > 0 ||
len(r.Vout) > 0 ||
len(r.Spends) > 0 ||
len(r.Outputs) > 0 ||
len(r.Actions) > 0 {
len(r.Actions) > 0 ||
len(r.IronwoodActions) > 0 {
return r
}
return nil
Expand All @@ -556,10 +570,11 @@ func FilterTxPool(tx *walletrpc.CompactTx, poolTypes []walletrpc.PoolType) *wall
// don't bother to return empty transactions).
func filterBlockPool(vtx []*walletrpc.CompactTx, poolTypes []walletrpc.PoolType) []*walletrpc.CompactTx {
if len(poolTypes) == 0 {
// legacy behavior: return only blocks containing shielded components.
// Return all shielded pools when no explicit pool filter is requested.
poolTypes = []walletrpc.PoolType{
walletrpc.PoolType_SAPLING,
walletrpc.PoolType_ORCHARD,
walletrpc.PoolType_IRONWOOD,
}
}
trimmedVtx := []*walletrpc.CompactTx{}
Expand Down
156 changes: 156 additions & 0 deletions common/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/sirupsen/logrus"
"github.com/zcash/lightwalletd/walletrpc"
"google.golang.org/grpc/metadata"
)

// ------------------------------------------ Setup
Expand All @@ -33,6 +34,22 @@ var (
testcache *BlockCache
)

type subtreeRootStream struct {
roots []*walletrpc.SubtreeRoot
}

func (s *subtreeRootStream) Send(root *walletrpc.SubtreeRoot) error {
s.roots = append(s.roots, root)
return nil
}

func (s *subtreeRootStream) SetHeader(metadata.MD) error { return nil }
func (s *subtreeRootStream) SendHeader(metadata.MD) error { return nil }
func (s *subtreeRootStream) SetTrailer(metadata.MD) {}
func (s *subtreeRootStream) Context() context.Context { return context.Background() }
func (s *subtreeRootStream) SendMsg(any) error { return nil }
func (s *subtreeRootStream) RecvMsg(any) error { return nil }

const (
testTxid = "1234000000000000000000000000000000000000000000000000000000000000"
testBlockid40 = "0000000000000000000000000000000000000000000000000000000000380640"
Expand Down Expand Up @@ -412,6 +429,145 @@ func TestBlockIngestor(t *testing.T) {
os.RemoveAll(unitTestPath)
}

func TestFilterTxPoolIronwood(t *testing.T) {
tx := &walletrpc.CompactTx{
Index: 7,
Txid: []byte{1, 2, 3},
Actions: []*walletrpc.CompactOrchardAction{
{Nullifier: []byte{4}},
},
IronwoodActions: []*walletrpc.CompactOrchardAction{
{Nullifier: []byte{5}},
},
}

orchardOnly := FilterTxPool(tx, []walletrpc.PoolType{walletrpc.PoolType_ORCHARD})
if orchardOnly == nil || len(orchardOnly.Actions) != 1 || len(orchardOnly.IronwoodActions) != 0 {
t.Fatal("orchard-only filter returned unexpected actions")
}

ironwoodOnly := FilterTxPool(tx, []walletrpc.PoolType{walletrpc.PoolType_IRONWOOD})
if ironwoodOnly == nil || len(ironwoodOnly.Actions) != 0 || len(ironwoodOnly.IronwoodActions) != 1 {
t.Fatal("ironwood-only filter returned unexpected actions")
}

defaultFiltered := filterBlockPool([]*walletrpc.CompactTx{tx}, nil)
if len(defaultFiltered) != 1 || len(defaultFiltered[0].IronwoodActions) != 1 {
t.Fatal("default shielded filter should keep ironwood actions")
}
}

func TestDarksideClearAllTreeStatesClearsHashIndex(t *testing.T) {
hash := strings.Repeat("ab", 32)
cache := NewBlockCache(t.TempDir(), unitTestChain, 100, 0)

mutex.Lock()
state.cache = cache
mutex.Unlock()

if err := DarksideReset(100, "cafe", "test", 0, 0, 0); err != nil {
t.Fatal(err)
}
if err := DarksideAddTreeState(DarksideTreeState{
Network: "test",
Height: 123,
Hash: hash,
Time: 456,
SaplingTree: "sapling",
OrchardTree: "orchard",
IronwoodTree: "ironwood",
}); err != nil {
t.Fatal(err)
}

hashJSON, err := json.Marshal(hash)
if err != nil {
t.Fatal(err)
}
result, err := darksideRawRequest("z_gettreestate", []json.RawMessage{hashJSON})
if err != nil {
t.Fatal(err)
}
var treeState ZcashdRpcReplyGettreestate
if err := json.Unmarshal(result, &treeState); err != nil {
t.Fatal(err)
}
if treeState.Ironwood.Commitments.FinalState != "ironwood" {
t.Fatal("ironwood tree state was not returned")
}

if err := DarksideClearAllTreeStates(); err != nil {
t.Fatal(err)
}
if _, err := darksideRawRequest("z_gettreestate", []json.RawMessage{hashJSON}); err == nil {
t.Fatal("tree state should not be available by hash after clearing")
}
if err := DarksideRemoveTreeState(&walletrpc.BlockID{Height: 123}); err != nil {
t.Fatal(err)
}
if err := DarksideRemoveTreeState(&walletrpc.BlockID{Hash: bytes.Repeat([]byte{0xab}, 32)}); err != nil {
t.Fatal(err)
}
}

func TestDarksideGetSubtreeRootsZeroMaxEntriesReturnsAll(t *testing.T) {
cache := NewBlockCache(t.TempDir(), unitTestChain, 100, 0)

mutex.Lock()
state.cache = cache
mutex.Unlock()

if err := DarksideReset(100, "cafe", "test", 0, 0, 0); err != nil {
t.Fatal(err)
}
if err := DarksideSetSubtreeRoots(&walletrpc.DarksideSubtreeRoots{
ShieldedProtocol: walletrpc.ShieldedProtocol_ironwood,
StartIndex: 7,
SubtreeRoots: []*walletrpc.SubtreeRoot{
{
RootHash: []byte{1},
CompletingBlockHash: []byte{11},
CompletingBlockHeight: 101,
},
{
RootHash: []byte{2},
CompletingBlockHash: []byte{12},
CompletingBlockHeight: 102,
},
{
RootHash: []byte{3},
CompletingBlockHash: []byte{13},
CompletingBlockHeight: 103,
},
},
}); err != nil {
t.Fatal(err)
}

allRoots := &subtreeRootStream{}
if err := DarksideGetSubtreeRoots(&walletrpc.GetSubtreeRootsArg{
ShieldedProtocol: walletrpc.ShieldedProtocol_ironwood,
StartIndex: 7,
}, allRoots); err != nil {
t.Fatal(err)
}
if len(allRoots.roots) != 3 {
t.Fatalf("expected all roots for zero maxEntries, got %d", len(allRoots.roots))
}

limitedRoots := &subtreeRootStream{}
if err := DarksideGetSubtreeRoots(&walletrpc.GetSubtreeRootsArg{
ShieldedProtocol: walletrpc.ShieldedProtocol_ironwood,
StartIndex: 8,
MaxEntries: 1,
}, limitedRoots); err != nil {
t.Fatal(err)
}
if len(limitedRoots.roots) != 1 || !bytes.Equal(limitedRoots.roots[0].RootHash, []byte{2}) {
t.Fatal("limited subtree roots response was incorrect")
}
}

// ------------------------------------------ GetBlockRange()

// There are four test blocks, 0..3
Expand Down
Loading