From 4f886802085d2f591722a8453cbc5f7c91d5dd95 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 19 Aug 2026 09:30:18 +0800 Subject: [PATCH] fix(token): deduplicate composite-owner inputs whose token IDs are filtered out UniquePerInput deduplicates by (token ID, enrollment ID) and keeps every input with a nil token ID. Metadata.filterTransfer strips TokenID from the input metadata it clones, so consumers of eID-filtered metadata get nil token IDs and composite-owner member rows are summed once per member again. Give Input an Index, its position within the action, and fall back to (action index, input index, enrollment ID) as the deduplication key when the token ID is absent. Positions survive filtering because filterTransfer emits one entry per input. Inputs carrying a token ID keep the previous key, so existing callers are unaffected. Index defaults to 0, so two literal-built inputs with no token ID would silently collapse on a position nobody set. NewInput becomes the canonical way to build an input and stamps an unexported position-known marker; UniquePerInput collapses on position only for marked inputs, while literal-built inputs keep the previous keep-all behavior, locked by a regression test. Signed-off-by: Evan --- docs/services/storage/ttxdb.md | 6 +++- token/request.go | 21 +++++------- token/stream.go | 58 ++++++++++++++++++++++++++------- token/stream_test.go | 59 +++++++++++++++++++++++++++++++--- 4 files changed, 114 insertions(+), 30 deletions(-) diff --git a/docs/services/storage/ttxdb.md b/docs/services/storage/ttxdb.md index bc62a68c16..211228f037 100644 --- a/docs/services/storage/ttxdb.md +++ b/docs/services/storage/ttxdb.md @@ -100,4 +100,8 @@ pair once (`OutputStream.UniquePerOutput`), so members sharing an enrollment ID do not multiply the recorded amount. A spent input is expanded the same way, one row per member carrying the token's full quantity; sent-amount aggregation counts each `(token ID, enrollment ID)` pair once -(`InputStream.UniquePerInput`). +(`InputStream.UniquePerInput`), falling back to +`(action index, input index, enrollment ID)` when the token ID has been +filtered out of the metadata. Positional deduplication applies only to inputs +built through `token.NewInput`, which marks the position as known; a +literal-built input without a token ID keeps the previous keep-all behavior. diff --git a/token/request.go b/token/request.go index 80fafc5a62..35e220bee6 100644 --- a/token/request.go +++ b/token/request.go @@ -907,11 +907,8 @@ func (r *Request) inputs(ctx context.Context, failOnMissing bool) (*InputStream, func (r *Request) extractIssueInputs(actionIndex int, metadata *IssueMetadata) ([]*Input, error) { var inputs []*Input - for _, input := range metadata.Inputs { - inputs = append(inputs, &Input{ - ActionIndex: actionIndex, - Id: input.TokenID, - }) + for j, input := range metadata.Inputs { + inputs = append(inputs, NewInput(actionIndex, j, input.TokenID)) } return inputs, nil @@ -938,14 +935,12 @@ func (r *Request) extractTransferInputs(ctx context.Context, actionIndex int, me return nil, errors.Wrapf(err, "failed getting enrollment id and revocation handle [%d,%d]", actionIndex, j) } - inputs = append(inputs, &Input{ - ActionIndex: actionIndex, - Id: metadata.TokenIDAt(j), - Owner: sender.Identity, - OwnerAuditInfo: sender.AuditInfo, - EnrollmentID: eID, - RevocationHandler: rID, - }) + in := NewInput(actionIndex, j, metadata.TokenIDAt(j)) + in.Owner = sender.Identity + in.OwnerAuditInfo = sender.AuditInfo + in.EnrollmentID = eID + in.RevocationHandler = rID + inputs = append(inputs, in) } } diff --git a/token/stream.go b/token/stream.go index c864353272..20c93006a0 100644 --- a/token/stream.go +++ b/token/stream.go @@ -232,7 +232,10 @@ func (o *OutputStream) String() string { // Input models an input of a token action type Input struct { - ActionIndex int + ActionIndex int + // Index is the position of this input within its action; it takes part in + // deduplication only on inputs built through NewInput + Index int Id *token.ID Owner Identity OwnerAuditInfo []byte @@ -240,6 +243,19 @@ type Input struct { RevocationHandler string Type token.Type Quantity token.Quantity + + // positionKnown records that (ActionIndex, Index) was set deliberately — + // NewInput stamps it — so UniquePerInput never collapses literal-built + // inputs on their zero-valued position + positionKnown bool +} + +// NewInput returns an Input positioned within its action. The position stands +// in for the token ID during deduplication when the metadata has the token ID +// filtered out (see UniquePerInput), so inputs are built through here; an +// input built as a literal keeps no position and never collapses on it. +func NewInput(actionIndex, index int, id *token.ID) *Input { + return &Input{ActionIndex: actionIndex, Index: index, Id: id, positionKnown: true} } // InputStream models a stream over a set of inputs (Input). @@ -391,26 +407,46 @@ func (is *InputStream) ByType(tokenType token.Type) *InputStream { }) } -// UniquePerInput returns a stream keeping, for each (token ID, EnrollmentID) -// pair, only the first input, so amount aggregation counts a composite -// owner's members once. Inputs with no token ID are all kept. Identity -// consumers use the full stream instead. +// UniquePerInput returns a stream keeping, for each spent token and +// EnrollmentID, only the first input, so amount aggregation counts a +// composite owner's members once. The token is identified by its token ID, +// or by (ActionIndex, Index) when the token ID has been filtered out of the +// metadata; the two key spaces never mix, since only transfer inputs lose +// their token ID. The position counts only for inputs built through NewInput: +// a literal-built input without a token ID is kept as it stands, never +// collapsed on a position nobody set. Identity consumers use the full stream +// instead. func (is *InputStream) UniquePerInput() *InputStream { - type key struct { + type idKey struct { id token.ID eID string } - seen := map[key]bool{} + type posKey struct { + actionIndex int + index int + eID string + } + seenByID := map[idKey]bool{} + seenByPos := map[posKey]bool{} return is.Filter(func(t *Input) bool { - if t.Id == nil { + if t.Id != nil { + k := idKey{id: *t.Id, eID: t.EnrollmentID} + if seenByID[k] { + return false + } + seenByID[k] = true + return true } - k := key{id: *t.Id, eID: t.EnrollmentID} - if seen[k] { + if !t.positionKnown { + return true + } + k := posKey{actionIndex: t.ActionIndex, index: t.Index, eID: t.EnrollmentID} + if seenByPos[k] { return false } - seen[k] = true + seenByPos[k] = true return true }) diff --git a/token/stream_test.go b/token/stream_test.go index 6d2e23dcbb..b054fcf039 100644 --- a/token/stream_test.go +++ b/token/stream_test.go @@ -285,8 +285,17 @@ func TestOutputStream_UniquePerOutput(t *testing.T) { } } +// extractedInput builds an input the way the request's extraction path does, +// so its position takes part in deduplication. +func extractedInput(actionIndex, index int, id *token.ID, eID, rh string) *Input { + in := NewInput(actionIndex, index, id) + in.EnrollmentID = eID + in.RevocationHandler = rh + + return in +} + func TestInputStream_UniquePerInput(t *testing.T) { - // distinct pointers to the same token ID value must collapse cases := []struct { name string inputs []*Input @@ -312,7 +321,7 @@ func TestInputStream_UniquePerInput(t *testing.T) { }, }, { - "different token IDs with the same enrollment ID both survive", + "different token IDs with the same enrollment ID both survive despite equal positions", []*Input{ {Id: &token.ID{TxId: "tx0", Index: 0}, EnrollmentID: "enroll1"}, {Id: &token.ID{TxId: "tx0", Index: 1}, EnrollmentID: "enroll1"}, @@ -323,9 +332,49 @@ func TestInputStream_UniquePerInput(t *testing.T) { }, }, { - "inputs with no token ID are all kept", - []*Input{{EnrollmentID: "enroll1"}, {EnrollmentID: "enroll1"}}, - []*Input{{EnrollmentID: "enroll1"}, {EnrollmentID: "enroll1"}}, + "member rows with a filtered-out token ID collapse on action and input index", + []*Input{ + extractedInput(0, 0, nil, "enroll1", "first"), + extractedInput(0, 0, nil, "enroll1", "second"), + }, + []*Input{extractedInput(0, 0, nil, "enroll1", "first")}, + }, + { + "filtered rows with different positions or enrollment IDs all survive", + []*Input{ + extractedInput(0, 0, nil, "enroll1", ""), + extractedInput(0, 1, nil, "enroll1", ""), + extractedInput(1, 0, nil, "enroll1", ""), + extractedInput(0, 0, nil, "enroll2", ""), + }, + []*Input{ + extractedInput(0, 0, nil, "enroll1", ""), + extractedInput(0, 1, nil, "enroll1", ""), + extractedInput(1, 0, nil, "enroll1", ""), + extractedInput(0, 0, nil, "enroll2", ""), + }, + }, + { + "a row with a token ID never collides with a positionally identical row without one", + []*Input{ + extractedInput(0, 0, &token.ID{TxId: "tx0", Index: 0}, "enroll1", ""), + extractedInput(0, 0, nil, "enroll1", ""), + }, + []*Input{ + extractedInput(0, 0, &token.ID{TxId: "tx0", Index: 0}, "enroll1", ""), + extractedInput(0, 0, nil, "enroll1", ""), + }, + }, + { + "literal-built rows with no token ID never collapse on the zero-valued position", + []*Input{ + {EnrollmentID: "enroll1"}, + {EnrollmentID: "enroll1"}, + }, + []*Input{ + {EnrollmentID: "enroll1"}, + {EnrollmentID: "enroll1"}, + }, }, } for _, tc := range cases {