From 7780e44116ac1d96b311c78523cf4fa96025dcec Mon Sep 17 00:00:00 2001
From: Adam Tucker
Date: Mon, 8 Jun 2026 10:58:39 -0600
Subject: [PATCH 01/11] parser: accept NU7 v6 transactions
---
parser/transaction.go | 107 +++++++++++++++++++++++++++++++------
parser/transaction_test.go | 95 ++++++++++++++++++++++++++++++++
2 files changed, 187 insertions(+), 15 deletions(-)
diff --git a/parser/transaction.go b/parser/transaction.go
index a45ba60e..dde06995 100644
--- a/parser/transaction.go
+++ b/parser/transaction.go
@@ -528,6 +528,66 @@ func (tx *Transaction) parseV5(data []byte) ([]byte, error) {
return nil, err
}
+ s, err = tx.parseSaplingBundle([]byte(s))
+ if err != nil {
+ return nil, err
+ }
+
+ var orchardActions []action
+ s, orchardActions, err = parseOrchardActionsBundle([]byte(s), "Orchard")
+ if err != nil {
+ return nil, err
+ }
+ tx.orchardActions = orchardActions
+
+ return s, nil
+}
+
+// parse version 6 transaction data after the nVersionGroupId field.
+func (tx *Transaction) parseV6(data []byte) ([]byte, error) {
+ s := bytestring.String(data)
+ var err error
+ if !s.ReadUint32(&tx.consensusBranchID) {
+ return nil, errors.New("could not read nVersionGroupId")
+ }
+ if tx.nVersionGroupID != ZIP230_VERSION_GROUP_ID {
+ // This shouldn't be possible
+ return nil, fmt.Errorf("version group ID %d must be 0xFFFFFFFF", tx.nVersionGroupID)
+ }
+ if !s.Skip(4) {
+ return nil, errors.New("could not skip nLockTime")
+ }
+ if !s.Skip(4) {
+ return nil, errors.New("could not skip nExpiryHeight")
+ }
+ s, err = tx.ParseTransparent([]byte(s))
+ if err != nil {
+ return nil, err
+ }
+
+ s, err = tx.parseSaplingBundle([]byte(s))
+ if err != nil {
+ return nil, err
+ }
+
+ var orchardActions []action
+ s, orchardActions, err = parseOrchardActionsBundle([]byte(s), "Orchard")
+ if err != nil {
+ return nil, err
+ }
+ tx.orchardActions = orchardActions
+
+ s, _, err = parseOrchardActionsBundle([]byte(s), "Ironwood")
+ if err != nil {
+ return nil, err
+ }
+
+ return s, nil
+}
+
+func (tx *Transaction) parseSaplingBundle(data []byte) ([]byte, error) {
+ s := bytestring.String(data)
+ var err error
var spendCount, outputCount int
if !s.ReadCompactSize(&spendCount) {
return nil, errors.New("could not read nShieldedSpend")
@@ -575,58 +635,67 @@ func (tx *Transaction) parseV5(data []byte) ([]byte, error) {
if spendCount+outputCount > 0 && !s.Skip(64) {
return nil, errors.New("could not skip bindingSigSapling")
}
+
+ return s, nil
+}
+
+func parseOrchardActionsBundle(data []byte, pool string) ([]byte, []action, error) {
+ s := bytestring.String(data)
+ var err error
var actionsCount int
if !s.ReadCompactSize(&actionsCount) {
- return nil, errors.New("could not read nActionsOrchard")
+ return nil, nil, fmt.Errorf("could not read nActions%s", pool)
}
if actionsCount >= (1 << 16) {
- return nil, fmt.Errorf("actionsCount (%d) must be less than 2^16", actionsCount)
+ return nil, nil, fmt.Errorf("actionsCount (%d) must be less than 2^16", actionsCount)
}
- tx.orchardActions = make([]action, actionsCount)
+ actions := make([]action, actionsCount)
for i := 0; i < actionsCount; i++ {
- a := &tx.orchardActions[i]
+ a := &actions[i]
s, err = a.ParseFromSlice([]byte(s))
if err != nil {
- return nil, fmt.Errorf("error parsing orchard action: %w", err)
+ return nil, nil, fmt.Errorf("error parsing %s action: %w", pool, err)
}
}
if actionsCount > 0 {
if !s.Skip(1) {
- return nil, errors.New("could not skip flagsOrchard")
+ return nil, nil, fmt.Errorf("could not skip flags%s", pool)
}
if !s.Skip(8) {
- return nil, errors.New("could not skip valueBalanceOrchard")
+ return nil, nil, fmt.Errorf("could not skip valueBalance%s", pool)
}
if !s.Skip(32) {
- return nil, errors.New("could not skip anchorOrchard")
+ return nil, nil, fmt.Errorf("could not skip anchor%s", pool)
}
var proofsCount int
if !s.ReadCompactSize(&proofsCount) {
- return nil, errors.New("could not read sizeProofsOrchard")
+ return nil, nil, fmt.Errorf("could not read sizeProofs%s", pool)
}
if !s.Skip(proofsCount) {
- return nil, errors.New("could not skip proofsOrchard")
+ return nil, nil, fmt.Errorf("could not skip proofs%s", pool)
}
if !s.Skip(64 * actionsCount) {
- return nil, errors.New("could not skip vSpendAuthSigsOrchard")
+ return nil, nil, fmt.Errorf("could not skip vSpendAuthSigs%s", pool)
}
if !s.Skip(64) {
- return nil, errors.New("could not skip bindingSigOrchard")
+ return nil, nil, fmt.Errorf("could not skip bindingSig%s", pool)
}
}
- return s, nil
+ return s, actions, nil
}
-// The logic in the following four functions is copied from
+// The logic in the following version helpers is copied from
// https://github.com/zcash/zcash/blob/master/src/primitives/transaction.h#L811
const OVERWINTER_TX_VERSION uint32 = 3
const SAPLING_TX_VERSION uint32 = 4
const ZIP225_TX_VERSION uint32 = 5
+const ZIP230_TX_VERSION uint32 = 6
const OVERWINTER_VERSION_GROUP_ID uint32 = 0x03C48270
const SAPLING_VERSION_GROUP_ID uint32 = 0x892F2085
const ZIP225_VERSION_GROUP_ID uint32 = 0x26A7270A
+const ZIP230_VERSION_GROUP_ID uint32 = 0xFFFFFFFF
func (tx *Transaction) isOverwinterV3() bool {
return tx.fOverwintered &&
@@ -646,6 +715,12 @@ func (tx *Transaction) isZip225V5() bool {
tx.version == ZIP225_TX_VERSION
}
+func (tx *Transaction) isZip230V6() bool {
+ return tx.fOverwintered &&
+ tx.nVersionGroupID == ZIP230_VERSION_GROUP_ID &&
+ tx.version == ZIP230_TX_VERSION
+}
+
func (tx *Transaction) isGroth16Proof() bool {
// Sapling changed the joinSplit proof from PHGR (BCTV14) to Groth16;
// this applies also to versions beyond Sapling.
@@ -675,12 +750,14 @@ func (tx *Transaction) ParseFromSlice(data []byte) ([]byte, error) {
}
if tx.fOverwintered &&
- !(tx.isOverwinterV3() || tx.isSaplingV4() || tx.isZip225V5()) {
+ !(tx.isOverwinterV3() || tx.isSaplingV4() || tx.isZip225V5() || tx.isZip230V6()) {
return nil, errors.New("unknown transaction format")
}
// parse the main part of the transaction
if tx.isZip225V5() {
s, err = tx.parseV5([]byte(s))
+ } else if tx.isZip230V6() {
+ s, err = tx.parseV6([]byte(s))
} else {
s, err = tx.parsePreV5([]byte(s))
}
diff --git a/parser/transaction_test.go b/parser/transaction_test.go
index 86493e50..1dc95f38 100644
--- a/parser/transaction_test.go
+++ b/parser/transaction_test.go
@@ -4,6 +4,7 @@
package parser
import (
+ "bytes"
"encoding/hex"
"encoding/json"
"os"
@@ -113,3 +114,97 @@ func TestV5TransactionParser(t *testing.T) {
}
}
}
+
+func TestV6TransactionParser(t *testing.T) {
+ rawTxData, err := hex.DecodeString("06000080ffffffffffffffff0000000000000000000000000000")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tx := NewTransaction()
+ rest, err := tx.ParseFromSlice(rawTxData)
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ if len(rest) != 0 {
+ t.Fatalf("Test did not consume entire buffer, %d remaining", len(rest))
+ }
+ if tx.version != ZIP230_TX_VERSION {
+ t.Fatal("version miscompare")
+ }
+ if tx.nVersionGroupID != ZIP230_VERSION_GROUP_ID {
+ t.Fatal("nVersionGroupId miscompare")
+ }
+ if tx.consensusBranchID != ZIP230_VERSION_GROUP_ID {
+ t.Fatal("consensusBranchID miscompare")
+ }
+ if len(tx.transparentInputs) != 0 {
+ t.Fatal("tx_in_count miscompare")
+ }
+ if len(tx.transparentOutputs) != 0 {
+ t.Fatal("tx_out_count miscompare")
+ }
+ if len(tx.shieldedSpends) != 0 {
+ t.Fatal("NSpendsSapling miscompare")
+ }
+ if len(tx.shieldedOutputs) != 0 {
+ t.Fatal("NOutputsSapling miscompare")
+ }
+ if len(tx.orchardActions) != 0 {
+ t.Fatal("NActionsOrchard miscompare")
+ }
+}
+
+func TestV6TransactionParserSkipsIronwoodBundle(t *testing.T) {
+ var raw bytes.Buffer
+ raw.Write([]byte{
+ 0x06, 0x00, 0x00, 0x80, // fOverwintered | version 6
+ 0xff, 0xff, 0xff, 0xff, // version group ID
+ 0xff, 0xff, 0xff, 0xff, // consensus branch ID
+ 0x00, 0x00, 0x00, 0x00, // lock time
+ 0x00, 0x00, 0x00, 0x00, // expiry height
+ 0x00, // tx_in_count
+ 0x00, // tx_out_count
+ 0x00, // nShieldedSpend
+ 0x00, // nShieldedOutput
+ })
+ appendOrchardLikeBundle(&raw, 1)
+ appendOrchardLikeBundle(&raw, 1)
+ raw.Write([]byte{0xaa, 0xbb})
+
+ tx := NewTransaction()
+ rest, err := tx.ParseFromSlice(raw.Bytes())
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ if len(rest) != 2 {
+ t.Fatalf("expected two trailing bytes, got %d", len(rest))
+ }
+ if !bytes.Equal(rest, []byte{0xaa, 0xbb}) {
+ t.Fatal("trailing bytes miscompare")
+ }
+ if len(tx.orchardActions) != 1 {
+ t.Fatal("NActionsOrchard miscompare")
+ }
+ if len(tx.ToCompact(0).Actions) != 1 {
+ t.Fatal("compact orchard action count miscompare")
+ }
+ if len(tx.rawBytes) != raw.Len()-len(rest) {
+ t.Fatal("raw transaction length miscompare")
+ }
+}
+
+func appendOrchardLikeBundle(raw *bytes.Buffer, actionsCount int) {
+ raw.WriteByte(byte(actionsCount))
+ for i := 0; i < actionsCount; i++ {
+ raw.Write(bytes.Repeat([]byte{byte(i + 1)}, 820))
+ }
+ if actionsCount > 0 {
+ raw.WriteByte(0x00) // flags
+ raw.Write(make([]byte, 8)) // value balance
+ raw.Write(make([]byte, 32)) // anchor
+ raw.WriteByte(0x00) // proofs length
+ raw.Write(make([]byte, 64*actionsCount)) // spend auth signatures
+ raw.Write(make([]byte, 64)) // binding signature
+ }
+}
From 2ead60c49927a627ca7d48befdae074c32452646 Mon Sep 17 00:00:00 2001
From: Adam Tucker
Date: Tue, 9 Jun 2026 10:54:25 -0600
Subject: [PATCH 02/11] Clarify Valar NU7 V6 transaction parsing
---
parser/transaction.go | 29 +++++++++++++++++++----------
parser/transaction_test.go | 27 ++++++++++++++++++++++-----
2 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/parser/transaction.go b/parser/transaction.go
index dde06995..8fb4327d 100644
--- a/parser/transaction.go
+++ b/parser/transaction.go
@@ -511,7 +511,7 @@ func (tx *Transaction) parseV5(data []byte) ([]byte, error) {
s := bytestring.String(data)
var err error
if !s.ReadUint32(&tx.consensusBranchID) {
- return nil, errors.New("could not read nVersionGroupId")
+ return nil, errors.New("could not read nConsensusBranchId")
}
if tx.nVersionGroupID != 0x26A7270A {
// This shouldn't be possible
@@ -544,16 +544,24 @@ func (tx *Transaction) parseV5(data []byte) ([]byte, error) {
}
// parse version 6 transaction data after the nVersionGroupId field.
+//
+// This intentionally handles the Valar NU7 V6 layout used by this stack. It is
+// not the ZIP 248 extensible transaction format. After nExpiryHeight, this
+// layout keeps transparent inputs and outputs inline, followed by Sapling,
+// Orchard, and Ironwood bundles.
func (tx *Transaction) parseV6(data []byte) ([]byte, error) {
s := bytestring.String(data)
var err error
if !s.ReadUint32(&tx.consensusBranchID) {
- return nil, errors.New("could not read nVersionGroupId")
+ return nil, errors.New("could not read nConsensusBranchId")
}
- if tx.nVersionGroupID != ZIP230_VERSION_GROUP_ID {
+ if tx.nVersionGroupID != VALAR_NU7_VERSION_GROUP_ID {
// This shouldn't be possible
return nil, fmt.Errorf("version group ID %d must be 0xFFFFFFFF", tx.nVersionGroupID)
}
+ if tx.consensusBranchID != VALAR_NU7_CONSENSUS_BRANCH_ID {
+ return nil, fmt.Errorf("consensus branch ID %d must be 0xFFFFFFFF", tx.consensusBranchID)
+ }
if !s.Skip(4) {
return nil, errors.New("could not skip nLockTime")
}
@@ -690,12 +698,13 @@ func parseOrchardActionsBundle(data []byte, pool string) ([]byte, []action, erro
const OVERWINTER_TX_VERSION uint32 = 3
const SAPLING_TX_VERSION uint32 = 4
const ZIP225_TX_VERSION uint32 = 5
-const ZIP230_TX_VERSION uint32 = 6
+const VALAR_NU7_TX_VERSION uint32 = 6
const OVERWINTER_VERSION_GROUP_ID uint32 = 0x03C48270
const SAPLING_VERSION_GROUP_ID uint32 = 0x892F2085
const ZIP225_VERSION_GROUP_ID uint32 = 0x26A7270A
-const ZIP230_VERSION_GROUP_ID uint32 = 0xFFFFFFFF
+const VALAR_NU7_VERSION_GROUP_ID uint32 = 0xFFFFFFFF
+const VALAR_NU7_CONSENSUS_BRANCH_ID uint32 = 0xFFFFFFFF
func (tx *Transaction) isOverwinterV3() bool {
return tx.fOverwintered &&
@@ -715,10 +724,10 @@ func (tx *Transaction) isZip225V5() bool {
tx.version == ZIP225_TX_VERSION
}
-func (tx *Transaction) isZip230V6() bool {
+func (tx *Transaction) isValarNU7V6() bool {
return tx.fOverwintered &&
- tx.nVersionGroupID == ZIP230_VERSION_GROUP_ID &&
- tx.version == ZIP230_TX_VERSION
+ tx.nVersionGroupID == VALAR_NU7_VERSION_GROUP_ID &&
+ tx.version == VALAR_NU7_TX_VERSION
}
func (tx *Transaction) isGroth16Proof() bool {
@@ -750,13 +759,13 @@ func (tx *Transaction) ParseFromSlice(data []byte) ([]byte, error) {
}
if tx.fOverwintered &&
- !(tx.isOverwinterV3() || tx.isSaplingV4() || tx.isZip225V5() || tx.isZip230V6()) {
+ !(tx.isOverwinterV3() || tx.isSaplingV4() || tx.isZip225V5() || tx.isValarNU7V6()) {
return nil, errors.New("unknown transaction format")
}
// parse the main part of the transaction
if tx.isZip225V5() {
s, err = tx.parseV5([]byte(s))
- } else if tx.isZip230V6() {
+ } else if tx.isValarNU7V6() {
s, err = tx.parseV6([]byte(s))
} else {
s, err = tx.parsePreV5([]byte(s))
diff --git a/parser/transaction_test.go b/parser/transaction_test.go
index 1dc95f38..b73a2a29 100644
--- a/parser/transaction_test.go
+++ b/parser/transaction_test.go
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"os"
+ "strings"
"testing"
)
@@ -115,7 +116,7 @@ func TestV5TransactionParser(t *testing.T) {
}
}
-func TestV6TransactionParser(t *testing.T) {
+func TestValarNU7V6TransactionParser(t *testing.T) {
rawTxData, err := hex.DecodeString("06000080ffffffffffffffff0000000000000000000000000000")
if err != nil {
t.Fatal(err)
@@ -129,13 +130,13 @@ func TestV6TransactionParser(t *testing.T) {
if len(rest) != 0 {
t.Fatalf("Test did not consume entire buffer, %d remaining", len(rest))
}
- if tx.version != ZIP230_TX_VERSION {
+ if tx.version != VALAR_NU7_TX_VERSION {
t.Fatal("version miscompare")
}
- if tx.nVersionGroupID != ZIP230_VERSION_GROUP_ID {
+ if tx.nVersionGroupID != VALAR_NU7_VERSION_GROUP_ID {
t.Fatal("nVersionGroupId miscompare")
}
- if tx.consensusBranchID != ZIP230_VERSION_GROUP_ID {
+ if tx.consensusBranchID != VALAR_NU7_CONSENSUS_BRANCH_ID {
t.Fatal("consensusBranchID miscompare")
}
if len(tx.transparentInputs) != 0 {
@@ -155,7 +156,23 @@ func TestV6TransactionParser(t *testing.T) {
}
}
-func TestV6TransactionParserSkipsIronwoodBundle(t *testing.T) {
+func TestValarNU7V6TransactionParserRejectsOtherConsensusBranchID(t *testing.T) {
+ rawTxData, err := hex.DecodeString("06000080ffffffff000000000000000000000000000000000000")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tx := NewTransaction()
+ _, err = tx.ParseFromSlice(rawTxData)
+ if err == nil {
+ t.Fatal("expected consensus branch ID error")
+ }
+ if !strings.Contains(err.Error(), "consensus branch ID") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValarNU7V6TransactionParserSkipsIronwoodBundle(t *testing.T) {
var raw bytes.Buffer
raw.Write([]byte{
0x06, 0x00, 0x00, 0x80, // fOverwintered | version 6
From 36b729359be08378c5f2b9cb1461fb5bc12f5b53 Mon Sep 17 00:00:00 2001
From: Adam Tucker
Date: Tue, 9 Jun 2026 14:43:14 -0600
Subject: [PATCH 03/11] Expose Ironwood compact sync data
---
CHANGELOG.md | 3 +
common/common.go | 19 ++-
common/common_test.go | 156 ++++++++++++++++++
common/darkside.go | 89 ++++++----
docs/rtd/index.html | 48 +++++-
frontend/service.go | 39 +++--
lightwallet-protocol/CHANGELOG.md | 7 +
.../walletrpc/compact_formats.proto | 11 +-
lightwallet-protocol/walletrpc/service.proto | 29 ++--
parser/transaction.go | 26 ++-
parser/transaction_test.go | 14 +-
walletrpc/compact_formats.pb.go | 61 +++++--
walletrpc/darkside.pb.go | 29 ++--
walletrpc/darkside.proto | 1 +
walletrpc/service.pb.go | 55 +++---
walletrpc/service_grpc.pb.go | 36 ++--
16 files changed, 486 insertions(+), 137 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ee48d1a..7a9e4bdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,9 @@ 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 V6 transaction
+ parsing support.
+
### Changed
diff --git a/common/common.go b/common/common.go
index b422810b..b5d54e74 100644
--- a/common/common.go
+++ b/common/common.go
@@ -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
@@ -172,6 +178,9 @@ type (
Orchard struct {
Size uint32
}
+ Ironwood struct {
+ Size uint32
+ }
}
}
@@ -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
}
@@ -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
@@ -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{}
diff --git a/common/common_test.go b/common/common_test.go
index e8ffcc64..76b0a117 100644
--- a/common/common_test.go
+++ b/common/common_test.go
@@ -18,6 +18,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/zcash/lightwalletd/walletrpc"
+ "google.golang.org/grpc/metadata"
)
// ------------------------------------------ Setup
@@ -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"
@@ -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
diff --git a/common/darkside.go b/common/darkside.go
index d1bf863e..d0ec04d9 100644
--- a/common/darkside.go
+++ b/common/darkside.go
@@ -34,6 +34,8 @@ type darksideState struct {
startSaplingTreeSize uint32
// Size of the Orchard commitment tree as of `startHeight - 1`.
startOrchardTreeSize uint32
+ // Size of the Ironwood commitment tree as of `startHeight - 1`.
+ startIronwoodTreeSize uint32
// These blocks (up to and including tip) are presented by mock zcashd.
// activeBlocks[0] is the block at height startHeight.
@@ -67,7 +69,7 @@ type darksideState struct {
cacheBlockIndex int
// Cache of artificial z_getsubtreebyindex subtree entries,
- // indexed by protocol (currently, sapling (0) or orchard (1)).
+ // indexed by protocol.
subtrees map[walletrpc.ShieldedProtocol]darksideProtocolSubtreeRoots
}
@@ -79,25 +81,28 @@ var state darksideState
var mutex sync.Mutex
type activeBlock struct {
- bytes []byte
- saplingTreeSize uint32
- orchardTreeSize uint32
+ bytes []byte
+ saplingTreeSize uint32
+ orchardTreeSize uint32
+ ironwoodTreeSize uint32
}
type stagedTx struct {
- height int
- saplingOutputs int
- orchardActions int
- bytes []byte
+ height int
+ saplingOutputs int
+ orchardActions int
+ ironwoodActions int
+ bytes []byte
}
type DarksideTreeState struct {
- Network string
- Height uint64
- Hash string
- Time uint32
- SaplingTree string
- OrchardTree string
+ Network string
+ Height uint64
+ Hash string
+ Time uint32
+ SaplingTree string
+ OrchardTree string
+ IronwoodTree string
}
type darksideSubtree struct {
@@ -144,7 +149,7 @@ func DarksideInit(c *BlockCache, timeout int) {
// DarksideReset allows the wallet test code to specify values
// that are returned by GetLightdInfo().
-func DarksideReset(sa int, bi, cn string, sst, sot uint32) error {
+func DarksideReset(sa int, bi, cn string, sst, sot, sit uint32) error {
Log.Info("DarksideReset(saplingActivation=", sa, ")")
mutex.Lock()
defer mutex.Unlock()
@@ -157,6 +162,7 @@ func DarksideReset(sa int, bi, cn string, sst, sot uint32) error {
chainName: cn,
startSaplingTreeSize: sst,
startOrchardTreeSize: sot,
+ startIronwoodTreeSize: sit,
cache: state.cache,
activeBlocks: make([]*activeBlock, 0),
stagedBlocks: make([][]byte, 0),
@@ -190,7 +196,7 @@ func addBlockActive(blockBytes []byte) error {
return errors.New(fmt.Sprint("adding block at height ", blockHeight,
" is lower than Sapling activation height ", state.startHeight))
}
- // Determine the Sapling and Orchard commitment tree sizes for the new block.
+ // Determine the Sapling, Orchard, and Ironwood commitment tree sizes for the new block.
countSaplingOutputs := func(block *parser.Block) uint32 {
var count = 0
for _, tx := range block.Transactions() {
@@ -205,24 +211,35 @@ func addBlockActive(blockBytes []byte) error {
}
return uint32(count)
}
+ countIronwoodActions := func(block *parser.Block) uint32 {
+ var count = 0
+ for _, tx := range block.Transactions() {
+ count += tx.IronwoodActionsCount()
+ }
+ return uint32(count)
+ }
var prevSaplingTreeSize uint32
var prevOrchardTreeSize uint32
+ var prevIronwoodTreeSize uint32
if blockHeight-state.startHeight > 0 {
// The new block connects to the previous one.
prevSaplingTreeSize = state.activeBlocks[blockHeight-state.startHeight-1].saplingTreeSize
prevOrchardTreeSize = state.activeBlocks[blockHeight-state.startHeight-1].orchardTreeSize
+ prevIronwoodTreeSize = state.activeBlocks[blockHeight-state.startHeight-1].ironwoodTreeSize
} else {
// This is the first block.
prevSaplingTreeSize = state.startSaplingTreeSize
prevOrchardTreeSize = state.startOrchardTreeSize
+ prevIronwoodTreeSize = state.startIronwoodTreeSize
}
// Drop the block that will be overwritten, and its children, then add block.
state.activeBlocks = state.activeBlocks[:blockHeight-state.startHeight]
state.activeBlocks = append(state.activeBlocks,
&activeBlock{
- bytes: blockBytes,
- saplingTreeSize: prevSaplingTreeSize + countSaplingOutputs(block),
- orchardTreeSize: prevOrchardTreeSize + countOrchardActions(block),
+ bytes: blockBytes,
+ saplingTreeSize: prevSaplingTreeSize + countSaplingOutputs(block),
+ orchardTreeSize: prevOrchardTreeSize + countOrchardActions(block),
+ ironwoodTreeSize: prevIronwoodTreeSize + countIronwoodActions(block),
})
return nil
}
@@ -321,6 +338,7 @@ func DarksideApplyStaged(height int) error {
for _, b := range state.activeBlocks[tx.height-state.startHeight:] {
b.saplingTreeSize += uint32(tx.saplingOutputs)
b.orchardTreeSize += uint32(tx.orchardActions)
+ b.ironwoodTreeSize += uint32(tx.ironwoodActions)
}
}
maxHeight := state.startHeight + len(state.activeBlocks) - 1
@@ -588,6 +606,9 @@ func darksideRawRequest(method string, params []json.RawMessage) (json.RawMessag
Orchard struct {
Size uint32
}
+ Ironwood struct {
+ Size uint32
+ }
}
}
r.Tx = make([]string, 0)
@@ -597,6 +618,7 @@ func darksideRawRequest(method string, params []json.RawMessage) (json.RawMessag
r.Hash = block.GetDisplayHashString()
r.Trees.Sapling.Size = state.activeBlocks[blockIndex].saplingTreeSize
r.Trees.Orchard.Size = state.activeBlocks[blockIndex].orchardTreeSize
+ r.Trees.Ironwood.Size = state.activeBlocks[blockIndex].ironwoodTreeSize
state.cacheBlockHash = r.Hash
state.cacheBlockIndex = blockIndex
return json.Marshal(r)
@@ -734,6 +756,9 @@ func darksideRawRequest(method string, params []json.RawMessage) (json.RawMessag
if treeState.OrchardTree != "" {
zcashdTreeState.Orchard.Commitments.FinalState = treeState.OrchardTree
}
+ if treeState.IronwoodTree != "" {
+ zcashdTreeState.Ironwood.Commitments.FinalState = treeState.IronwoodTree
+ }
return json.Marshal(zcashdTreeState)
@@ -757,7 +782,7 @@ func DarksideGetSubtreeRoots(arg *walletrpc.GetSubtreeRootsArg, resp walletrpc.C
}
sliceIndex := arg.StartIndex - subtrees.startIndex
var limit int = len(subtrees.subtrees) - int(sliceIndex)
- if limit > int(arg.MaxEntries) {
+ if arg.MaxEntries > 0 && limit > int(arg.MaxEntries) {
limit = int(arg.MaxEntries)
}
for i := 0; i < limit; i++ {
@@ -882,10 +907,11 @@ func stageTransaction(height int, txBytes []byte) error {
}
state.stagedTransactions = append(state.stagedTransactions,
stagedTx{
- height: height,
- saplingOutputs: tx.SaplingOutputsCount(),
- orchardActions: tx.OrchardActionsCount(),
- bytes: txBytes,
+ height: height,
+ saplingOutputs: tx.SaplingOutputsCount(),
+ orchardActions: tx.OrchardActionsCount(),
+ ironwoodActions: tx.IronwoodActionsCount(),
+ bytes: txBytes,
})
return nil
}
@@ -964,6 +990,7 @@ func DarksideClearAddressTransactions() error {
func DarksideClearAllTreeStates() error {
mutex.Lock()
state.stagedTreeStates = make(map[uint64]*DarksideTreeState)
+ state.stagedTreeStatesByHash = make(map[string]*DarksideTreeState)
mutex.Unlock()
return nil
}
@@ -986,16 +1013,18 @@ func DarksideRemoveTreeState(arg *walletrpc.BlockID) error {
if !state.resetted || state.stagedTreeStates == nil {
return errors.New("please call Reset first")
}
+ var treestate *DarksideTreeState
if arg.Height > 0 {
- treestate := state.stagedTreeStates[arg.Height]
- delete(state.stagedTreeStatesByHash, treestate.Hash)
- delete(state.stagedTreeStates, treestate.Height)
+ treestate = state.stagedTreeStates[arg.Height]
} else {
h := hex.EncodeToString(arg.Hash)
- treestate := state.stagedTreeStatesByHash[h]
- delete(state.stagedTreeStatesByHash, treestate.Hash)
- delete(state.stagedTreeStates, treestate.Height)
+ treestate = state.stagedTreeStatesByHash[h]
+ }
+ if treestate == nil {
+ return nil
}
+ delete(state.stagedTreeStatesByHash, treestate.Hash)
+ delete(state.stagedTreeStates, treestate.Height)
return nil
}
diff --git a/docs/rtd/index.html b/docs/rtd/index.html
index 826800bf..c1d708fb 100644
--- a/docs/rtd/index.html
+++ b/docs/rtd/index.html
@@ -359,6 +359,13 @@
the size of the Orchard note commitment tree as of the end of this block |
+
+ | ironwoodCommitmentTreeSize |
+ uint32 |
+ |
+ the size of the Ironwood note commitment tree as of the end of this block |
+
+
@@ -440,7 +447,7 @@ CompactBlock
CompactOrchardAction
- A compact representation of an [Orchard Action](https://zips.z.cash/protocol/protocol.pdf#actionencodingandconsensus).
+ A compact representation of an [Orchard Action](https://zips.z.cash/protocol/protocol.pdf#actionencodingandconsensus).
This shape is also used for Ironwood actions because Ironwood action fields
are identical to Orchard action fields.
@@ -582,7 +589,8 @@ CompactTx
stateless server and a transaction with transparent inputs, this will be
unset because the calculation requires reference to prior transactions.
If there are no transparent inputs, the fee will be calculable as:
- valueBalanceSapling + valueBalanceOrchard + sum(vPubNew) - sum(vPubOld) - sum(tOut)
+ valueBalanceSapling + valueBalanceOrchard + valueBalanceIronwood
+ + sum(vPubNew) - sum(vPubOld) - sum(tOut)
@@ -625,6 +633,14 @@ CompactTx
A sequence of transparent outputs being created by the transaction. |
+
+ | ironwoodActions |
+ CompactOrchardAction |
+ repeated |
+ A sequence of Ironwood actions in the transaction. Ironwood reuses the
+compact Orchard action shape because the pools have identical action fields. |
+
+
@@ -815,7 +831,7 @@ BlockID
BlockRange
- BlockRange specifies a series of blocks from start to end inclusive.
Both BlockIDs must be heights; specification by hash is not yet supported.
If no pool types are specified, the server should default to the legacy
behavior of returning only data relevant to the shielded (Sapling and
Orchard) pools; otherwise, the server should prune `CompactBlocks` returned
to include only data relevant to the requested pool types. Clients MUST
verify that the version of the server they are connected to are capable
of returning pruned and/or transparent data before setting `poolTypes`
to a non-empty value.
+ BlockRange specifies a series of blocks from start to end inclusive.
Both BlockIDs must be heights; specification by hash is not yet supported.
If no pool types are specified, the server should default to the legacy
behavior of returning only data relevant to the shielded (Sapling, Orchard,
and Ironwood) pools; otherwise, the server should prune `CompactBlock`s returned
to include only data relevant to the requested pool types. Clients MUST
verify that the version of the server they are connected to are capable
of returning pruned and/or transparent data before setting `poolTypes`
to a non-empty value.
@@ -1039,8 +1055,8 @@ GetMempoolTxRequest
repeated |
The server must prune `CompactTx`s returned to include only data
relevant to the requested pool types. If no pool types are specified,
-the server should default to the legacy behavior of returning only data
-relevant to the shielded (Sapling and Orchard) pools. |
+the server should default to returning only data relevant to the shielded
+(Sapling, Orchard, and Ironwood) pools.
@@ -1462,6 +1478,13 @@ TreeState
orchard commitment tree state |
+
+ | ironwoodTree |
+ string |
+ |
+ ironwood commitment tree state |
+
+
@@ -1541,6 +1564,12 @@ PoolType
|
+
+ | IRONWOOD |
+ 4 |
+ |
+
+
@@ -1564,6 +1593,12 @@ ShieldedProtocol
|
+
+ | ironwood |
+ 2 |
+ |
+
+
@@ -1720,7 +1755,7 @@ CompactTxStreamer
GetSubtreeRootsArg |
SubtreeRoot stream |
Returns a stream of information about roots of subtrees of the note commitment tree
-for the specified shielded protocol (Sapling or Orchard). |
+for the specified shielded protocol (Sapling, Orchard, or Ironwood).
@@ -1948,4 +1983,3 @@ Scalar Value Types