fix(token): count composite-owner outputs once per enrollment ID in amount sums - #2148
fix(token): count composite-owner outputs once per enrollment ID in amount sums#2148EvanYan1024 wants to merge 9 commits into
Conversation
4a11ad2 to
92f93c6
Compare
…mount sums extractIssueOutputs/extractTransferOutputs emit one audit Output row per recipient of a composite owner, each carrying the full amount. Members of the same enrollment therefore multiply the amount by the member count in every eid-keyed sum (movements, holdings, transaction records). Deduplicating rows at extraction time is not an option: it would drop the members' identities and revocation handles from the output stream -- RevocationHandles() must surface a revoked second key and ByRecipient() must see every member. Keep one output row per member and collapse rows sharing (Index, EnrollmentID) only where amounts aggregate: a new OutputStream.UniquePerOutput feeds the ttxdb TransactionRecords and Movements sums (shared by the owner and auditor stores). Signed-off-by: Evan <evanyan@sign.global>
92f93c6 to
11bd6ca
Compare
|
Thanks a lot for this PR. Nice fix. One thing I wanted to ask about: does the same apply to the input side? extractTransferInputs looks like it expands members the same way (for _, sender := range input.Senders, same token ID), and AuditRecord() fills each row with the full quantity — so received is fixed but sent would still count once per member. Spending 40 from a two-member wallet gave me -80 rather than -40, so I may be missing something about that path. If it's the same shape, would an InputStream.UniquePerInput() keyed on (Id, EnrollmentID) be the natural fit, given Input has no index? Fine as a follow-up if you'd prefer. What do you think? |
…nt sums A spent input is expanded into one row per composite-owner member, each carrying the token's full quantity, and the Movements sent aggregation summed those rows directly: spending 40 from a two-member same-enrollment wallet booked as 80 sent. Add InputStream.UniquePerInput, keeping for each (token ID value, enrollment ID) pair only the first input -- rows with no token ID are all kept -- and apply it in the sent sum only, so identity and revocation consumers keep seeing every member row. Signed-off-by: Evan <evanyan@sign.global>
|
Thanks Akram, you are right. I traced the input path and confirmed that it has the same representation as the output side:
I’ll include this in the current PR since it is the same accounting issue rather than leave movements partially fixed. I’ll also update the composite-spend test to use two input member rows, add the Thanks a lot for the concrete -80 reproduction — it made the input-side gap easy to confirm. |
| @@ -371,6 +392,31 @@ func (is *InputStream) ByType(tokenType token.Type) *InputStream { | |||
| } | |||
|
|
|||
| // Sum returns the sum of the quantities of the inputs. | |||
There was a problem hiding this comment.
UniquePerInput was inserted between this comment and the func line it documents, so two things broke at once:
UniquePerInput's doc block now opens with "Sum returns the sum of the quantities of the inputs." — that first sentence is the synopsisgo doc, pkg.go.dev and editor hovers show, so it describes a different function.Sum(line 420) is left with no Godoc at all, against the AGENTS.md rule that all exported functions have one.
Nothing in .golangci.yml catches it: the rule would be revive's exported, and while there is a full revive: settings block with enable-all-rules: true, revive is not in linters.enable — so that block is inert.
Comment move only, no code change:
// 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.
func (is *InputStream) UniquePerInput() *InputStream {
...
}
// Sum returns the sum of the quantities of the inputs.
func (is *InputStream) Sum() *big.Int {|
@EvanYan1024 Thanks a lot, I have the following concerns, I think they may warrant a follow-up rather than changes in this PR. What do you think? 1. Different enrollment IDs. Both keys include 2. 3. Auditor views. |
…imit checks The sample auditor views summed the raw streams, so a composite owner whose members share an enrollment ID had its payment, cumulative and holding limits evaluated against amounts multiplied by the member count. Apply UniquePerOutput/UniquePerInput there as ttxdb already does, so the examples match the aggregation rule the documentation states. Also move the Sum godoc back onto Sum: UniquePerInput was inserted between the comment and the function it documents, leaving Sum undocumented and UniquePerInput with a synopsis describing Sum. Signed-off-by: Evan <evanyan@sign.global>
|
Thanks — I've done (3) here and would leave (1) as is and (2) as a follow-up. Reasoning below.
It is also a no-op where it is actually used: in Movements the deduplication runs after ByEnrollmentID(eID), so every row in the stream already carries the same enrollment ID. The eID in the key only guards a caller who reaches for UniquePerInput on an unfiltered stream.
Giving Input an index is the right fix, and there is no cheap substitute: ActionIndex cannot serve as the key because several distinct tokens share one action index, so keying on it would over-deduplicate. The index has to be carried through TransferInputMetadata, which is a driver metadata change and larger than this PR. I'll open a follow-up issue for it unless you'd rather it land here.
I ran the token module tests with -race and the pinned linter over both modules, but not the integration suite locally — leaving that to CI. |
Fixes #2147
What
OutputStream.UniquePerOutput, keeping for each(Index, EnrollmentID)pair only the first output, and apply it in the ttxdbTransactionRecordsandMovementssums (shared by the owner and auditor stores).InputStream.UniquePerInput, keeping for each(token ID value, EnrollmentID)pair only the first input — rows with no token ID are all kept — and apply it in theMovementssent sum. The input side has the same shape (raised in review below):extractTransferInputsemits one row per member with the same token ID andAuditRecordfills every row with the token's full quantity.Why
A composite owner (multisig, boolpolicy) is expanded into one audit row per member on both sides of a transaction, each row carrying the full amount. Members sharing an enrollment ID therefore multiply the amount by the member count in every eid-keyed sum: in our test environments a 100 deposit into a wallet whose owner held two keys of the same enrollment booked as 200 received, and review reproduced the symmetric sent side — a 40 spend from a two-member wallet booked as -80.
Deduplicating at extraction time instead would drop data that identity consumers need —
RevocationHandles()must surface a revoked second key andByRecipient()must see every member — so the collapse happens only where amounts aggregate.Testing
token/request_composite_output_test.go: composite owners with same-enrollment and cross-enrollment members, issue and transfer paths; asserts identity consumers still see every member row.token/services/storage/ttxdb/store_test.go:TransactionRecords/Movementsbook a composite-owner output once per enrollment ID; the composite-spend fixture expands the spent input into two member rows sharing one token ID, pinning the movement to -6 instead of -46.token/stream_test.go: table-driven contract tests forUniquePerOutputandUniquePerInput— the same(Index, EnrollmentID)/(token ID, EnrollmentID)pair collapses to the first row (for inputs via distinct pointers to the same token ID value); the same index or token ID with different enrollment IDs, and different indexes or token IDs with the same enrollment ID, all survive; inputs with no token ID are all kept.Docs
docs/services/storage/ttxdb.mdgains a "Composite Owners" note: one audit row per member for identity/revocation visibility, amount aggregations count each(output index, enrollment ID)/(token ID, enrollment ID)pair once.