Skip to content

fix(auditor): attribute empty-EID inputs to their own token owner - #2199

Open
EvanYan1024 wants to merge 3 commits into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/empty-eid-input-attribution
Open

fix(auditor): attribute empty-EID inputs to their own token owner#2199
EvanYan1024 wants to merge 3 commits into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/empty-eid-input-attribution

Conversation

@EvanYan1024

@EvanYan1024 EvanYan1024 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #2198

What

  • Attribute each input with an unresolved enrollment ID to its own spent token's owner, instead of to the first output's enrollment ID. The revocation handle is filled in from the same resolution.
  • An owner that maps to no single enrollment ID — a composite owner such as a multisig — leaves its input unattributed, with an empty enrollment ID.
  • A token upgrade is the exception: its issue metadata describes no sender for the inputs, and the pre-upgrade owner resolves to nothing here, so such an input takes the enrollment ID of the outputs issued by its own action, when every one of them resolves to the same party. The revocation handle comes from the same output.
  • Add WalletManager.GetEIDAndRH(ctx, identity, auditInfo), which prefers the audit info carried by the record and falls back to the locally stored one; when neither is available it returns empty values rather than an error.
  • Run the gap filling in Audit, before the enrollment IDs are collected, so the locks cover the enrollment ID each input is finally booked under, and return a record cached by Audit from Append as it stands. Append still attributes the record when called without a preceding Audit.
  • Guard the vault result the way Request.AuditRecord does — a length check plus a per-token nil check — since this code now runs on the audit approval path.

Why

In a payment the first output is the recipient, so the old targetEID := record.Outputs.EnrollmentIDs()[0] recorded the payer's spend against the counterparty: the recipient is charged a spend it never made, and the payer's spend is never booked. Neither side errors or warns. The TODO above that line marked it as a placeholder.

Leaving an unattributable input empty rather than guessing is the point of the change: EnrollmentIDs() skips empty enrollment IDs, so such an input is counted for nobody, which is the honest outcome when the owner genuinely maps to no single enrollment ID. A multisig input is exactly that — several members, no single one to charge — so it is a normal state to represent, not a failure to raise.

The upgrade case is the one where the same guess was right, and dropping it showed up as a red TokensUpgrade integration test (alice's holding 220 instead of 110). extractIssueInputs fills only the token id, and the pre-upgrade owner predates the current driver, so nothing in the record resolves it. But an upgrade re-issues the spent tokens to their owner under a fresh identity, so what the request issues to is the input's enrollment ID — leaving it empty credits the upgraded amount without ever debiting it. The fallback is confined to inputs the request describes no sender for, and scoped to the outputs of the input's own action: a second issue action neither suppresses the attribution nor lends its enrollment ID to it. Every issued output of that action must resolve to the same party — one resolving to none cannot be shown to belong to the others — and the handle is kept only while it stays paired with that enrollment ID.

The lifecycle matters as much as the attribution. Audit collects the enrollment IDs it locks from the record and Append writes that record, so filling the gaps only in Append would leave the final record booked against a payer that was never locked — an exact spend has no change output, so the payer's enrollment ID appears nowhere in what Audit saw.

The same reasoning applies in reverse to the cached record: re-running the gap filling in Append could attribute an input that Audit deliberately left empty, storing it under an enrollment ID that was never locked. Append therefore returns the cached record as it stands. Together these keep the locked set and the stored record in agreement, and remove the second vault read for records carrying an unattributed input.

Resolving from the input's own owner also drops the record.Outputs.EnrollmentIDs()[0] index-out-of-range on a record with no outputs.

Testing

  • token/services/auditor/auditor_internal_test.go: attribution from the token owner; record-carried audit info preferred over a local lookup (asserting no local lookup happens); a composite owner and a missing-audit-info owner both staying unattributed without an error, with the remaining fields still filled from the spent token; a genuine resolution error still propagating; a short vault answer and a nil vault token each erroring instead of panicking.
  • The upgrade fallback: an input with no sender and an unresolvable owner takes the issued enrollment ID and revocation handle; a transfer input whose owner maps to nothing keeps none even when the same request issues tokens; issuing to two parties, or to one party plus an output resolving to none, leaves the input unattributed; two handles under one enrollment ID keep the ID and drop the handle; a two-action record attributes each input from its own action.
  • TestRequestWrapper_AuditRecord_UpgradeInputAttributedToReceiver runs an upgrade-shaped request through the real Request.AuditRecord pipeline rather than a hand-written record, so what extractIssueInputs and extractIssueOutputs actually produce — the input arriving without an owner, the issued output carrying issuer, owner and enrollment ID under the same action index — is exercised instead of assumed. It also asserts the record's inputs and outputs sum equal, which is the holding that went to 220 in update-t1.
  • TestRequestWrapper_AuditRecord_CachedKeepsUnattributedInput: a record cached by Audit with an unattributed input is returned untouched even though the wallet service would now resolve it, with no further vault or identity lookup.
  • TestService_Audit_AttributesAndLocksEmptyEIDInput pins the lifecycle over an exact spend whose sender does not resolve locally: Audit returns the resolved payer enrollment ID and revocation handle, the acquired locks contain that payer and no empty ID, and Append reuses the record without a second vault read or identity lookup. Moving the gap filling back after lock acquisition fails this test.
  • Verified locally on both modules: gofmt, go build, go vet, go test -race, and golangci-lint v2.12.2 with the repo config.

Docs

docs/services/auditor.md gains an "Input Attribution" section: the per-input resolution order, the upgrade fallback, what happens to an owner with no single enrollment ID, and where in the Audit/Append lifecycle it runs relative to the EID locking documented just below it.

@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch 2 times, most recently from 5e052bf to 856fbd4 Compare August 12, 2026 07:00
@EvanYan1024 EvanYan1024 changed the title fix(auditor): attribute empty-EID inputs to their token owner, fail closed fix(auditor): attribute empty-EID inputs to their own token owner Aug 12, 2026
@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch from 856fbd4 to 0148b19 Compare August 12, 2026 07:04
@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch from 0148b19 to 6f7cbab Compare August 12, 2026 09:36
@AkramBitar

Copy link
Copy Markdown
Contributor

@EvanYan1024

Thanks a lot for submitting this PR.

According to my understanding (please correct me if I am wrong)

Problem

The auditor's ledger recorded the wrong person as the spender. When a spend arrived without an owner attached, the code guessed by picking the first name on the receiving side — which is the recipient, not the payer.

Fix

Look up each spent coin and record its actual previous owner as the spender. If ownership can't be pinned to a single person (e.g. a joint wallet), leave it blank rather than guess.

Scenario

  1. Alice pays Bob 100 tokens.
  2. The auditor processes the transaction. It knows 100 tokens were spent, but the spend record has no owner name on it.
  3. Old behaviour: it takes the first name on the receiving side — Bob — and writes "Bob spent 100."
  4. Result in the books:
  • Bob is charged for a payment he never made.
  • Alice's payment is missing entirely.
  • No error is raised. The books look complete and are silently wrong.
  1. New behaviour: it checks who owned those 100 tokens before the transaction — Alice — and writes "Alice spent 100."

I think the direction makes sense to me; attributing the spend to the actual token owner rather than the first output is clearly the right model. A few concerns before it lands:

  1. Multi-owner actions may not be storable. Since inputs are now resolved per owner, one action can produce more than one input EID. ttxdb/store.go:376 still rejects that case, so I think Audit could approve and Append then fail — leaving the record unstored after the tx has committed. Is that reachable in practice, or is something upstream guaranteeing a single owner?
  2. Godoc vs. behaviour. The comment at auditor.go:419 says an unattributable input fails the audit record, but the code continues and leaves it unattributed. Worth aligning the comment (and the doc page) with whichever is intended.
  3. Bounds/nil check on tokens[i]. auditor.go:437 indexes by filter position without the len(ids) != len(toks) / toks[i] == nil guards that Request.AuditRecord has. Since this now runs on the approval path, a short or nil vault result would panic there — might be worth mirroring those checks.
  4. Two TMS sources. Audit resolves via request.TokenService while Append uses tmsProvider.TokenManagementService(...). Probably equivalent today, but unifying them would remove the chance of divergence.
  5. Minor: the "returns immediately if already attributed" note holds only when every input was attributed — a deliberately unattributed composite/multisig input keeps ByEnrollmentID("") non-empty, so Append repeats the ListAuditTokens read.

Regards,
Akram

@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch from 6f7cbab to c21333e Compare August 13, 2026 02:02
@EvanYan1024

EvanYan1024 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@AkramBitar Thanks — your summary is exactly right. (2), (3) and (5) are fixed in the latest push; on (1) you were right to ask and my first answer was wrong; (4) I would like your opinion on.

1. Multi-owner actions. Correcting an earlier version of this reply, in which I claimed this was unreachable: it is reachable.

Request.Transfer does not constrain inputs to the wallet it is given. With WithTokenIDs, prepareTransfer goes through parseInputIDs, which validates only the token type and the sum, never that the tokens belong to the passed wallet; the wallet-scoped selector runs only when no token IDs were supplied. Request.Upgrade takes an explicit token list too. So a single action can span enrollment IDs, and TransactionRecords rejects it. I had checked only the selector path.

One detail of the consequence is milder than feared: AuditApproveView.Call appends before signAndSendBack, so a rejected Append means no auditor signature and the transaction cannot commit — it fails the audit rather than leaving a committed transaction unrecorded.

That leaves a real question: reject such an action earlier with a clear error, or teach the store to represent a multi-sender action. I would rather not settle it inside this PR — happy to open a separate issue unless you prefer it handled here.

2. Godoc vs. behaviour. Fixed — a leftover from an earlier revision that did fail closed. The Godoc and the doc page now both state that such an input is left unattributed.

3. Bounds/nil check. Fixed, mirroring Request.AuditRecord: a length check after the vault call and a tokens[i] == nil check in the loop. You are right that this became reachable once the gap filling moved onto the approval path — with the guards removed, the new short-answer test panics with index out of range [0] with length 0.

4. Two TMS sources. Narrowed by the push below: Append no longer resolves identities through its own TMS, so the two can no longer disagree on attribution. I would still rather you pick the direction for unifying them outright. I used request.TokenService in Audit because routing through tmsProvider changes Audit's dependency contract — it is nil in the service tests, one of which is named TestService_Audit_TMSProviderIrrelevant. Happy to unify here or as a follow-up.

5. The "returns immediately" note. Correct, and now moot: Append returns a record cached by Audit as it stands, so there is no second ListAuditTokens read at all.

Also pushed. Append no longer re-runs the gap filling on a cached record. Re-filling there could attribute an input that Audit deliberately left empty, storing it under an enrollment ID that was never locked — the same inconsistency this PR set out to remove, approached from the other side. Append still attributes the record when called without a preceding Audit.

Since then: update-t1 was red and it was mine — TokensUpgrade read alice's holding as 220 instead of 110. An upgrade is the one case where the old first-output guess was right: extractIssueInputs fills only the token id and the pre-upgrade owner resolves to nothing, so the input went unattributed and the upgraded amount was credited without ever being debited. Such an input — one the request describes no sender for — now takes the enrollment ID of the outputs issued by its own action, when every one of them resolves to the same party; the revocation handle comes from the same output. Covered by unit tests and by one that runs an upgrade-shaped request through the real Request.AuditRecord pipeline.

@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch 7 times, most recently from 3a4fce6 to 62c3f2c Compare August 13, 2026 08:21
completeInputsWithEmptyEID booked every input with an unresolved
enrollment ID under the first output's enrollment ID -- for a payment
that is the recipient, so the payer's whole input was recorded as spent
by the counterparty and both parties' eid-keyed audit balances corrupt
silently.

Resolve the enrollment ID (and revocation handle) from the input token's
owner instead, preferring the audit info already carried by the record
over the local identity store: WalletManager gains GetEIDAndRH taking
explicit audit info, falling back to the stored one when empty. An owner
that maps to no single enrollment ID -- a composite owner such as a
multisig, or one whose audit info this auditor does not hold -- leaves
its input unattributed. Amount aggregations skip an empty enrollment ID,
so an unattributed input is counted for nobody, whereas a guessed one is
charged to the wrong party.

A token upgrade is the exception: across the built-in drivers its issue
metadata describes no sender for the inputs, and the pre-upgrade owner
predates the current driver, so it resolves to nothing here. The upgrade re-issues the spent tokens to
the same party, so such an input takes the enrollment ID of the outputs
issued by its own action, when every one of them resolves to the same
party; the revocation handle comes from the same output. Leaving it
unattributed would credit the upgraded amount without ever debiting it
and double the owner's holding.

Run the gap filling in Audit, before the enrollment IDs are collected,
so the locks cover the enrollment ID each input is finally booked under,
and return a cached record from Append as it stands. Re-filling there
could attribute an input Audit deliberately left empty, storing it under
an enrollment ID that was never locked; Append still attributes the
record when called without a preceding Audit.

Guard the vault result the way Request.AuditRecord does, with a length
check and a nil check per token: the gap filling now runs on the audit
approval path, where indexing past a short answer would panic.

Fixes LFDT-Panurus#2198

Signed-off-by: Evan <evanyan@sign.global>
@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch from 62c3f2c to ecb71f9 Compare August 13, 2026 09:40
…-attribution

Signed-off-by: Evan <evanyan@sign.global>
…-attribution

Signed-off-by: Evan <evanyan@sign.global>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auditor books inputs with an unresolved enrollment ID against the first output's enrollment ID

2 participants