From 9a8ba01bf00c679d20d3b41016115ab2d5b36ea7 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Thu, 15 Oct 2026 23:11:20 -0700 Subject: [PATCH] api: expose compact account input ownership evidence --- api/types.go | 1 + api/worker.go | 5 ++ api/worker_txsummary_test.go | 132 +++++++++++++++++++++++++++++++++++ blockbook-api.ts | 2 + openapi.yaml | 3 + 5 files changed, 143 insertions(+) create mode 100644 api/worker_txsummary_test.go diff --git a/api/types.go b/api/types.go index 8ea89f3903..b74714e27f 100644 --- a/api/types.go +++ b/api/types.go @@ -409,6 +409,7 @@ type Tx struct { Direction string `json:"direction,omitempty" ts_doc:"Transaction direction relative to the queried account, when returned by account summary endpoints."` AddressValueInSat *Amount `json:"addressValueIn,omitempty" ts_doc:"Total input value belonging to the queried account, when returned by account summary endpoints."` AddressValueOutSat *Amount `json:"addressValueOut,omitempty" ts_doc:"Total output value belonging to the queried account, when returned by account summary endpoints."` + AccountOwnsAllInputs *bool `json:"accountOwnsAllInputs,omitempty" ts_doc:"Whether every input has a known positive native value and belongs to the queried account. Present only in compact account summaries."` AccountAssetTransfers []TokenTransfer `json:"accountAssetTransfers,omitempty" ts_doc:"Syscoin SPT transfers summarized relative to the queried account."` // SYSCOIN: SPT transaction type and optional decoded memo. TokenType *bchain.TokenType `json:"tokenType,omitempty" ts_doc:"Syscoin SPT transaction type."` diff --git a/api/worker.go b/api/worker.go index 82f6abcbf2..2c8cc73df6 100644 --- a/api/worker.go +++ b/api/worker.go @@ -1599,8 +1599,12 @@ func (w *Worker) summarizeTxForAccount(tx *Tx, addrDescs map[string]struct{}) *T } var valueIn, valueOut big.Int assetTransfers := make(map[string]*accountAssetTransferSummary) + accountOwnsAllInputs := len(tx.Vin) > 0 for i := range tx.Vin { vin := &tx.Vin[i] + if len(vin.AddrDesc) == 0 || vin.ValueSat == nil || (*big.Int)(vin.ValueSat).Sign() <= 0 || !addrDescInSet(vin.AddrDesc, addrDescs) { + accountOwnsAllInputs = false + } if !addrDescInSet(vin.AddrDesc, addrDescs) { continue } @@ -1641,6 +1645,7 @@ func (w *Worker) summarizeTxForAccount(tx *Tx, addrDescs map[string]struct{}) *T Direction: direction, AddressValueInSat: (*Amount)(&valueIn), AddressValueOutSat: (*Amount)(&valueOut), + AccountOwnsAllInputs: &accountOwnsAllInputs, AccountAssetTransfers: accountAssetTransfers, TokenType: tx.TokenType, Memo: tx.Memo, diff --git a/api/worker_txsummary_test.go b/api/worker_txsummary_test.go new file mode 100644 index 0000000000..f19e856d62 --- /dev/null +++ b/api/worker_txsummary_test.go @@ -0,0 +1,132 @@ +//go:build unittest + +package api + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +func TestSummarizeTxForAccountInputOwnership(t *testing.T) { + owned := bchain.AddressDescriptor{1} + ownedChange := bchain.AddressDescriptor{2} + foreign := bchain.AddressDescriptor{3} + accounts := map[string]struct{}{string(owned): {}, string(ownedChange): {}} + amount := func(value int64) *Amount { return (*Amount)(big.NewInt(value)) } + tests := []struct { + name string + vin []Vin + want bool + }{ + {"one owned input", []Vin{{AddrDesc: owned, ValueSat: amount(100000226)}}, true}, + {"multiple owned inputs", []Vin{{AddrDesc: owned, ValueSat: amount(60000000)}, {AddrDesc: ownedChange, ValueSat: amount(40000226)}}, true}, + {"mixed ownership", []Vin{{AddrDesc: owned, ValueSat: amount(60000000)}, {AddrDesc: foreign, ValueSat: amount(40000226)}}, false}, + {"only foreign inputs", []Vin{{AddrDesc: foreign, ValueSat: amount(100000226)}}, false}, + {"zero owned input", []Vin{{AddrDesc: owned, ValueSat: amount(0)}}, false}, + {"foreign zero input alongside owned funding", []Vin{{AddrDesc: owned, ValueSat: amount(100000226)}, {AddrDesc: foreign, ValueSat: amount(0)}}, false}, + {"unknown value", []Vin{{AddrDesc: owned}}, false}, + {"negative value", []Vin{{AddrDesc: owned, ValueSat: amount(-1)}}, false}, + {"missing descriptor", []Vin{{ValueSat: amount(100000226)}}, false}, + {"empty inputs", []Vin{}, false}, + {"nil inputs", nil, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tx := &Tx{Txid: "tx", Vin: test.vin, Vout: []Vout{}, FeesSat: amount(226)} + summary := (&Worker{}).summarizeTxForAccount(tx, accounts) + if summary.AccountOwnsAllInputs == nil || *summary.AccountOwnsAllInputs != test.want { + t.Fatalf("accountOwnsAllInputs = %v, want explicit %v", summary.AccountOwnsAllInputs, test.want) + } + if tx.AccountOwnsAllInputs != nil { + t.Fatal("summary changed the materialized transaction") + } + if summary.Vin == nil || len(summary.Vin) != 0 || summary.Vout == nil || len(summary.Vout) != 0 { + t.Fatal("summary must retain empty input/output arrays") + } + }) + } + if summary := (&Worker{}).summarizeTxForAccount(nil, accounts); summary != nil { + t.Fatal("nil transaction must still produce nil summary") + } + for _, descriptors := range []map[string]struct{}{nil, {}, {"": {}}} { + tx := &Tx{Vin: []Vin{{AddrDesc: owned, ValueSat: amount(100000226)}}} + summary := (&Worker{}).summarizeTxForAccount(tx, descriptors) + if summary.AccountOwnsAllInputs == nil || *summary.AccountOwnsAllInputs { + t.Fatal("missing account descriptors must not establish ownership") + } + } + missingDescriptor := &Tx{Vin: []Vin{{ValueSat: amount(100000226)}}} + summary := (&Worker{}).summarizeTxForAccount(missingDescriptor, map[string]struct{}{"": {}}) + if summary.AccountOwnsAllInputs == nil || *summary.AccountOwnsAllInputs { + t.Fatal("an empty descriptor must not match an empty account descriptor") + } +} + +func TestSummarizeTxForAccountOwnershipPreservesAmounts(t *testing.T) { + owned := bchain.AddressDescriptor{1} + foreign := bchain.AddressDescriptor{2} + accounts := map[string]struct{}{string(owned): {}} + amount := func(value int64) *Amount { return (*Amount)(big.NewInt(value)) } + for _, self := range []bool{false, true} { + name := "external spend" + accountOut := int64(30000000) + outputs := []Vout{{AddrDesc: owned, ValueSat: amount(accountOut)}, {AddrDesc: foreign, ValueSat: amount(70000000)}} + if self { + name = "self spend" + accountOut = 100000000 + outputs = []Vout{{AddrDesc: owned, ValueSat: amount(accountOut)}} + } + t.Run(name, func(t *testing.T) { + tx := &Tx{ + Txid: "tx", Vin: []Vin{{AddrDesc: owned, ValueSat: amount(100000226)}}, Vout: outputs, + ValueInSat: amount(100000226), ValueOutSat: amount(100000000), FeesSat: amount(226), + Confirmations: 1, Blockheight: 100, Blocktime: 1700000000, + } + summary := (&Worker{}).summarizeTxForAccount(tx, accounts) + if summary.AccountOwnsAllInputs == nil || !*summary.AccountOwnsAllInputs { + t.Fatal("expected explicit complete input ownership") + } + if summary.AddressValueInSat.String() != "100000226" || summary.ValueInSat.String() != "100000226" || summary.AddressValueOutSat.AsInt64() != accountOut || summary.ValueOutSat.AsInt64() != accountOut { + t.Fatal("account totals and legacy aliases changed") + } + if summary.FeesSat != tx.FeesSat || summary.Confirmations != tx.Confirmations || summary.Blockheight != tx.Blockheight || summary.Blocktime != tx.Blocktime { + t.Fatal("fees or confirmation metadata changed") + } + if tx.ValueOutSat.String() != "100000000" || tx.AccountOwnsAllInputs != nil { + t.Fatal("materialized transaction was modified") + } + }) + } +} + +func TestTxAccountOwnsAllInputsJSON(t *testing.T) { + for _, test := range []struct { + name string + value *bool + want interface{} + }{ + {"not a summary", nil, nil}, + {"owned summary", boolPointer(true), true}, + {"unproven summary", boolPointer(false), false}, + } { + t.Run(test.name, func(t *testing.T) { + encoded, err := json.Marshal(&Tx{AccountOwnsAllInputs: test.value}) + if err != nil { + t.Fatal(err) + } + var fields map[string]interface{} + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) + } + value, present := fields["accountOwnsAllInputs"] + if present != (test.value != nil) || value != test.want { + t.Fatalf("accountOwnsAllInputs = %v (present %v), want %v", value, present, test.want) + } + }) + } +} + +func boolPointer(value bool) *bool { return &value } diff --git a/blockbook-api.ts b/blockbook-api.ts index 427201f8b9..47355b9173 100644 --- a/blockbook-api.ts +++ b/blockbook-api.ts @@ -258,6 +258,8 @@ export interface Tx { addressValueIn?: string; /** Total output value belonging to the queried account, when returned by account summary endpoints. */ addressValueOut?: string; + /** Whether every input has a known positive native value and belongs to the queried account. Present only in compact account summaries. */ + accountOwnsAllInputs?: boolean; /** Syscoin SPT transfers summarized relative to the queried account. */ accountAssetTransfers?: TokenTransfer[]; /** Syscoin SPT transaction type. */ diff --git a/openapi.yaml b/openapi.yaml index f289b590ac..1becbbd36b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2137,6 +2137,9 @@ components: allOf: - $ref: "#/components/schemas/AmountString" description: Total output value belonging to the queried account, when returned by account summary endpoints. + accountOwnsAllInputs: + type: boolean + description: Whether every input has a known positive native value and belongs to the queried account. Present only in compact account summaries. accountAssetTransfers: type: array description: Syscoin SPT transfers summarized relative to the queried account.