Skip to content

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

Merged
AkramBitar merged 1 commit into
LFDT-Panurus:mainfrom
Built-by-Sign:fix/empty-eid-input-attribution
Aug 18, 2026
Merged

fix(auditor): attribute empty-EID inputs to their own token owner#2199
AkramBitar merged 1 commit 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 resolves through the audit info attached to the input — the locally stored audit info of the spent token's owner where present (Request.AuditRecord fills it in), the request-carried one otherwise; 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.

Review rounds

@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 8 times, most recently from 62c3f2c to ecb71f9 Compare August 13, 2026 09:40

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EvanYan1024

Thanks a lot for all the efforts that you put on that PR.

I did additional review on what we have.

Regarding your question: "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."

I preferer to handle it in different issue, however I leave this to you.

Thanks a million,
Akram

Optional / follow-up:

  • token/services/storage/ttxdb/store.go:376 — happy to defer the multi-owner case as agreed. Two small asks: open the follow-up issue as part of this PR, and reject at Audit time with a message that names the multi-owner cause. Today it surfaces as expected at most 1 input enrollment id from Append, which is easy to misdiagnose. Optional for this PR.
  • Documented preference order is inverted. docs/services/auditor.md:62 and WalletManager.GetEIDAndRH's godoc (token/wallet.go:169) both say the record-carried audit info is preferred over the locally stored one, but Request.AuditRecord has already overwritten in.OwnerAuditInfo with the local lookup of toks[i].Owner before completeInputsWithEmptyEID runs — so in practice the local one wins wherever it exists. TestRequestWrapper_CompleteInputsWithEmptyEID_UsesRecordAuditInfo hand-builds the record, so it doesn't catch this. Optional.
  • auditor.go:437 re-reads audit tokens that Request.AuditRecord already fetched, only to add Owner. Having AuditRecord set in.Owner = toks[i].Owner would remove a vault query per unattributed input. Optional, nit.

Comment thread token/services/auditor/auditor.go
Comment thread token/services/auditor/auditor.go Outdated
Comment thread token/services/auditor/auditor.go
Comment thread token/services/auditor/auditor.go
Comment thread token/services/auditor/auditor.go Outdated
@EvanYan1024

Copy link
Copy Markdown
Contributor Author

@AkramBitar Done:

On the optional points:

  • Preference order: confirmed inverted — the local lookup wins wherever it exists. Which one should win is a semantics call I'd keep out of this PR; happy to fix the docs here or flip the behavior in a follow-up, your pick.
  • Vault re-read: in.Owner = toks[i].Owner would also rewrite attributed composite member rows, whose Owner is deliberately the member identity; limiting it to gap-filled rows loses the upgrade signal (len(Owner) == 0). I'd keep the read — one query per audited tx, only when something is unattributed.

@AkramBitar

AkramBitar commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@EvanYan1024,

Thanks for #2242 and 5700b38a — the audit-time rejection looks good, and I’m happy with the vault re-read reasoning, so let’s drop that one.

I re-checked the remaining points against 84b055a5f. Here’s where I’m at:

1. Composite-owner upgrade is still credited without being debited (auditor.go:523)
issuedToEIDAndRH compares EIDs across outputs without deduplicating by Index. Since composite-owner recipients share the same Index, an upgrade can become unattributed on the input side while still being credited to the members. Could we deduplicate by Index and add a test for the composite-owner upgrade case?

2. Owner-resolution errors still fail the audit (auditor.go:493)
GetEIDAndRH errors return before the upgraded/unattributed fallback. This means older/unresolvable owners can still make the audit fail. Could we treat these resolution failures as an empty EID on the fallback paths? This would also require updating TestCompleteInputsWithEmptyEID_OwnerResolutionErrorPropagates.

3. Documented preference order is inverted
The code prefers the local lookup, contrary to the docs and GetEIDAndRH godoc. My preference is to fix the docs in this PR and follow up on the behavior separately, keeping this PR focused. What do you think?

4. Action-scoped output filter (auditor.go:525)
The current issuer/owner heuristic works today but is not guaranteed by construction. Scoping this by action would be safer. Would you prefer to fix this here or follow up?

5. Cache entry lifetime (auditor.go:407)
Append releases the cache entry, so a later Append for the same transaction can re-run attribution without the original locks. Worth either keeping the entry until finality or rejecting a second Append. Would you prefer to address this here or follow up?

6. TMS source (auditor.go:164)
Audit uses request.TokenService, while Append uses the provider. Standardizing both on the provider would prevent the caller from influencing attribution. Would you prefer to fix this here or follow up?

Thanks again — I think 1 and 2 are the main blockers, while I’d like your call on 3–6. What do you think?

@EvanYan1024

Copy link
Copy Markdown
Contributor Author

@AkramBitar Thanks — all six addressed. 1, 2 and 6 are in the pushed commits; 3's docs are fixed here; 3's behavior, 4 and 5 are now #2251, #2249 and #2250.

1. Composite-owner upgrade. Working through the cases, deduplicating by (Index, EnrollmentID) as UniquePerOutput does turns out to change no outcome: members sharing one enrollment ID already attribute (now pinned by a test), and members spanning enrollment IDs still leave two EIDs after dedup. So issuedToEIDAndRH distinguishes by Index instead, and fails the audit for every shape that would credit the members while debiting nobody: members of one output index spanning enrollment IDs fail naming both IDs; issued outputs that only partly resolve fail for the same reason; when none resolve, nothing is credited and the input stays unattributed; members sharing one enrollment ID attribute as before. This mirrors the multi-owner rejection from the previous round; representing multi-sender actions stays with #2242. Four tests cover these shapes.

2. Owner-resolution errors. Done, with the classification made at the layer that knows: EIDRHDeserializer marks exactly its decoding failures — an identity that does not unmarshal, a type with no deserializer, audit info the deserializer rejects — with a new identity.ErrUnresolvableIdentity, keeping the cause in the chain via errors.Join. The gap filling falls back to unattributed only for that class, so an upgrade input still takes the issued enrollment ID and the older-driver owner you described no longer blocks the upgrade; storage failures and context cancellation propagate and fail the audit. Tests renamed and extended accordingly.

3. Preference order. Docs fixed here: the locally stored audit info wins, since Request.AuditRecord fills it in before the gap filling runs. One note: WalletManager.GetEIDAndRH's own godoc describes the function's parameter fallback and stays accurate — the inversion sat one level up. Flipping the behavior: #2251.

4. Action-scoped filter#2249. 5. Cache entry lifetime#2250.

6. TMS source. Done, one step further than resolving through the provider: the record computation itself runs through request.TokenService, so resolving alone would still let the caller's TMS do the extraction. Audit and Append now rebind the request via SetTokenManagementService (shared bindProviderTMS), and a provider error fails the audit. Regression tests run a request carrying a TMS that resolves different enrollment IDs than the provider's through both paths, asserting the record and the locks reflect only the provider's.

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EvanYan1024

Thanks a million for all the efforts that you put on this PR. I appreciate your support a lot.

The PR LGTM.

One last thing could you please rebase on top of latest main and squash all the commits to one.

Regards,
Akram

The auditor attributed an input with an unresolved enrollment ID to the
first output's enrollment ID — in a payment, the recipient — charging
the counterparty and never booking the payer. Each such input is now
resolved from its own spent token's owner, and the gap filling runs in
Audit so the EID locks cover what the record is finally booked under;
Append reuses the record Audit attributed.

An owner that maps to no single enrollment ID — a composite owner such
as a multisig — leaves its input unattributed rather than booked under
a guess. Only decoding failures count as unresolvable: the identity
layer marks them with identity.ErrUnresolvableIdentity, keeping the
cause in the chain; storage failures and context cancellation fail the
audit.

A token upgrade describes no sender for its inputs and its pre-upgrade
owner often resolves to nothing, 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. Shapes that would credit the members while
debiting nobody fail the audit instead: an action spending tokens of
multiple enrollment IDs, a composite owner spanning enrollment IDs,
issued outputs that only partly resolve.

Audit and Append rebind the request to the provider-resolved TMS before
the record is computed, so the request cannot influence which TMS
attributes the record.

Follow-ups: LFDT-Panurus#2242 (multi-sender representation), LFDT-Panurus#2249 (action-scoped
output filter), LFDT-Panurus#2250 (cache entry lifetime), LFDT-Panurus#2251 (audit-info
preference order).

Fixes LFDT-Panurus#2198

Signed-off-by: Evan <evanyan@sign.global>
@EvanYan1024
EvanYan1024 force-pushed the fix/empty-eid-input-attribution branch from f127045 to 6c77de6 Compare August 18, 2026 09:06
@EvanYan1024

Copy link
Copy Markdown
Contributor Author

@AkramBitar Done — rebased on latest main and squashed to a single commit (6c77de6d). Thanks for the thorough review!

@AkramBitar
AkramBitar merged commit 4fa8965 into LFDT-Panurus:main Aug 18, 2026
152 checks passed
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