Skip to content

Commit 0148b19

Browse files
committed
fix(auditor): attribute empty-EID inputs to their own token owner
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. 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. Append keeps its call as a fallback for callers that skip Audit; on an already attributed record it returns immediately, leaving the number of vault reads unchanged. Fixes #2198 Signed-off-by: Evan <evanyan@sign.global>
1 parent 714a6b4 commit 0148b19

4 files changed

Lines changed: 372 additions & 19 deletions

File tree

docs/services/auditor.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ Auditors use specialized wallets (Auditor Wallets) managed by the **Identity Ser
5252

5353
The service provides the `AuditApproveView`, which auditors use to respond to incoming audit requests. This view automates the verification and signing process, ensuring that the auditor only approves transactions that are fully compliant with the system's public parameters.
5454

55+
## Input Attribution
56+
57+
An audit record pairs every input and output with an enrollment ID. Inputs do not always carry one, so those left empty are resolved before the record is stored; the rest are untouched.
58+
59+
Each such input is resolved from its own spent token:
60+
61+
1. The spent token is read from the vault, yielding its owner, type, and quantity.
62+
2. The owner is resolved to an enrollment ID and revocation handle through `WalletManager.GetEIDAndRH`, preferring the audit info carried by the record over the locally stored one — a counterparty's audit info is not necessarily present locally.
63+
64+
An owner that maps to no single enrollment ID — a composite owner such as a multisig, or one whose audit info is not available to this auditor — leaves its input **unattributed**, with an empty enrollment ID. Amount aggregations skip empty enrollment IDs, so an unattributed input is counted for nobody. Guessing instead, for instance from the first output, would in a payment attribute the payer's spending to the recipient and silently corrupt both balances.
65+
66+
Attribution runs in `Audit`, before the enrollment IDs are collected, so the EID locking described below covers the enrollment ID each input is finally booked under. `Append` repeats it for callers that skip `Audit`; on an already attributed record it is a no-op.
67+
5568
## Distributed EID Locking
5669

5770
When multiple auditor replicas share the same AuditDB (PostgreSQL), concurrent processing of the same enrollment IDs (EIDs) must be serialized. The **auditor locker** (`token/services/storage/auditdb/locker`) coordinates exclusive access to EIDs during audit record writes.

token/services/auditor/auditor.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ func (a *Service) Audit(ctx context.Context, tx Transaction) (*token.InputStream
159159
start := time.Now()
160160
logger.DebugfContext(ctx, "audit transaction [%s]....", tx.ID())
161161
request := tx.Request()
162-
record, err := request.AuditRecord(ctx)
162+
// the record is completed before the enrollment IDs are collected, so that
163+
// the locks cover the enrollment ID every input is finally booked under
164+
record, err := newRequestWrapper(request, request.TokenService).AuditRecord(ctx)
163165
if err != nil {
164166
return nil, nil, errors.WithMessagef(err, "failed getting transaction audit record")
165167
}
@@ -415,31 +417,46 @@ func (r *requestWrapper) AuditRecord(ctx context.Context) (*token.AuditRecord, e
415417

416418
// completeInputsWithEmptyEID fills in missing enrollment ID information for inputs in the audit record
417419
// by querying the token vault. This is necessary when inputs don't have enrollment IDs explicitly set.
418-
// It uses the first output's enrollment ID as the target and retrieves token details from the vault.
420+
// Each input is attributed to the enrollment ID resolved from its own token
421+
// owner and the audit info carried by the record (falling back to the locally
422+
// stored audit info); an input that cannot be attributed fails the audit
423+
// record instead of being booked under a guessed enrollment ID.
419424
func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record *token.AuditRecord) error {
420425
filter := record.Inputs.ByEnrollmentID("")
421426
if filter.Count() == 0 {
422427
return nil
423428
}
424-
// TODO: extract from the audit tokens
425-
targetEID := record.Outputs.EnrollmentIDs()[0]
426429

427430
// fetch all the tokens
428431
tokens, err := r.tms.Vault().NewQueryEngine().ListAuditTokens(ctx, filter.IDs()...)
429432
if err != nil {
430433
return errors.WithMessagef(err, "failed listing tokens for [%s]", filter.IDs())
431434
}
432435
precision := r.tms.PublicParametersManager().PublicParameters().Precision()
436+
wm := r.tms.WalletManager()
433437
for i := range filter.Count() {
434438
item := filter.At(i)
435-
item.EnrollmentID = targetEID
436439
item.Owner = tokens[i].Owner
437440
item.Type = tokens[i].Type
438441
q, err := token2.ToQuantity(tokens[i].Quantity, precision)
439442
if err != nil {
440443
return errors.WithMessagef(err, "failed converting token quantity [%s]", tokens[i].Quantity)
441444
}
442445
item.Quantity = q
446+
447+
eID, rID, err := wm.GetEIDAndRH(ctx, item.Owner, item.OwnerAuditInfo)
448+
if err != nil {
449+
return errors.WithMessagef(err, "failed resolving enrollment id for input [%v]", item.Id)
450+
}
451+
if eID == "" {
452+
// the owner maps to no single enrollment ID — a composite owner,
453+
// or one whose audit info is unavailable. Leave the input
454+
// unattributed: amount aggregations skip an empty enrollment ID,
455+
// whereas a guessed one would be charged to the wrong party.
456+
continue
457+
}
458+
item.EnrollmentID = eID
459+
item.RevocationHandler = rID
443460
}
444461

445462
return nil

0 commit comments

Comments
 (0)