Problem
InputStream.UniquePerInput() (added in #2148) deduplicates the member rows of a composite-owner input by (token ID, enrollment ID), and keeps every input whose token ID is nil.
Token IDs are not always there. Metadata.filterTransfer builds the enrollment-ID-filtered copy of the transfer metadata by cloning only Senders and never TokenID (token/metadata.go), and ttx marshals exactly that filtered copy for each counterparty (token/services/ttx/marshaller.go, via FilterMetadataBy). On those consumers every Input carries Id == nil, so the deduplication no-ops and the member rows are summed again: spending 40 from a wallet whose two members share an enrollment ID is recorded as -80 in that party's transaction records and movements.
This gap was identified during the review of #2148 and deferred as a follow-up.
Proposed fix
No driver/metadata wire change is needed. The filtering is position-preserving: filterTransfer emits one (possibly empty) input-metadata entry per input, and Request.extractTransferInputs walks them by index, so the input's position within its action is available even when the token ID is not.
- Give
Input an Index field, its position within the action, mirroring the role of Output.Index.
- Key
UniquePerInput on (token ID, enrollment ID) when the token ID is present (behavior unchanged for existing callers), falling back to (action index, input index, enrollment ID) when it has been filtered out.
The two key spaces cannot mix: filterIssue keeps Inputs verbatim (token IDs included), so only transfer inputs ever lose their token ID, and the per-type action index is unique among transfers.
Problem
InputStream.UniquePerInput()(added in #2148) deduplicates the member rows of a composite-owner input by(token ID, enrollment ID), and keeps every input whose token ID is nil.Token IDs are not always there.
Metadata.filterTransferbuilds the enrollment-ID-filtered copy of the transfer metadata by cloning onlySendersand neverTokenID(token/metadata.go), andttxmarshals exactly that filtered copy for each counterparty (token/services/ttx/marshaller.go, viaFilterMetadataBy). On those consumers everyInputcarriesId == nil, so the deduplication no-ops and the member rows are summed again: spending 40 from a wallet whose two members share an enrollment ID is recorded as -80 in that party's transaction records and movements.This gap was identified during the review of #2148 and deferred as a follow-up.
Proposed fix
No driver/metadata wire change is needed. The filtering is position-preserving:
filterTransferemits one (possibly empty) input-metadata entry per input, andRequest.extractTransferInputswalks them by index, so the input's position within its action is available even when the token ID is not.InputanIndexfield, its position within the action, mirroring the role ofOutput.Index.UniquePerInputon(token ID, enrollment ID)when the token ID is present (behavior unchanged for existing callers), falling back to(action index, input index, enrollment ID)when it has been filtered out.The two key spaces cannot mix:
filterIssuekeepsInputsverbatim (token IDs included), so only transfer inputs ever lose their token ID, and the per-type action index is unique among transfers.