Skip to content

Commit c21333e

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. 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 #2198 Signed-off-by: Evan <evanyan@sign.global>
1 parent 255b225 commit c21333e

4 files changed

Lines changed: 409 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`. It costs nothing on a record whose inputs were all attributed; a record that keeps an unattributed input is read from the vault a second time, since such an input is indistinguishable from one that was never resolved.
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: 29 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,53 @@ 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 owner that maps to no single enrollment ID leaves its
423+
// input unattributed rather than booked under a guessed enrollment ID, so a
424+
// record keeping such an input is not fully attributed on return.
419425
func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record *token.AuditRecord) error {
420426
filter := record.Inputs.ByEnrollmentID("")
421427
if filter.Count() == 0 {
422428
return nil
423429
}
424-
// TODO: extract from the audit tokens
425-
targetEID := record.Outputs.EnrollmentIDs()[0]
426430

427431
// fetch all the tokens
428432
tokens, err := r.tms.Vault().NewQueryEngine().ListAuditTokens(ctx, filter.IDs()...)
429433
if err != nil {
430434
return errors.WithMessagef(err, "failed listing tokens for [%s]", filter.IDs())
431435
}
436+
if filter.Count() != len(tokens) {
437+
return errors.Errorf("retrieved less inputs than those in the transaction [%d][%d]", filter.Count(), len(tokens))
438+
}
432439
precision := r.tms.PublicParametersManager().PublicParameters().Precision()
440+
wm := r.tms.WalletManager()
433441
for i := range filter.Count() {
434442
item := filter.At(i)
435-
item.EnrollmentID = targetEID
443+
if tokens[i] == nil {
444+
return errors.Errorf("failed to audit inputs: nil input at [%d]th input", i)
445+
}
436446
item.Owner = tokens[i].Owner
437447
item.Type = tokens[i].Type
438448
q, err := token2.ToQuantity(tokens[i].Quantity, precision)
439449
if err != nil {
440450
return errors.WithMessagef(err, "failed converting token quantity [%s]", tokens[i].Quantity)
441451
}
442452
item.Quantity = q
453+
454+
eID, rID, err := wm.GetEIDAndRH(ctx, item.Owner, item.OwnerAuditInfo)
455+
if err != nil {
456+
return errors.WithMessagef(err, "failed resolving enrollment id for input [%v]", item.Id)
457+
}
458+
if eID == "" {
459+
// the owner maps to no single enrollment ID — a composite owner,
460+
// or one whose audit info is unavailable. Leave the input
461+
// unattributed: amount aggregations skip an empty enrollment ID,
462+
// whereas a guessed one would be charged to the wrong party.
463+
continue
464+
}
465+
item.EnrollmentID = eID
466+
item.RevocationHandler = rID
443467
}
444468

445469
return nil

0 commit comments

Comments
 (0)