Skip to content

Commit 4f88680

Browse files
committed
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 <evanyan@sign.global>
1 parent 5c8aafc commit 4f88680

4 files changed

Lines changed: 114 additions & 30 deletions

File tree

docs/services/storage/ttxdb.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,8 @@ pair once (`OutputStream.UniquePerOutput`), so members sharing an enrollment
100100
ID do not multiply the recorded amount. A spent input is expanded the same
101101
way, one row per member carrying the token's full quantity; sent-amount
102102
aggregation counts each `(token ID, enrollment ID)` pair once
103-
(`InputStream.UniquePerInput`).
103+
(`InputStream.UniquePerInput`), falling back to
104+
`(action index, input index, enrollment ID)` when the token ID has been
105+
filtered out of the metadata. Positional deduplication applies only to inputs
106+
built through `token.NewInput`, which marks the position as known; a
107+
literal-built input without a token ID keeps the previous keep-all behavior.

token/request.go

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -907,11 +907,8 @@ func (r *Request) inputs(ctx context.Context, failOnMissing bool) (*InputStream,
907907

908908
func (r *Request) extractIssueInputs(actionIndex int, metadata *IssueMetadata) ([]*Input, error) {
909909
var inputs []*Input
910-
for _, input := range metadata.Inputs {
911-
inputs = append(inputs, &Input{
912-
ActionIndex: actionIndex,
913-
Id: input.TokenID,
914-
})
910+
for j, input := range metadata.Inputs {
911+
inputs = append(inputs, NewInput(actionIndex, j, input.TokenID))
915912
}
916913

917914
return inputs, nil
@@ -938,14 +935,12 @@ func (r *Request) extractTransferInputs(ctx context.Context, actionIndex int, me
938935
return nil, errors.Wrapf(err, "failed getting enrollment id and revocation handle [%d,%d]", actionIndex, j)
939936
}
940937

941-
inputs = append(inputs, &Input{
942-
ActionIndex: actionIndex,
943-
Id: metadata.TokenIDAt(j),
944-
Owner: sender.Identity,
945-
OwnerAuditInfo: sender.AuditInfo,
946-
EnrollmentID: eID,
947-
RevocationHandler: rID,
948-
})
938+
in := NewInput(actionIndex, j, metadata.TokenIDAt(j))
939+
in.Owner = sender.Identity
940+
in.OwnerAuditInfo = sender.AuditInfo
941+
in.EnrollmentID = eID
942+
in.RevocationHandler = rID
943+
inputs = append(inputs, in)
949944
}
950945
}
951946

token/stream.go

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,30 @@ func (o *OutputStream) String() string {
232232

233233
// Input models an input of a token action
234234
type Input struct {
235-
ActionIndex int
235+
ActionIndex int
236+
// Index is the position of this input within its action; it takes part in
237+
// deduplication only on inputs built through NewInput
238+
Index int
236239
Id *token.ID
237240
Owner Identity
238241
OwnerAuditInfo []byte
239242
EnrollmentID string
240243
RevocationHandler string
241244
Type token.Type
242245
Quantity token.Quantity
246+
247+
// positionKnown records that (ActionIndex, Index) was set deliberately —
248+
// NewInput stamps it — so UniquePerInput never collapses literal-built
249+
// inputs on their zero-valued position
250+
positionKnown bool
251+
}
252+
253+
// NewInput returns an Input positioned within its action. The position stands
254+
// in for the token ID during deduplication when the metadata has the token ID
255+
// filtered out (see UniquePerInput), so inputs are built through here; an
256+
// input built as a literal keeps no position and never collapses on it.
257+
func NewInput(actionIndex, index int, id *token.ID) *Input {
258+
return &Input{ActionIndex: actionIndex, Index: index, Id: id, positionKnown: true}
243259
}
244260

245261
// InputStream models a stream over a set of inputs (Input).
@@ -391,26 +407,46 @@ func (is *InputStream) ByType(tokenType token.Type) *InputStream {
391407
})
392408
}
393409

394-
// UniquePerInput returns a stream keeping, for each (token ID, EnrollmentID)
395-
// pair, only the first input, so amount aggregation counts a composite
396-
// owner's members once. Inputs with no token ID are all kept. Identity
397-
// consumers use the full stream instead.
410+
// UniquePerInput returns a stream keeping, for each spent token and
411+
// EnrollmentID, only the first input, so amount aggregation counts a
412+
// composite owner's members once. The token is identified by its token ID,
413+
// or by (ActionIndex, Index) when the token ID has been filtered out of the
414+
// metadata; the two key spaces never mix, since only transfer inputs lose
415+
// their token ID. The position counts only for inputs built through NewInput:
416+
// a literal-built input without a token ID is kept as it stands, never
417+
// collapsed on a position nobody set. Identity consumers use the full stream
418+
// instead.
398419
func (is *InputStream) UniquePerInput() *InputStream {
399-
type key struct {
420+
type idKey struct {
400421
id token.ID
401422
eID string
402423
}
403-
seen := map[key]bool{}
424+
type posKey struct {
425+
actionIndex int
426+
index int
427+
eID string
428+
}
429+
seenByID := map[idKey]bool{}
430+
seenByPos := map[posKey]bool{}
404431

405432
return is.Filter(func(t *Input) bool {
406-
if t.Id == nil {
433+
if t.Id != nil {
434+
k := idKey{id: *t.Id, eID: t.EnrollmentID}
435+
if seenByID[k] {
436+
return false
437+
}
438+
seenByID[k] = true
439+
407440
return true
408441
}
409-
k := key{id: *t.Id, eID: t.EnrollmentID}
410-
if seen[k] {
442+
if !t.positionKnown {
443+
return true
444+
}
445+
k := posKey{actionIndex: t.ActionIndex, index: t.Index, eID: t.EnrollmentID}
446+
if seenByPos[k] {
411447
return false
412448
}
413-
seen[k] = true
449+
seenByPos[k] = true
414450

415451
return true
416452
})

token/stream_test.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,17 @@ func TestOutputStream_UniquePerOutput(t *testing.T) {
285285
}
286286
}
287287

288+
// extractedInput builds an input the way the request's extraction path does,
289+
// so its position takes part in deduplication.
290+
func extractedInput(actionIndex, index int, id *token.ID, eID, rh string) *Input {
291+
in := NewInput(actionIndex, index, id)
292+
in.EnrollmentID = eID
293+
in.RevocationHandler = rh
294+
295+
return in
296+
}
297+
288298
func TestInputStream_UniquePerInput(t *testing.T) {
289-
// distinct pointers to the same token ID value must collapse
290299
cases := []struct {
291300
name string
292301
inputs []*Input
@@ -312,7 +321,7 @@ func TestInputStream_UniquePerInput(t *testing.T) {
312321
},
313322
},
314323
{
315-
"different token IDs with the same enrollment ID both survive",
324+
"different token IDs with the same enrollment ID both survive despite equal positions",
316325
[]*Input{
317326
{Id: &token.ID{TxId: "tx0", Index: 0}, EnrollmentID: "enroll1"},
318327
{Id: &token.ID{TxId: "tx0", Index: 1}, EnrollmentID: "enroll1"},
@@ -323,9 +332,49 @@ func TestInputStream_UniquePerInput(t *testing.T) {
323332
},
324333
},
325334
{
326-
"inputs with no token ID are all kept",
327-
[]*Input{{EnrollmentID: "enroll1"}, {EnrollmentID: "enroll1"}},
328-
[]*Input{{EnrollmentID: "enroll1"}, {EnrollmentID: "enroll1"}},
335+
"member rows with a filtered-out token ID collapse on action and input index",
336+
[]*Input{
337+
extractedInput(0, 0, nil, "enroll1", "first"),
338+
extractedInput(0, 0, nil, "enroll1", "second"),
339+
},
340+
[]*Input{extractedInput(0, 0, nil, "enroll1", "first")},
341+
},
342+
{
343+
"filtered rows with different positions or enrollment IDs all survive",
344+
[]*Input{
345+
extractedInput(0, 0, nil, "enroll1", ""),
346+
extractedInput(0, 1, nil, "enroll1", ""),
347+
extractedInput(1, 0, nil, "enroll1", ""),
348+
extractedInput(0, 0, nil, "enroll2", ""),
349+
},
350+
[]*Input{
351+
extractedInput(0, 0, nil, "enroll1", ""),
352+
extractedInput(0, 1, nil, "enroll1", ""),
353+
extractedInput(1, 0, nil, "enroll1", ""),
354+
extractedInput(0, 0, nil, "enroll2", ""),
355+
},
356+
},
357+
{
358+
"a row with a token ID never collides with a positionally identical row without one",
359+
[]*Input{
360+
extractedInput(0, 0, &token.ID{TxId: "tx0", Index: 0}, "enroll1", ""),
361+
extractedInput(0, 0, nil, "enroll1", ""),
362+
},
363+
[]*Input{
364+
extractedInput(0, 0, &token.ID{TxId: "tx0", Index: 0}, "enroll1", ""),
365+
extractedInput(0, 0, nil, "enroll1", ""),
366+
},
367+
},
368+
{
369+
"literal-built rows with no token ID never collapse on the zero-valued position",
370+
[]*Input{
371+
{EnrollmentID: "enroll1"},
372+
{EnrollmentID: "enroll1"},
373+
},
374+
[]*Input{
375+
{EnrollmentID: "enroll1"},
376+
{EnrollmentID: "enroll1"},
377+
},
329378
},
330379
}
331380
for _, tc := range cases {

0 commit comments

Comments
 (0)