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 @@

ChainMetadata

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.

ironwoodActionsCompactOrchardActionrepeated

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

+the server should default to returning only data relevant to the shielded +(Sapling, Orchard, and Ironwood) pools.

@@ -1462,6 +1478,13 @@

TreeState

+ + + + + + +
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.

orchard commitment tree state

ironwoodTreestring

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

- diff --git a/frontend/service.go b/frontend/service.go index 2dff5a19..0595bb1f 100644 --- a/frontend/service.go +++ b/frontend/service.go @@ -213,6 +213,9 @@ func (s *lwdStreamer) GetBlockNullifiers(ctx context.Context, id *walletrpc.Bloc for i, action := range tx.Actions { tx.Actions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} } + for i, action := range tx.IronwoodActions { + tx.IronwoodActions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} + } tx.Outputs = nil tx.Vin = nil tx.Vout = nil @@ -220,6 +223,7 @@ func (s *lwdStreamer) GetBlockNullifiers(ctx context.Context, id *walletrpc.Bloc // these are not needed (we prefer to save bandwidth) cBlock.ChainMetadata.SaplingCommitmentTreeSize = 0 cBlock.ChainMetadata.OrchardCommitmentTreeSize = 0 + cBlock.ChainMetadata.IronwoodCommitmentTreeSize = 0 common.Log.Tracef(" return: %+v\n", cBlock) return cBlock, err } @@ -292,11 +296,15 @@ func (s *lwdStreamer) GetBlockRangeNullifiers(span *walletrpc.BlockRange, resp w for i, action := range tx.Actions { tx.Actions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} } + for i, action := range tx.IronwoodActions { + tx.IronwoodActions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} + } tx.Outputs = nil } // these are not needed (we prefer to save bandwidth) cBlock.ChainMetadata.SaplingCommitmentTreeSize = 0 cBlock.ChainMetadata.OrchardCommitmentTreeSize = 0 + cBlock.ChainMetadata.IronwoodCommitmentTreeSize = 0 if err := resp.Send(cBlock); err != nil { return err } @@ -374,12 +382,13 @@ func (s *lwdStreamer) GetTreeState(ctx context.Context, id *walletrpc.BlockID) ( "GetTreeState: z_gettreestate did not return treestate") } r := &walletrpc.TreeState{ - Network: s.chainName, - Height: uint64(gettreestateReply.Height), - Hash: gettreestateReply.Hash, - Time: gettreestateReply.Time, - SaplingTree: gettreestateReply.Sapling.Commitments.FinalState, - OrchardTree: gettreestateReply.Orchard.Commitments.FinalState, + Network: s.chainName, + Height: uint64(gettreestateReply.Height), + Hash: gettreestateReply.Hash, + Time: gettreestateReply.Time, + SaplingTree: gettreestateReply.Sapling.Commitments.FinalState, + OrchardTree: gettreestateReply.Orchard.Commitments.FinalState, + IronwoodTree: gettreestateReply.Ironwood.Commitments.FinalState, } common.Log.Tracef(" return: %+v\n", r) return r, nil @@ -608,10 +617,11 @@ func (s *lwdStreamer) GetMempoolTx(exclude *walletrpc.GetMempoolTxRequest, resp return status.Errorf(codes.InvalidArgument, "invalid pool type requested") } if len(exclude.PoolTypes) == 0 { - // legacy behavior: return only blocks containing shielded components. + // Return all shielded pools when no explicit pool filter is requested. exclude.PoolTypes = []walletrpc.PoolType{ walletrpc.PoolType_SAPLING, walletrpc.PoolType_ORCHARD, + walletrpc.PoolType_IRONWOOD, } } s.mutex.Lock() @@ -837,6 +847,7 @@ func (s *lwdStreamer) GetSubtreeRoots(arg *walletrpc.GetSubtreeRootsArg, resp wa switch arg.ShieldedProtocol { case walletrpc.ShieldedProtocol_sapling: case walletrpc.ShieldedProtocol_orchard: + case walletrpc.ShieldedProtocol_ironwood: break default: return errors.New("unrecognized shielded protocol") @@ -953,6 +964,7 @@ func (s *DarksideStreamer) Reset(ctx context.Context, ms *walletrpc.DarksideMeta ms.ChainName, ms.StartSaplingCommitmentTreeSize, ms.StartOrchardCommitmentTreeSize, + ms.StartIronwoodCommitmentTreeSize, ) if err != nil { common.Log.Fatal("Reset failed, error: ", err.Error()) @@ -1104,12 +1116,13 @@ func (s *DarksideStreamer) ClearAddressTransactions(ctx context.Context, arg *wa // Adds a tree state to the cached tree states func (s *DarksideStreamer) AddTreeState(ctx context.Context, arg *walletrpc.TreeState) (*walletrpc.Empty, error) { tree := common.DarksideTreeState{ - Network: arg.Network, - Height: arg.Height, - Hash: arg.Hash, - Time: arg.Time, - SaplingTree: arg.SaplingTree, - OrchardTree: arg.OrchardTree, + Network: arg.Network, + Height: arg.Height, + Hash: arg.Hash, + Time: arg.Time, + SaplingTree: arg.SaplingTree, + OrchardTree: arg.OrchardTree, + IronwoodTree: arg.IronwoodTree, } err := common.DarksideAddTreeState(tree) diff --git a/lightwallet-protocol/CHANGELOG.md b/lightwallet-protocol/CHANGELOG.md index 7f5a08d4..daeff28e 100644 --- a/lightwallet-protocol/CHANGELOG.md +++ b/lightwallet-protocol/CHANGELOG.md @@ -7,6 +7,13 @@ and this library adheres to Rust's notion of ## Unreleased +### Added +- `compact_formats.ChainMetadata.ironwoodCommitmentTreeSize` +- `compact_formats.CompactTx.ironwoodActions` +- `service.PoolType.IRONWOOD` +- `service.TreeState.ironwoodTree` +- `service.ShieldedProtocol.ironwood` + ## [v0.4.1] - 2026-02-20 ### Added diff --git a/lightwallet-protocol/walletrpc/compact_formats.proto b/lightwallet-protocol/walletrpc/compact_formats.proto index c68ce39d..0957bfed 100644 --- a/lightwallet-protocol/walletrpc/compact_formats.proto +++ b/lightwallet-protocol/walletrpc/compact_formats.proto @@ -14,6 +14,7 @@ option swift_prefix = ""; message ChainMetadata { uint32 saplingCommitmentTreeSize = 1; // the size of the Sapling note commitment tree as of the end of this block uint32 orchardCommitmentTreeSize = 2; // the size of the Orchard note commitment tree as of the end of this block + uint32 ironwoodCommitmentTreeSize = 3; // the size of the Ironwood note commitment tree as of the end of this block } // A compact representation of a Zcash block. @@ -60,7 +61,8 @@ message 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) uint32 fee = 3; repeated CompactSaplingSpend spends = 4; @@ -77,6 +79,10 @@ message CompactTx { // A sequence of transparent outputs being created by the transaction. repeated TxOut vout = 8; + + // A sequence of Ironwood actions in the transaction. Ironwood reuses the + // compact Orchard action shape because the pools have identical action fields. + repeated CompactOrchardAction ironwoodActions = 9; } // A compact representation of a transparent transaction input. @@ -122,6 +128,9 @@ message CompactSaplingOutput { } // 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. message CompactOrchardAction { bytes nullifier = 1; // [32] The nullifier of the input note bytes cmx = 2; // [32] The x-coordinate of the note commitment for the output note diff --git a/lightwallet-protocol/walletrpc/service.proto b/lightwallet-protocol/walletrpc/service.proto index 2f30f676..f691264a 100644 --- a/lightwallet-protocol/walletrpc/service.proto +++ b/lightwallet-protocol/walletrpc/service.proto @@ -14,6 +14,7 @@ enum PoolType { TRANSPARENT = 1; SAPLING = 2; ORCHARD = 3; + IRONWOOD = 4; } // A BlockID message contains identifiers to select a block: a height or a @@ -29,8 +30,8 @@ message BlockID { // 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 `CompactBlock`s returned +// 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` @@ -168,8 +169,8 @@ message GetMempoolTxRequest { reserved 2; // 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. repeated PoolType poolTypes = 3; } @@ -181,11 +182,13 @@ message TreeState { uint32 time = 4; // Unix epoch time when the block was mined string saplingTree = 5; // sapling commitment tree state string orchardTree = 6; // orchard commitment tree state + string ironwoodTree = 7; // ironwood commitment tree state } enum ShieldedProtocol { sapling = 0; orchard = 1; + ironwood = 2; } message GetSubtreeRootsArg { @@ -227,7 +230,7 @@ service CompactTxStreamer { // The returned `CompactBlock` includes transaction data for all value // pools, including transparent inputs (`vin`) and outputs (`vout`). This // differs from `GetBlockRange`, which supports filtering by pool type and - // defaults to returning only shielded (Sapling and Orchard) data. Clients + // defaults to returning only shielded (Sapling, Orchard, and Ironwood) data. Clients // that require only data for specific pools should use `GetBlockRange` // with the appropriate `poolTypes` set. // @@ -237,9 +240,10 @@ service CompactTxStreamer { rpc GetBlock(BlockID) returns (CompactBlock) {} // Return a compact block containing only nullifier information for the - // shielded pools (Sapling spend nullifiers and Orchard action nullifiers). - // Transparent transaction data, Sapling outputs, full Orchard action data, - // and commitment tree sizes are not included. + // shielded pools (Sapling spend nullifiers, Orchard action nullifiers, and + // Ironwood action nullifiers). Transparent transaction data, Sapling + // outputs, full Orchard/Ironwood action data, and commitment tree sizes are + // not included. // // Note: this method is deprecated; use `GetBlockRange` with the // appropriate `poolTypes` instead. @@ -256,9 +260,10 @@ service CompactTxStreamer { // Return a stream of compact blocks for the specified range, where each // block contains only nullifier information for the shielded pools - // (Sapling spend nullifiers and Orchard action nullifiers). Transparent - // transaction data, Sapling outputs, full Orchard action data, and - // commitment tree sizes are not included. Implementations MUST ignore any + // (Sapling spend nullifiers, Orchard action nullifiers, and Ironwood action + // nullifiers). Transparent transaction data, Sapling outputs, full + // Orchard/Ironwood action data, and commitment tree sizes are not included. + // Implementations MUST ignore any // `PoolType::TRANSPARENT` member of the `poolTypes` field of the request. // // Note: this method is deprecated; use `GetBlockRange` with the @@ -312,7 +317,7 @@ service CompactTxStreamer { rpc GetLatestTreeState(Empty) returns (TreeState) {} // 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). rpc GetSubtreeRoots(GetSubtreeRootsArg) returns (stream SubtreeRoot) {} rpc GetAddressUtxos(GetAddressUtxosArg) returns (GetAddressUtxosReplyList) {} diff --git a/parser/transaction.go b/parser/transaction.go index 8fb4327d..069eabb0 100644 --- a/parser/transaction.go +++ b/parser/transaction.go @@ -30,7 +30,8 @@ type rawTransaction struct { //joinSplitPubKey []byte //joinSplitSig []byte //bindingSigSapling []byte - orchardActions []action + orchardActions []action + ironwoodActions []action } // Txin format as described in https://en.bitcoin.it/wiki/Transaction @@ -394,6 +395,11 @@ func (tx *Transaction) OrchardActionsCount() int { return len(tx.orchardActions) } +// IronwoodActionsCount returns the number of Ironwood actions in the transaction. +func (tx *Transaction) IronwoodActionsCount() int { + return len(tx.ironwoodActions) +} + // ToCompact converts the given (full) transaction to compact format. func (tx *Transaction) ToCompact(index int) *walletrpc.CompactTx { // we don't need to store the vin (transparent inputs) of a coinbase tx @@ -405,11 +411,12 @@ func (tx *Transaction) ToCompact(index int) *walletrpc.CompactTx { Index: uint64(index), // index is contextual Txid: hash32.ToSlice(tx.GetEncodableHash()), //Fee: 0, // TODO: calculate fees - Spends: make([]*walletrpc.CompactSaplingSpend, len(tx.shieldedSpends)), - Outputs: make([]*walletrpc.CompactSaplingOutput, len(tx.shieldedOutputs)), - Actions: make([]*walletrpc.CompactOrchardAction, len(tx.orchardActions)), - Vin: make([]*walletrpc.CompactTxIn, vinLen), - Vout: make([]*walletrpc.TxOut, len(tx.transparentOutputs)), + Spends: make([]*walletrpc.CompactSaplingSpend, len(tx.shieldedSpends)), + Outputs: make([]*walletrpc.CompactSaplingOutput, len(tx.shieldedOutputs)), + Actions: make([]*walletrpc.CompactOrchardAction, len(tx.orchardActions)), + Vin: make([]*walletrpc.CompactTxIn, vinLen), + Vout: make([]*walletrpc.TxOut, len(tx.transparentOutputs)), + IronwoodActions: make([]*walletrpc.CompactOrchardAction, len(tx.ironwoodActions)), } for i, spend := range tx.shieldedSpends { ctx.Spends[i] = spend.ToCompact() @@ -420,6 +427,9 @@ func (tx *Transaction) ToCompact(index int) *walletrpc.CompactTx { for i, a := range tx.orchardActions { ctx.Actions[i] = a.ToCompact() } + for i, a := range tx.ironwoodActions { + ctx.IronwoodActions[i] = a.ToCompact() + } if vinLen > 0 { for i, tinput := range tx.transparentInputs { ctx.Vin[i] = tinput.ToCompact() @@ -585,10 +595,12 @@ func (tx *Transaction) parseV6(data []byte) ([]byte, error) { } tx.orchardActions = orchardActions - s, _, err = parseOrchardActionsBundle([]byte(s), "Ironwood") + var ironwoodActions []action + s, ironwoodActions, err = parseOrchardActionsBundle([]byte(s), "Ironwood") if err != nil { return nil, err } + tx.ironwoodActions = ironwoodActions return s, nil } diff --git a/parser/transaction_test.go b/parser/transaction_test.go index b73a2a29..bd971b03 100644 --- a/parser/transaction_test.go +++ b/parser/transaction_test.go @@ -154,6 +154,9 @@ func TestValarNU7V6TransactionParser(t *testing.T) { if len(tx.orchardActions) != 0 { t.Fatal("NActionsOrchard miscompare") } + if len(tx.ironwoodActions) != 0 { + t.Fatal("NActionsIronwood miscompare") + } } func TestValarNU7V6TransactionParserRejectsOtherConsensusBranchID(t *testing.T) { @@ -172,7 +175,7 @@ func TestValarNU7V6TransactionParserRejectsOtherConsensusBranchID(t *testing.T) } } -func TestValarNU7V6TransactionParserSkipsIronwoodBundle(t *testing.T) { +func TestValarNU7V6TransactionParserKeepsIronwoodBundle(t *testing.T) { var raw bytes.Buffer raw.Write([]byte{ 0x06, 0x00, 0x00, 0x80, // fOverwintered | version 6 @@ -203,9 +206,16 @@ func TestValarNU7V6TransactionParserSkipsIronwoodBundle(t *testing.T) { if len(tx.orchardActions) != 1 { t.Fatal("NActionsOrchard miscompare") } - if len(tx.ToCompact(0).Actions) != 1 { + if len(tx.ironwoodActions) != 1 { + t.Fatal("NActionsIronwood miscompare") + } + compactTx := tx.ToCompact(0) + if len(compactTx.Actions) != 1 { t.Fatal("compact orchard action count miscompare") } + if len(compactTx.IronwoodActions) != 1 { + t.Fatal("compact ironwood action count miscompare") + } if len(tx.rawBytes) != raw.Len()-len(rest) { t.Fatal("raw transaction length miscompare") } diff --git a/walletrpc/compact_formats.pb.go b/walletrpc/compact_formats.pb.go index 6ae4ee5b..35e08ac6 100644 --- a/walletrpc/compact_formats.pb.go +++ b/walletrpc/compact_formats.pb.go @@ -27,11 +27,12 @@ const ( // Information about the state of the chain as of a given block. type ChainMetadata struct { - state protoimpl.MessageState `protogen:"open.v1"` - SaplingCommitmentTreeSize uint32 `protobuf:"varint,1,opt,name=saplingCommitmentTreeSize,proto3" json:"saplingCommitmentTreeSize,omitempty"` // the size of the Sapling note commitment tree as of the end of this block - OrchardCommitmentTreeSize uint32 `protobuf:"varint,2,opt,name=orchardCommitmentTreeSize,proto3" json:"orchardCommitmentTreeSize,omitempty"` // the size of the Orchard note commitment tree as of the end of this block - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SaplingCommitmentTreeSize uint32 `protobuf:"varint,1,opt,name=saplingCommitmentTreeSize,proto3" json:"saplingCommitmentTreeSize,omitempty"` // the size of the Sapling note commitment tree as of the end of this block + OrchardCommitmentTreeSize uint32 `protobuf:"varint,2,opt,name=orchardCommitmentTreeSize,proto3" json:"orchardCommitmentTreeSize,omitempty"` // the size of the Orchard note commitment tree as of the end of this block + IronwoodCommitmentTreeSize uint32 `protobuf:"varint,3,opt,name=ironwoodCommitmentTreeSize,proto3" json:"ironwoodCommitmentTreeSize,omitempty"` // the size of the Ironwood note commitment tree as of the end of this block + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChainMetadata) Reset() { @@ -78,6 +79,13 @@ func (x *ChainMetadata) GetOrchardCommitmentTreeSize() uint32 { return 0 } +func (x *ChainMetadata) GetIronwoodCommitmentTreeSize() uint32 { + if x != nil { + return x.IronwoodCommitmentTreeSize + } + return 0 +} + // A compact representation of a Zcash block. // // CompactBlock is a packaging of ONLY the data from a block that's needed to: @@ -211,7 +219,8 @@ type CompactTx struct { // 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) Fee uint32 `protobuf:"varint,3,opt,name=fee,proto3" json:"fee,omitempty"` Spends []*CompactSaplingSpend `protobuf:"bytes,4,rep,name=spends,proto3" json:"spends,omitempty"` Outputs []*CompactSaplingOutput `protobuf:"bytes,5,rep,name=outputs,proto3" json:"outputs,omitempty"` @@ -224,9 +233,12 @@ type CompactTx struct { // first transaction in any block. Vin []*CompactTxIn `protobuf:"bytes,7,rep,name=vin,proto3" json:"vin,omitempty"` // A sequence of transparent outputs being created by the transaction. - Vout []*TxOut `protobuf:"bytes,8,rep,name=vout,proto3" json:"vout,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Vout []*TxOut `protobuf:"bytes,8,rep,name=vout,proto3" json:"vout,omitempty"` + // A sequence of Ironwood actions in the transaction. Ironwood reuses the + // compact Orchard action shape because the pools have identical action fields. + IronwoodActions []*CompactOrchardAction `protobuf:"bytes,9,rep,name=ironwoodActions,proto3" json:"ironwoodActions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CompactTx) Reset() { @@ -315,6 +327,13 @@ func (x *CompactTx) GetVout() []*TxOut { return nil } +func (x *CompactTx) GetIronwoodActions() []*CompactOrchardAction { + if x != nil { + return x.IronwoodActions + } + return nil +} + // A compact representation of a transparent transaction input. type CompactTxIn struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -544,6 +563,9 @@ func (x *CompactSaplingOutput) GetCiphertext() []byte { } // 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. type CompactOrchardAction struct { state protoimpl.MessageState `protogen:"open.v1"` Nullifier []byte `protobuf:"bytes,1,opt,name=nullifier,proto3" json:"nullifier,omitempty"` // [32] The nullifier of the input note @@ -616,10 +638,11 @@ var File_compact_formats_proto protoreflect.FileDescriptor const file_compact_formats_proto_rawDesc = "" + "\n" + - "\x15compact_formats.proto\x12\x15cash.z.wallet.sdk.rpc\"\x8b\x01\n" + + "\x15compact_formats.proto\x12\x15cash.z.wallet.sdk.rpc\"\xcb\x01\n" + "\rChainMetadata\x12<\n" + "\x19saplingCommitmentTreeSize\x18\x01 \x01(\rR\x19saplingCommitmentTreeSize\x12<\n" + - "\x19orchardCommitmentTreeSize\x18\x02 \x01(\rR\x19orchardCommitmentTreeSize\"\xa6\x02\n" + + "\x19orchardCommitmentTreeSize\x18\x02 \x01(\rR\x19orchardCommitmentTreeSize\x12>\n" + + "\x1aironwoodCommitmentTreeSize\x18\x03 \x01(\rR\x1aironwoodCommitmentTreeSize\"\xa6\x02\n" + "\fCompactBlock\x12\"\n" + "\fprotoVersion\x18\x01 \x01(\rR\fprotoVersion\x12\x16\n" + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x12\n" + @@ -628,7 +651,7 @@ const file_compact_formats_proto_rawDesc = "" + "\x04time\x18\x05 \x01(\rR\x04time\x12\x16\n" + "\x06header\x18\x06 \x01(\fR\x06header\x122\n" + "\x03vtx\x18\a \x03(\v2 .cash.z.wallet.sdk.rpc.CompactTxR\x03vtx\x12J\n" + - "\rchainMetadata\x18\b \x01(\v2$.cash.z.wallet.sdk.rpc.ChainMetadataR\rchainMetadata\"\x81\x03\n" + + "\rchainMetadata\x18\b \x01(\v2$.cash.z.wallet.sdk.rpc.ChainMetadataR\rchainMetadata\"\xd8\x03\n" + "\tCompactTx\x12\x14\n" + "\x05index\x18\x01 \x01(\x04R\x05index\x12\x12\n" + "\x04txid\x18\x02 \x01(\fR\x04txid\x12\x10\n" + @@ -637,7 +660,8 @@ const file_compact_formats_proto_rawDesc = "" + "\aoutputs\x18\x05 \x03(\v2+.cash.z.wallet.sdk.rpc.CompactSaplingOutputR\aoutputs\x12E\n" + "\aactions\x18\x06 \x03(\v2+.cash.z.wallet.sdk.rpc.CompactOrchardActionR\aactions\x124\n" + "\x03vin\x18\a \x03(\v2\".cash.z.wallet.sdk.rpc.CompactTxInR\x03vin\x120\n" + - "\x04vout\x18\b \x03(\v2\x1c.cash.z.wallet.sdk.rpc.TxOutR\x04vout\"S\n" + + "\x04vout\x18\b \x03(\v2\x1c.cash.z.wallet.sdk.rpc.TxOutR\x04vout\x12U\n" + + "\x0fironwoodActions\x18\t \x03(\v2+.cash.z.wallet.sdk.rpc.CompactOrchardActionR\x0fironwoodActions\"S\n" + "\vCompactTxIn\x12 \n" + "\vprevoutTxid\x18\x01 \x01(\fR\vprevoutTxid\x12\"\n" + "\fprevoutIndex\x18\x02 \x01(\rR\fprevoutIndex\"A\n" + @@ -691,11 +715,12 @@ var file_compact_formats_proto_depIdxs = []int32{ 7, // 4: cash.z.wallet.sdk.rpc.CompactTx.actions:type_name -> cash.z.wallet.sdk.rpc.CompactOrchardAction 3, // 5: cash.z.wallet.sdk.rpc.CompactTx.vin:type_name -> cash.z.wallet.sdk.rpc.CompactTxIn 4, // 6: cash.z.wallet.sdk.rpc.CompactTx.vout:type_name -> cash.z.wallet.sdk.rpc.TxOut - 7, // [7:7] is the sub-list for method output_type - 7, // [7:7] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 7, // 7: cash.z.wallet.sdk.rpc.CompactTx.ironwoodActions:type_name -> cash.z.wallet.sdk.rpc.CompactOrchardAction + 8, // [8:8] is the sub-list for method output_type + 8, // [8:8] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_compact_formats_proto_init() } diff --git a/walletrpc/darkside.pb.go b/walletrpc/darkside.pb.go index 6cb8cdf4..f3e43f48 100644 --- a/walletrpc/darkside.pb.go +++ b/walletrpc/darkside.pb.go @@ -26,14 +26,15 @@ const ( ) type DarksideMetaState struct { - state protoimpl.MessageState `protogen:"open.v1"` - SaplingActivation int32 `protobuf:"varint,1,opt,name=saplingActivation,proto3" json:"saplingActivation,omitempty"` - BranchID string `protobuf:"bytes,2,opt,name=branchID,proto3" json:"branchID,omitempty"` - ChainName string `protobuf:"bytes,3,opt,name=chainName,proto3" json:"chainName,omitempty"` - StartSaplingCommitmentTreeSize uint32 `protobuf:"varint,4,opt,name=startSaplingCommitmentTreeSize,proto3" json:"startSaplingCommitmentTreeSize,omitempty"` - StartOrchardCommitmentTreeSize uint32 `protobuf:"varint,5,opt,name=startOrchardCommitmentTreeSize,proto3" json:"startOrchardCommitmentTreeSize,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SaplingActivation int32 `protobuf:"varint,1,opt,name=saplingActivation,proto3" json:"saplingActivation,omitempty"` + BranchID string `protobuf:"bytes,2,opt,name=branchID,proto3" json:"branchID,omitempty"` + ChainName string `protobuf:"bytes,3,opt,name=chainName,proto3" json:"chainName,omitempty"` + StartSaplingCommitmentTreeSize uint32 `protobuf:"varint,4,opt,name=startSaplingCommitmentTreeSize,proto3" json:"startSaplingCommitmentTreeSize,omitempty"` + StartOrchardCommitmentTreeSize uint32 `protobuf:"varint,5,opt,name=startOrchardCommitmentTreeSize,proto3" json:"startOrchardCommitmentTreeSize,omitempty"` + StartIronwoodCommitmentTreeSize uint32 `protobuf:"varint,6,opt,name=startIronwoodCommitmentTreeSize,proto3" json:"startIronwoodCommitmentTreeSize,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DarksideMetaState) Reset() { @@ -101,6 +102,13 @@ func (x *DarksideMetaState) GetStartOrchardCommitmentTreeSize() uint32 { return 0 } +func (x *DarksideMetaState) GetStartIronwoodCommitmentTreeSize() uint32 { + if x != nil { + return x.StartIronwoodCommitmentTreeSize + } + return 0 +} + // A block is a hex-encoded string. type DarksideBlock struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -475,13 +483,14 @@ var File_darkside_proto protoreflect.FileDescriptor const file_darkside_proto_rawDesc = "" + "\n" + - "\x0edarkside.proto\x12\x15cash.z.wallet.sdk.rpc\x1a\rservice.proto\"\x8b\x02\n" + + "\x0edarkside.proto\x12\x15cash.z.wallet.sdk.rpc\x1a\rservice.proto\"\xd5\x02\n" + "\x11DarksideMetaState\x12,\n" + "\x11saplingActivation\x18\x01 \x01(\x05R\x11saplingActivation\x12\x1a\n" + "\bbranchID\x18\x02 \x01(\tR\bbranchID\x12\x1c\n" + "\tchainName\x18\x03 \x01(\tR\tchainName\x12F\n" + "\x1estartSaplingCommitmentTreeSize\x18\x04 \x01(\rR\x1estartSaplingCommitmentTreeSize\x12F\n" + - "\x1estartOrchardCommitmentTreeSize\x18\x05 \x01(\rR\x1estartOrchardCommitmentTreeSize\"%\n" + + "\x1estartOrchardCommitmentTreeSize\x18\x05 \x01(\rR\x1estartOrchardCommitmentTreeSize\x12H\n" + + "\x1fstartIronwoodCommitmentTreeSize\x18\x06 \x01(\rR\x1fstartIronwoodCommitmentTreeSize\"%\n" + "\rDarksideBlock\x12\x14\n" + "\x05block\x18\x01 \x01(\tR\x05block\"%\n" + "\x11DarksideBlocksURL\x12\x10\n" + diff --git a/walletrpc/darkside.proto b/walletrpc/darkside.proto index def599a8..b93c9f9b 100644 --- a/walletrpc/darkside.proto +++ b/walletrpc/darkside.proto @@ -14,6 +14,7 @@ message DarksideMetaState { string chainName = 3; uint32 startSaplingCommitmentTreeSize = 4; uint32 startOrchardCommitmentTreeSize = 5; + uint32 startIronwoodCommitmentTreeSize = 6; } // A block is a hex-encoded string. diff --git a/walletrpc/service.pb.go b/walletrpc/service.pb.go index 52b14898..09654aee 100644 --- a/walletrpc/service.pb.go +++ b/walletrpc/service.pb.go @@ -33,6 +33,7 @@ const ( PoolType_TRANSPARENT PoolType = 1 PoolType_SAPLING PoolType = 2 PoolType_ORCHARD PoolType = 3 + PoolType_IRONWOOD PoolType = 4 ) // Enum value maps for PoolType. @@ -42,12 +43,14 @@ var ( 1: "TRANSPARENT", 2: "SAPLING", 3: "ORCHARD", + 4: "IRONWOOD", } PoolType_value = map[string]int32{ "POOL_TYPE_INVALID": 0, "TRANSPARENT": 1, "SAPLING": 2, "ORCHARD": 3, + "IRONWOOD": 4, } ) @@ -81,8 +84,9 @@ func (PoolType) EnumDescriptor() ([]byte, []int) { type ShieldedProtocol int32 const ( - ShieldedProtocol_sapling ShieldedProtocol = 0 - ShieldedProtocol_orchard ShieldedProtocol = 1 + ShieldedProtocol_sapling ShieldedProtocol = 0 + ShieldedProtocol_orchard ShieldedProtocol = 1 + ShieldedProtocol_ironwood ShieldedProtocol = 2 ) // Enum value maps for ShieldedProtocol. @@ -90,10 +94,12 @@ var ( ShieldedProtocol_name = map[int32]string{ 0: "sapling", 1: "orchard", + 2: "ironwood", } ShieldedProtocol_value = map[string]int32{ - "sapling": 0, - "orchard": 1, + "sapling": 0, + "orchard": 1, + "ironwood": 2, } ) @@ -184,8 +190,8 @@ func (x *BlockID) GetHash() []byte { // 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 `CompactBlock`s returned +// 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` @@ -1006,8 +1012,8 @@ type GetMempoolTxRequest struct { ExcludeTxidSuffixes [][]byte `protobuf:"bytes,1,rep,name=exclude_txid_suffixes,json=excludeTxidSuffixes,proto3" json:"exclude_txid_suffixes,omitempty"` // 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. PoolTypes []PoolType `protobuf:"varint,3,rep,packed,name=poolTypes,proto3,enum=cash.z.wallet.sdk.rpc.PoolType" json:"poolTypes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1060,12 +1066,13 @@ func (x *GetMempoolTxRequest) GetPoolTypes() []PoolType { // The TreeState is derived from the Zcash z_gettreestate rpc. type TreeState struct { state protoimpl.MessageState `protogen:"open.v1"` - Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` // "main" or "test" - Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` // block height - Hash string `protobuf:"bytes,3,opt,name=hash,proto3" json:"hash,omitempty"` // block id - Time uint32 `protobuf:"varint,4,opt,name=time,proto3" json:"time,omitempty"` // Unix epoch time when the block was mined - SaplingTree string `protobuf:"bytes,5,opt,name=saplingTree,proto3" json:"saplingTree,omitempty"` // sapling commitment tree state - OrchardTree string `protobuf:"bytes,6,opt,name=orchardTree,proto3" json:"orchardTree,omitempty"` // orchard commitment tree state + Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` // "main" or "test" + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` // block height + Hash string `protobuf:"bytes,3,opt,name=hash,proto3" json:"hash,omitempty"` // block id + Time uint32 `protobuf:"varint,4,opt,name=time,proto3" json:"time,omitempty"` // Unix epoch time when the block was mined + SaplingTree string `protobuf:"bytes,5,opt,name=saplingTree,proto3" json:"saplingTree,omitempty"` // sapling commitment tree state + OrchardTree string `protobuf:"bytes,6,opt,name=orchardTree,proto3" json:"orchardTree,omitempty"` // orchard commitment tree state + IronwoodTree string `protobuf:"bytes,7,opt,name=ironwoodTree,proto3" json:"ironwoodTree,omitempty"` // ironwood commitment tree state unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1142,6 +1149,13 @@ func (x *TreeState) GetOrchardTree() string { return "" } +func (x *TreeState) GetIronwoodTree() string { + if x != nil { + return x.IronwoodTree + } + return "" +} + type GetSubtreeRootsArg struct { state protoimpl.MessageState `protogen:"open.v1"` StartIndex uint32 `protobuf:"varint,1,opt,name=startIndex,proto3" json:"startIndex,omitempty"` // Index identifying where to start returning subtree roots @@ -1516,14 +1530,15 @@ const file_service_proto_rawDesc = "" + "\bvalueZat\x18\x01 \x01(\x03R\bvalueZat\"\x8e\x01\n" + "\x13GetMempoolTxRequest\x122\n" + "\x15exclude_txid_suffixes\x18\x01 \x03(\fR\x13excludeTxidSuffixes\x12=\n" + - "\tpoolTypes\x18\x03 \x03(\x0e2\x1f.cash.z.wallet.sdk.rpc.PoolTypeR\tpoolTypesJ\x04\b\x02\x10\x03\"\xa9\x01\n" + + "\tpoolTypes\x18\x03 \x03(\x0e2\x1f.cash.z.wallet.sdk.rpc.PoolTypeR\tpoolTypesJ\x04\b\x02\x10\x03\"\xcd\x01\n" + "\tTreeState\x12\x18\n" + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x16\n" + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x12\n" + "\x04hash\x18\x03 \x01(\tR\x04hash\x12\x12\n" + "\x04time\x18\x04 \x01(\rR\x04time\x12 \n" + "\vsaplingTree\x18\x05 \x01(\tR\vsaplingTree\x12 \n" + - "\vorchardTree\x18\x06 \x01(\tR\vorchardTree\"\xa9\x01\n" + + "\vorchardTree\x18\x06 \x01(\tR\vorchardTree\x12\"\n" + + "\fironwoodTree\x18\a \x01(\tR\fironwoodTree\"\xa9\x01\n" + "\x12GetSubtreeRootsArg\x12\x1e\n" + "\n" + "startIndex\x18\x01 \x01(\rR\n" + @@ -1550,15 +1565,17 @@ const file_service_proto_rawDesc = "" + "\bvalueZat\x18\x04 \x01(\x03R\bvalueZat\x12\x16\n" + "\x06height\x18\x05 \x01(\x04R\x06height\"k\n" + "\x18GetAddressUtxosReplyList\x12O\n" + - "\faddressUtxos\x18\x01 \x03(\v2+.cash.z.wallet.sdk.rpc.GetAddressUtxosReplyR\faddressUtxos*L\n" + + "\faddressUtxos\x18\x01 \x03(\v2+.cash.z.wallet.sdk.rpc.GetAddressUtxosReplyR\faddressUtxos*Z\n" + "\bPoolType\x12\x15\n" + "\x11POOL_TYPE_INVALID\x10\x00\x12\x0f\n" + "\vTRANSPARENT\x10\x01\x12\v\n" + "\aSAPLING\x10\x02\x12\v\n" + - "\aORCHARD\x10\x03*,\n" + + "\aORCHARD\x10\x03\x12\f\n" + + "\bIRONWOOD\x10\x04*:\n" + "\x10ShieldedProtocol\x12\v\n" + "\asapling\x10\x00\x12\v\n" + - "\aorchard\x10\x012\xa8\x0f\n" + + "\aorchard\x10\x01\x12\f\n" + + "\bironwood\x10\x022\xa8\x0f\n" + "\x11CompactTxStreamer\x12T\n" + "\x0eGetLatestBlock\x12 .cash.z.wallet.sdk.rpc.ChainSpec\x1a\x1e.cash.z.wallet.sdk.rpc.BlockID\"\x00\x12Q\n" + "\bGetBlock\x12\x1e.cash.z.wallet.sdk.rpc.BlockID\x1a#.cash.z.wallet.sdk.rpc.CompactBlock\"\x00\x12^\n" + diff --git a/walletrpc/service_grpc.pb.go b/walletrpc/service_grpc.pb.go index be05aa99..7d76e892 100644 --- a/walletrpc/service_grpc.pb.go +++ b/walletrpc/service_grpc.pb.go @@ -56,7 +56,7 @@ type CompactTxStreamerClient interface { // The returned `CompactBlock` includes transaction data for all value // pools, including transparent inputs (`vin`) and outputs (`vout`). This // differs from `GetBlockRange`, which supports filtering by pool type and - // defaults to returning only shielded (Sapling and Orchard) data. Clients + // defaults to returning only shielded (Sapling, Orchard, and Ironwood) data. Clients // that require only data for specific pools should use `GetBlockRange` // with the appropriate `poolTypes` set. // @@ -66,9 +66,10 @@ type CompactTxStreamerClient interface { GetBlock(ctx context.Context, in *BlockID, opts ...grpc.CallOption) (*CompactBlock, error) // Deprecated: Do not use. // Return a compact block containing only nullifier information for the - // shielded pools (Sapling spend nullifiers and Orchard action nullifiers). - // Transparent transaction data, Sapling outputs, full Orchard action data, - // and commitment tree sizes are not included. + // shielded pools (Sapling spend nullifiers, Orchard action nullifiers, and + // Ironwood action nullifiers). Transparent transaction data, Sapling + // outputs, full Orchard/Ironwood action data, and commitment tree sizes are + // not included. // // Note: this method is deprecated; use `GetBlockRange` with the // appropriate `poolTypes` instead. @@ -82,9 +83,10 @@ type CompactTxStreamerClient interface { // Deprecated: Do not use. // Return a stream of compact blocks for the specified range, where each // block contains only nullifier information for the shielded pools - // (Sapling spend nullifiers and Orchard action nullifiers). Transparent - // transaction data, Sapling outputs, full Orchard action data, and - // commitment tree sizes are not included. Implementations MUST ignore any + // (Sapling spend nullifiers, Orchard action nullifiers, and Ironwood action + // nullifiers). Transparent transaction data, Sapling outputs, full + // Orchard/Ironwood action data, and commitment tree sizes are not included. + // Implementations MUST ignore any // `PoolType::TRANSPARENT` member of the `poolTypes` field of the request. // // Note: this method is deprecated; use `GetBlockRange` with the @@ -127,7 +129,7 @@ type CompactTxStreamerClient interface { GetTreeState(ctx context.Context, in *BlockID, opts ...grpc.CallOption) (*TreeState, error) GetLatestTreeState(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*TreeState, error) // 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). GetSubtreeRoots(ctx context.Context, in *GetSubtreeRootsArg, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SubtreeRoot], error) GetAddressUtxos(ctx context.Context, in *GetAddressUtxosArg, opts ...grpc.CallOption) (*GetAddressUtxosReplyList, error) GetAddressUtxosStream(ctx context.Context, in *GetAddressUtxosArg, opts ...grpc.CallOption) (grpc.ServerStreamingClient[GetAddressUtxosReply], error) @@ -433,7 +435,7 @@ type CompactTxStreamerServer interface { // The returned `CompactBlock` includes transaction data for all value // pools, including transparent inputs (`vin`) and outputs (`vout`). This // differs from `GetBlockRange`, which supports filtering by pool type and - // defaults to returning only shielded (Sapling and Orchard) data. Clients + // defaults to returning only shielded (Sapling, Orchard, and Ironwood) data. Clients // that require only data for specific pools should use `GetBlockRange` // with the appropriate `poolTypes` set. // @@ -443,9 +445,10 @@ type CompactTxStreamerServer interface { GetBlock(context.Context, *BlockID) (*CompactBlock, error) // Deprecated: Do not use. // Return a compact block containing only nullifier information for the - // shielded pools (Sapling spend nullifiers and Orchard action nullifiers). - // Transparent transaction data, Sapling outputs, full Orchard action data, - // and commitment tree sizes are not included. + // shielded pools (Sapling spend nullifiers, Orchard action nullifiers, and + // Ironwood action nullifiers). Transparent transaction data, Sapling + // outputs, full Orchard/Ironwood action data, and commitment tree sizes are + // not included. // // Note: this method is deprecated; use `GetBlockRange` with the // appropriate `poolTypes` instead. @@ -459,9 +462,10 @@ type CompactTxStreamerServer interface { // Deprecated: Do not use. // Return a stream of compact blocks for the specified range, where each // block contains only nullifier information for the shielded pools - // (Sapling spend nullifiers and Orchard action nullifiers). Transparent - // transaction data, Sapling outputs, full Orchard action data, and - // commitment tree sizes are not included. Implementations MUST ignore any + // (Sapling spend nullifiers, Orchard action nullifiers, and Ironwood action + // nullifiers). Transparent transaction data, Sapling outputs, full + // Orchard/Ironwood action data, and commitment tree sizes are not included. + // Implementations MUST ignore any // `PoolType::TRANSPARENT` member of the `poolTypes` field of the request. // // Note: this method is deprecated; use `GetBlockRange` with the @@ -504,7 +508,7 @@ type CompactTxStreamerServer interface { GetTreeState(context.Context, *BlockID) (*TreeState, error) GetLatestTreeState(context.Context, *Empty) (*TreeState, error) // 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). GetSubtreeRoots(*GetSubtreeRootsArg, grpc.ServerStreamingServer[SubtreeRoot]) error GetAddressUtxos(context.Context, *GetAddressUtxosArg) (*GetAddressUtxosReplyList, error) GetAddressUtxosStream(*GetAddressUtxosArg, grpc.ServerStreamingServer[GetAddressUtxosReply]) error From b17372dacbbdbbefb2f809355c98afaadb130ee5 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 9 Jun 2026 17:02:02 -0600 Subject: [PATCH 04/11] Handle empty Ironwood subtree roots --- frontend/frontend_test.go | 128 ++++++++++++++++++++++++++++++++++++++ frontend/service.go | 79 +++++++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/frontend/frontend_test.go b/frontend/frontend_test.go index 9743dbcc..f893dc71 100644 --- a/frontend/frontend_test.go +++ b/frontend/frontend_test.go @@ -214,6 +214,134 @@ func TestGetLatestBlock(t *testing.T) { step = 0 } +func TestCompletedSubtreeExists(t *testing.T) { + tests := []struct { + name string + treeSize uint32 + startIndex uint32 + want bool + }{ + { + name: "empty tree has no first subtree", + treeSize: 0, + startIndex: 0, + want: false, + }, + { + name: "one short of first subtree", + treeSize: 65535, + startIndex: 0, + want: false, + }, + { + name: "exact first subtree", + treeSize: 65536, + startIndex: 0, + want: true, + }, + { + name: "second subtree not complete", + treeSize: 65536, + startIndex: 1, + want: false, + }, + { + name: "exact second subtree", + treeSize: 131072, + startIndex: 1, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := completedSubtreeExists(test.treeSize, test.startIndex); got != test.want { + t.Fatalf("completedSubtreeExists(%d, %d) = %t, want %t", + test.treeSize, test.startIndex, got, test.want) + } + }) + } +} + +func TestCurrentIronwoodTreeSizeFromRPC(t *testing.T) { + originalRawRequest := common.RawRequest + defer func() { common.RawRequest = originalRawRequest }() + + step := 0 + common.RawRequest = func(method string, params []json.RawMessage) (json.RawMessage, error) { + step++ + switch step { + case 1: + if method != "getblockchaininfo" { + t.Fatalf("unexpected method: %s", method) + } + return []byte(`{"Blocks": 123}`), nil + case 2: + if method != "getblock" { + t.Fatalf("unexpected method: %s", method) + } + var height string + if err := json.Unmarshal(params[0], &height); err != nil { + t.Fatalf("could not unmarshal height: %v", err) + } + if height != "123" { + t.Fatalf("unexpected getblock height: %s", height) + } + if string(params[1]) != "1" { + t.Fatalf("unexpected getblock verbosity: %s", string(params[1])) + } + return []byte(`{"trees":{"ironwood":{"size":42}}}`), nil + default: + t.Fatalf("unexpected extra RPC call %d", step) + return nil, nil + } + } + + size, known, err := currentIronwoodTreeSizeFromRPC() + if err != nil { + t.Fatalf("currentIronwoodTreeSizeFromRPC failed: %v", err) + } + if !known { + t.Fatal("expected known Ironwood tree size") + } + if size != 42 { + t.Fatalf("tree size = %d, want 42", size) + } +} + +func TestCurrentIronwoodTreeSizeFromRPCMissingIronwoodFailsClosed(t *testing.T) { + originalRawRequest := common.RawRequest + defer func() { common.RawRequest = originalRawRequest }() + + step := 0 + common.RawRequest = func(method string, params []json.RawMessage) (json.RawMessage, error) { + step++ + switch step { + case 1: + if method != "getblockchaininfo" { + t.Fatalf("unexpected method: %s", method) + } + return []byte(`{"Blocks": 123}`), nil + case 2: + if method != "getblock" { + t.Fatalf("unexpected method: %s", method) + } + return []byte(`{"trees":{"orchard":{"size":42}}}`), nil + default: + t.Fatalf("unexpected extra RPC call %d", step) + return nil, nil + } + } + + size, known, err := currentIronwoodTreeSizeFromRPC() + if err != nil { + t.Fatalf("currentIronwoodTreeSizeFromRPC failed: %v", err) + } + if known { + t.Fatalf("tree size unexpectedly known: %d", size) + } +} + // A valid address starts with "t", followed by 34 alpha characters; // these should all be detected as invalid. var addressTests = []string{ diff --git a/frontend/service.go b/frontend/service.go index 0595bb1f..cdf409db 100644 --- a/frontend/service.go +++ b/frontend/service.go @@ -36,6 +36,8 @@ type lwdStreamer struct { walletrpc.UnimplementedCompactTxStreamerServer } +const subtreeRootSpan = uint64(1 << 16) + // NewLwdStreamer constructs a gRPC context. func NewLwdStreamer(cache *common.BlockCache, chainName string, enablePing bool) (walletrpc.CompactTxStreamerServer, error) { return &lwdStreamer{cache: cache, chainName: chainName, pingEnable: enablePing}, nil @@ -839,6 +841,80 @@ func (s *lwdStreamer) GetAddressUtxos(ctx context.Context, arg *walletrpc.GetAdd return r, nil } +func isUnsupportedIronwoodSubtreePoolError(err error) bool { + if err == nil { + return false + } + message := err.Error() + return strings.Contains(message, "invalid pool name") && + strings.Contains(message, "sapling") && + strings.Contains(message, "orchard") +} + +func completedSubtreeExists(treeSize uint32, startIndex uint32) bool { + nextRequestedSubtreeEnd := (uint64(startIndex) + 1) * subtreeRootSpan + return uint64(treeSize) >= nextRequestedSubtreeEnd +} + +func currentIronwoodTreeSizeFromRPC() (uint32, bool, error) { + info, err := common.GetBlockChainInfo() + if err != nil { + return 0, false, err + } + + heightJSON, err := json.Marshal(strconv.Itoa(info.Blocks)) + if err != nil { + return 0, false, err + } + result, rpcErr := common.RawRequest("getblock", []json.RawMessage{heightJSON, json.RawMessage("1")}) + if rpcErr != nil { + return 0, false, rpcErr + } + + var block struct { + Trees struct { + Ironwood *struct { + Size *uint32 + } + } + } + if err := json.Unmarshal(result, &block); err != nil { + return 0, false, err + } + if block.Trees.Ironwood == nil || block.Trees.Ironwood.Size == nil { + return 0, false, nil + } + return *block.Trees.Ironwood.Size, true, nil +} + +func (s *lwdStreamer) canReturnEmptyIronwoodSubtreeRoots(arg *walletrpc.GetSubtreeRootsArg, rpcErr error) bool { + if arg.ShieldedProtocol != walletrpc.ShieldedProtocol_ironwood || + !isUnsupportedIronwoodSubtreePoolError(rpcErr) { + return false + } + + treeSize, known, err := currentIronwoodTreeSizeFromRPC() + if err != nil { + common.Log.Warnf("GetSubtreeRoots: cannot check Ironwood tree size: %s", err.Error()) + return false + } + if !known { + common.Log.Warn("GetSubtreeRoots: backend did not expose Ironwood tree size") + return false + } + + if completedSubtreeExists(treeSize, arg.StartIndex) { + return false + } + + common.Log.Warnf( + "GetSubtreeRoots: backend does not expose Ironwood subtree roots; returning empty stream because ironwood tree size %d is below requested subtree index %d", + treeSize, + arg.StartIndex, + ) + return true +} + func (s *lwdStreamer) GetSubtreeRoots(arg *walletrpc.GetSubtreeRootsArg, resp walletrpc.CompactTxStreamer_GetSubtreeRootsServer) error { common.Log.Debugf("gRPC GetSubtreeRoots(%+v)\n", arg) if common.DarksideEnabled { @@ -876,6 +952,9 @@ func (s *lwdStreamer) GetSubtreeRoots(arg *walletrpc.GetSubtreeRootsArg, resp wa } result, rpcErr := common.RawRequest("z_getsubtreesbyindex", params) if rpcErr != nil { + if s.canReturnEmptyIronwoodSubtreeRoots(arg, rpcErr) { + return nil + } return status.Errorf(codes.InvalidArgument, "GetSubtreeRoots: z_getsubtreesbyindex, error: %s", rpcErr.Error()) } From a8eaffab3ae8f210cc2099943862322fe4e7dd99 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 12 Jun 2026 12:11:52 -0600 Subject: [PATCH 05/11] Document Ironwood default pool filters --- lightwallet-protocol/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightwallet-protocol/CHANGELOG.md b/lightwallet-protocol/CHANGELOG.md index daeff28e..afa3ea88 100644 --- a/lightwallet-protocol/CHANGELOG.md +++ b/lightwallet-protocol/CHANGELOG.md @@ -14,6 +14,10 @@ and this library adheres to Rust's notion of - `service.TreeState.ironwoodTree` - `service.ShieldedProtocol.ironwood` +### Changed +- Empty `service.BlockRange.poolTypes` and + `service.GetMempoolTxRequest.poolTypes` now include Ironwood shielded data. + ## [v0.4.1] - 2026-02-20 ### Added From 8a6c2b52d895b43a2a7d954619926723dcf408b6 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 12 Jun 2026 12:12:14 -0600 Subject: [PATCH 06/11] Clarify darkside reset parameters --- common/darkside.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/common/darkside.go b/common/darkside.go index d0ec04d9..e0194525 100644 --- a/common/darkside.go +++ b/common/darkside.go @@ -148,21 +148,22 @@ 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, sit uint32) error { - Log.Info("DarksideReset(saplingActivation=", sa, ")") +// that are returned by GetLightdInfo(). The tree sizes seed state as of +// saplingActivation - 1. +func DarksideReset(saplingActivation int, branchID, chainName string, startSaplingTreeSize, startOrchardTreeSize, startIronwoodTreeSize uint32) error { + Log.Info("DarksideReset(saplingActivation=", saplingActivation, ")") mutex.Lock() defer mutex.Unlock() stopIngestor() state = darksideState{ resetted: true, - startHeight: sa, + startHeight: saplingActivation, latestHeight: -1, - branchID: bi, - chainName: cn, - startSaplingTreeSize: sst, - startOrchardTreeSize: sot, - startIronwoodTreeSize: sit, + branchID: branchID, + chainName: chainName, + startSaplingTreeSize: startSaplingTreeSize, + startOrchardTreeSize: startOrchardTreeSize, + startIronwoodTreeSize: startIronwoodTreeSize, cache: state.cache, activeBlocks: make([]*activeBlock, 0), stagedBlocks: make([][]byte, 0), @@ -172,7 +173,7 @@ func DarksideReset(sa int, bi, cn string, sst, sot, sit uint32) error { stagedTreeStatesByHash: make(map[string]*DarksideTreeState), subtrees: make(map[walletrpc.ShieldedProtocol]darksideProtocolSubtreeRoots), } - state.cache.Reset(sa) + state.cache.Reset(saplingActivation) return nil } From 58d199d0101fff4e3d61cd539c5eba1b476df7e2 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 12 Jun 2026 12:12:40 -0600 Subject: [PATCH 07/11] Rename shared action parser helper --- parser/transaction.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/parser/transaction.go b/parser/transaction.go index 069eabb0..35f82114 100644 --- a/parser/transaction.go +++ b/parser/transaction.go @@ -544,7 +544,7 @@ func (tx *Transaction) parseV5(data []byte) ([]byte, error) { } var orchardActions []action - s, orchardActions, err = parseOrchardActionsBundle([]byte(s), "Orchard") + s, orchardActions, err = parseOrchardActionShapeBundle([]byte(s), "Orchard") if err != nil { return nil, err } @@ -589,14 +589,14 @@ func (tx *Transaction) parseV6(data []byte) ([]byte, error) { } var orchardActions []action - s, orchardActions, err = parseOrchardActionsBundle([]byte(s), "Orchard") + s, orchardActions, err = parseOrchardActionShapeBundle([]byte(s), "Orchard") if err != nil { return nil, err } tx.orchardActions = orchardActions var ironwoodActions []action - s, ironwoodActions, err = parseOrchardActionsBundle([]byte(s), "Ironwood") + s, ironwoodActions, err = parseOrchardActionShapeBundle([]byte(s), "Ironwood") if err != nil { return nil, err } @@ -659,7 +659,9 @@ func (tx *Transaction) parseSaplingBundle(data []byte) ([]byte, error) { return s, nil } -func parseOrchardActionsBundle(data []byte, pool string) ([]byte, []action, error) { +// parseOrchardActionShapeBundle parses the action-field layout shared by +// Orchard and Ironwood, with pool used for error messages. +func parseOrchardActionShapeBundle(data []byte, pool string) ([]byte, []action, error) { s := bytestring.String(data) var err error var actionsCount int From 6a79efd2f80b81b0bd68588adc322295ee7adb5f Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 12 Jun 2026 12:13:29 -0600 Subject: [PATCH 08/11] Document Ironwood subtree fallback --- frontend/service.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/service.go b/frontend/service.go index cdf409db..cb12d8c5 100644 --- a/frontend/service.go +++ b/frontend/service.go @@ -36,6 +36,7 @@ type lwdStreamer struct { walletrpc.UnimplementedCompactTxStreamerServer } +// subtreeRootSpan is 2^16, the note span covered by each subtree root. const subtreeRootSpan = uint64(1 << 16) // NewLwdStreamer constructs a gRPC context. @@ -841,6 +842,8 @@ func (s *lwdStreamer) GetAddressUtxos(ctx context.Context, arg *walletrpc.GetAdd return r, nil } +// isUnsupportedIronwoodSubtreePoolError matches the legacy backend error +// returned before z_getsubtreesbyindex accepts Ironwood as a pool name. func isUnsupportedIronwoodSubtreePoolError(err error) bool { if err == nil { return false @@ -851,11 +854,15 @@ func isUnsupportedIronwoodSubtreePoolError(err error) bool { strings.Contains(message, "orchard") } +// completedSubtreeExists reports whether the requested subtree start index +// points to a subtree that is complete for the current tree size. func completedSubtreeExists(treeSize uint32, startIndex uint32) bool { nextRequestedSubtreeEnd := (uint64(startIndex) + 1) * subtreeRootSpan return uint64(treeSize) >= nextRequestedSubtreeEnd } +// currentIronwoodTreeSizeFromRPC probes the tip block's Ironwood tree size so +// the fallback can decide whether any completed Ironwood subtree should exist. func currentIronwoodTreeSizeFromRPC() (uint32, bool, error) { info, err := common.GetBlockChainInfo() if err != nil { @@ -887,6 +894,9 @@ func currentIronwoodTreeSizeFromRPC() (uint32, bool, error) { return *block.Trees.Ironwood.Size, true, nil } +// canReturnEmptyIronwoodSubtreeRoots permits an empty response only for an +// Ironwood request rejected by a legacy backend when the current tree size is +// known and below the requested completed subtree. func (s *lwdStreamer) canReturnEmptyIronwoodSubtreeRoots(arg *walletrpc.GetSubtreeRootsArg, rpcErr error) bool { if arg.ShieldedProtocol != walletrpc.ShieldedProtocol_ironwood || !isUnsupportedIronwoodSubtreePoolError(rpcErr) { @@ -903,6 +913,7 @@ func (s *lwdStreamer) canReturnEmptyIronwoodSubtreeRoots(arg *walletrpc.GetSubtr return false } + // Fail closed once the requested subtree should be complete. if completedSubtreeExists(treeSize, arg.StartIndex) { return false } From 5e26ed92858d3ba3c16f2fca179076f10107c35b Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 12 Jun 2026 12:14:10 -0600 Subject: [PATCH 09/11] Extract nullifier block pruning --- frontend/service.go | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/frontend/service.go b/frontend/service.go index cb12d8c5..7ec9edcc 100644 --- a/frontend/service.go +++ b/frontend/service.go @@ -191,6 +191,24 @@ func (s *lwdStreamer) GetBlock(ctx context.Context, id *walletrpc.BlockID) (*wal return cBlock, err } +// pruneCompactBlockToNullifiers removes shielded data not used by the +// nullifier RPCs and clears tree size metadata. +func pruneCompactBlockToNullifiers(cBlock *walletrpc.CompactBlock) { + for _, tx := range cBlock.Vtx { + for i, action := range tx.Actions { + tx.Actions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} + } + for i, action := range tx.IronwoodActions { + tx.IronwoodActions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} + } + tx.Outputs = nil + } + // these are not needed (we prefer to save bandwidth) + cBlock.ChainMetadata.SaplingCommitmentTreeSize = 0 + cBlock.ChainMetadata.OrchardCommitmentTreeSize = 0 + cBlock.ChainMetadata.IronwoodCommitmentTreeSize = 0 +} + // GetBlockNullifiers is the same as GetBlock except that it returns the compact block // with actions containing only the nullifiers (a subset of the full compact block). func (s *lwdStreamer) GetBlockNullifiers(ctx context.Context, id *walletrpc.BlockID) (*walletrpc.CompactBlock, error) { @@ -212,21 +230,11 @@ func (s *lwdStreamer) GetBlockNullifiers(ctx context.Context, id *walletrpc.Bloc // GetBlock() returns gRPC-compatible errors. return nil, err } + pruneCompactBlockToNullifiers(cBlock) for _, tx := range cBlock.Vtx { - for i, action := range tx.Actions { - tx.Actions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} - } - for i, action := range tx.IronwoodActions { - tx.IronwoodActions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} - } - tx.Outputs = nil tx.Vin = nil tx.Vout = nil } - // these are not needed (we prefer to save bandwidth) - cBlock.ChainMetadata.SaplingCommitmentTreeSize = 0 - cBlock.ChainMetadata.OrchardCommitmentTreeSize = 0 - cBlock.ChainMetadata.IronwoodCommitmentTreeSize = 0 common.Log.Tracef(" return: %+v\n", cBlock) return cBlock, err } @@ -295,19 +303,7 @@ func (s *lwdStreamer) GetBlockRangeNullifiers(span *walletrpc.BlockRange, resp w // this will also catch context.DeadlineExceeded from the timeout return err case cBlock := <-blockChan: - for _, tx := range cBlock.Vtx { - for i, action := range tx.Actions { - tx.Actions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} - } - for i, action := range tx.IronwoodActions { - tx.IronwoodActions[i] = &walletrpc.CompactOrchardAction{Nullifier: action.Nullifier} - } - tx.Outputs = nil - } - // these are not needed (we prefer to save bandwidth) - cBlock.ChainMetadata.SaplingCommitmentTreeSize = 0 - cBlock.ChainMetadata.OrchardCommitmentTreeSize = 0 - cBlock.ChainMetadata.IronwoodCommitmentTreeSize = 0 + pruneCompactBlockToNullifiers(cBlock) if err := resp.Send(cBlock); err != nil { return err } From 47c87652842ef8e1fddba248a97762f2b9dcbcd7 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 12 Jun 2026 18:52:10 -0600 Subject: [PATCH 10/11] Clarify Ironwood protocol docs --- CHANGELOG.md | 5 +++-- docs/rtd/index.html | 2 +- lightwallet-protocol/walletrpc/service.proto | 6 +++--- walletrpc/service.pb.go | 6 +++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9e4bdf..94955983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +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. +- Add Ironwood compact block, tree state, subtree root, and Valar NU7 V6 + transaction parsing support. Empty `poolTypes` requests now include Ironwood + shielded data. ### Changed diff --git a/docs/rtd/index.html b/docs/rtd/index.html index c1d708fb..175c84b8 100644 --- a/docs/rtd/index.html +++ b/docs/rtd/index.html @@ -831,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, 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.

+

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 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.

diff --git a/lightwallet-protocol/walletrpc/service.proto b/lightwallet-protocol/walletrpc/service.proto index f691264a..93016380 100644 --- a/lightwallet-protocol/walletrpc/service.proto +++ b/lightwallet-protocol/walletrpc/service.proto @@ -29,9 +29,9 @@ message BlockID { // 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 +// If no pool types are specified, the server should default to 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` diff --git a/walletrpc/service.pb.go b/walletrpc/service.pb.go index 09654aee..80906427 100644 --- a/walletrpc/service.pb.go +++ b/walletrpc/service.pb.go @@ -189,9 +189,9 @@ func (x *BlockID) GetHash() []byte { // 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 +// If no pool types are specified, the server should default to 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` From 360a37d588756b2fc96c3e4403296cd2b47dbc04 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 15 Jun 2026 14:47:48 -0600 Subject: [PATCH 11/11] Rename Ironwood parser constants to NU6.3 --- CHANGELOG.md | 2 +- parser/transaction.go | 22 +++++++++++----------- parser/transaction_test.go | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94955983..28e7dbc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ 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 Valar NU7 V6 +- Add Ironwood compact block, tree state, subtree root, and Ironwood / NU6.3 V6 transaction parsing support. Empty `poolTypes` requests now include Ironwood shielded data. diff --git a/parser/transaction.go b/parser/transaction.go index 35f82114..cade9c67 100644 --- a/parser/transaction.go +++ b/parser/transaction.go @@ -555,7 +555,7 @@ 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 +// This intentionally handles the Ironwood / NU6.3 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. @@ -565,11 +565,11 @@ func (tx *Transaction) parseV6(data []byte) ([]byte, error) { if !s.ReadUint32(&tx.consensusBranchID) { return nil, errors.New("could not read nConsensusBranchId") } - if tx.nVersionGroupID != VALAR_NU7_VERSION_GROUP_ID { + if tx.nVersionGroupID != IRONWOOD_NU6_3_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 { + if tx.consensusBranchID != IRONWOOD_NU6_3_CONSENSUS_BRANCH_ID { return nil, fmt.Errorf("consensus branch ID %d must be 0xFFFFFFFF", tx.consensusBranchID) } if !s.Skip(4) { @@ -712,13 +712,13 @@ func parseOrchardActionShapeBundle(data []byte, pool string) ([]byte, []action, const OVERWINTER_TX_VERSION uint32 = 3 const SAPLING_TX_VERSION uint32 = 4 const ZIP225_TX_VERSION uint32 = 5 -const VALAR_NU7_TX_VERSION uint32 = 6 +const IRONWOOD_NU6_3_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 VALAR_NU7_VERSION_GROUP_ID uint32 = 0xFFFFFFFF -const VALAR_NU7_CONSENSUS_BRANCH_ID uint32 = 0xFFFFFFFF +const IRONWOOD_NU6_3_VERSION_GROUP_ID uint32 = 0xFFFFFFFF +const IRONWOOD_NU6_3_CONSENSUS_BRANCH_ID uint32 = 0xFFFFFFFF func (tx *Transaction) isOverwinterV3() bool { return tx.fOverwintered && @@ -738,10 +738,10 @@ func (tx *Transaction) isZip225V5() bool { tx.version == ZIP225_TX_VERSION } -func (tx *Transaction) isValarNU7V6() bool { +func (tx *Transaction) isIronwoodNU6_3V6() bool { return tx.fOverwintered && - tx.nVersionGroupID == VALAR_NU7_VERSION_GROUP_ID && - tx.version == VALAR_NU7_TX_VERSION + tx.nVersionGroupID == IRONWOOD_NU6_3_VERSION_GROUP_ID && + tx.version == IRONWOOD_NU6_3_TX_VERSION } func (tx *Transaction) isGroth16Proof() bool { @@ -773,13 +773,13 @@ func (tx *Transaction) ParseFromSlice(data []byte) ([]byte, error) { } if tx.fOverwintered && - !(tx.isOverwinterV3() || tx.isSaplingV4() || tx.isZip225V5() || tx.isValarNU7V6()) { + !(tx.isOverwinterV3() || tx.isSaplingV4() || tx.isZip225V5() || tx.isIronwoodNU6_3V6()) { 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.isValarNU7V6() { + } else if tx.isIronwoodNU6_3V6() { 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 bd971b03..f82797ec 100644 --- a/parser/transaction_test.go +++ b/parser/transaction_test.go @@ -116,7 +116,7 @@ func TestV5TransactionParser(t *testing.T) { } } -func TestValarNU7V6TransactionParser(t *testing.T) { +func TestIronwoodNU6_3V6TransactionParser(t *testing.T) { rawTxData, err := hex.DecodeString("06000080ffffffffffffffff0000000000000000000000000000") if err != nil { t.Fatal(err) @@ -130,13 +130,13 @@ func TestValarNU7V6TransactionParser(t *testing.T) { if len(rest) != 0 { t.Fatalf("Test did not consume entire buffer, %d remaining", len(rest)) } - if tx.version != VALAR_NU7_TX_VERSION { + if tx.version != IRONWOOD_NU6_3_TX_VERSION { t.Fatal("version miscompare") } - if tx.nVersionGroupID != VALAR_NU7_VERSION_GROUP_ID { + if tx.nVersionGroupID != IRONWOOD_NU6_3_VERSION_GROUP_ID { t.Fatal("nVersionGroupId miscompare") } - if tx.consensusBranchID != VALAR_NU7_CONSENSUS_BRANCH_ID { + if tx.consensusBranchID != IRONWOOD_NU6_3_CONSENSUS_BRANCH_ID { t.Fatal("consensusBranchID miscompare") } if len(tx.transparentInputs) != 0 { @@ -159,7 +159,7 @@ func TestValarNU7V6TransactionParser(t *testing.T) { } } -func TestValarNU7V6TransactionParserRejectsOtherConsensusBranchID(t *testing.T) { +func TestIronwoodNU6_3V6TransactionParserRejectsOtherConsensusBranchID(t *testing.T) { rawTxData, err := hex.DecodeString("06000080ffffffff000000000000000000000000000000000000") if err != nil { t.Fatal(err) @@ -175,7 +175,7 @@ func TestValarNU7V6TransactionParserRejectsOtherConsensusBranchID(t *testing.T) } } -func TestValarNU7V6TransactionParserKeepsIronwoodBundle(t *testing.T) { +func TestIronwoodNU6_3V6TransactionParserKeepsIronwoodBundle(t *testing.T) { var raw bytes.Buffer raw.Write([]byte{ 0x06, 0x00, 0x00, 0x80, // fOverwintered | version 6