Skip to content

Commit 409657b

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. 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 #2198 Signed-off-by: Evan <evanyan@sign.global>
1 parent c5fd305 commit 409657b

4 files changed

Lines changed: 743 additions & 31 deletions

File tree

docs/services/auditor.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ 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+
Across the built-in drivers, a **token upgrade** is the action whose metadata describes no sender for its inputs, and whose pre-upgrade owner often resolves to nothing: that identity predates the current driver and the request carries no audit info for it. Since an upgrade re-issues the spent tokens to the same party under a fresh identity, 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, and is dropped when the outputs carry more than one. Without this the upgraded amount would be credited to the owner without ever being debited, doubling the holding.
65+
66+
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.
67+
68+
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` reuses the record `Audit` attributed and stores it as it stands; it attributes the record itself only when called without a preceding `Audit`. An input left unattributed by `Audit` therefore stays unattributed, and no enrollment ID outside the locked set can reach the store.
69+
5570
## Distributed EID Locking
5671

5772
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: 85 additions & 14 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
}
@@ -395,16 +397,19 @@ func (r *requestWrapper) PublicParamsHash() token.PPHash { return r.r.PublicPara
395397

396398
// AuditRecord retrieves the audit record for the wrapped token request and completes any
397399
// inputs with missing enrollment IDs by querying the token vault.
398-
// The gap filling always runs, also on a cached record, because it depends on
399-
// the current vault state.
400+
// A record cached by Audit is returned as it stands: it was already attributed
401+
// there, and the locks were taken for the enrollment IDs it carries.
400402
func (r *requestWrapper) AuditRecord(ctx context.Context) (*token.AuditRecord, error) {
401-
record := r.cached
402-
if record == nil {
403-
var err error
404-
record, err = r.r.AuditRecord(ctx)
405-
if err != nil {
406-
return nil, err
407-
}
403+
// re-running the gap filling on a cached record could attribute an input
404+
// Audit deliberately left empty, booking it under an enrollment ID that
405+
// was never locked
406+
if r.cached != nil {
407+
return r.cached, nil
408+
}
409+
410+
record, err := r.r.AuditRecord(ctx)
411+
if err != nil {
412+
return nil, err
408413
}
409414
if err := r.completeInputsWithEmptyEID(ctx, record); err != nil {
410415
return nil, errors.WithMessagef(err, "failed filling gaps for request [%s]", r.r.Anchor)
@@ -415,36 +420,102 @@ func (r *requestWrapper) AuditRecord(ctx context.Context) (*token.AuditRecord, e
415420

416421
// completeInputsWithEmptyEID fills in missing enrollment ID information for inputs in the audit record
417422
// 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.
423+
// Each input is attributed to the enrollment ID resolved from its own token
424+
// owner and the audit info carried by the record (falling back to the locally
425+
// stored audit info). An input the request describes no sender for is an upgrade
426+
// input, and falls back to the enrollment ID the request issues to. An owner that
427+
// maps to no single enrollment ID leaves its input unattributed rather than booked
428+
// under a guessed enrollment ID, so a record keeping such an input is not fully
429+
// attributed on return.
419430
func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record *token.AuditRecord) error {
420431
filter := record.Inputs.ByEnrollmentID("")
421432
if filter.Count() == 0 {
422433
return nil
423434
}
424-
// TODO: extract from the audit tokens
425-
targetEID := record.Outputs.EnrollmentIDs()[0]
426435

427436
// fetch all the tokens
428437
tokens, err := r.tms.Vault().NewQueryEngine().ListAuditTokens(ctx, filter.IDs()...)
429438
if err != nil {
430439
return errors.WithMessagef(err, "failed listing tokens for [%s]", filter.IDs())
431440
}
441+
if filter.Count() != len(tokens) {
442+
return errors.Errorf("expected %d audit tokens, got %d", filter.Count(), len(tokens))
443+
}
432444
precision := r.tms.PublicParametersManager().PublicParameters().Precision()
445+
wm := r.tms.WalletManager()
433446
for i := range filter.Count() {
434447
item := filter.At(i)
435-
item.EnrollmentID = targetEID
448+
if tokens[i] == nil {
449+
return errors.Errorf("failed to audit inputs: nil input at [%d]th input", i)
450+
}
451+
// an input the request describes no sender for: extractIssueInputs fills
452+
// only the token id, so across the built-in drivers this is an upgrade
453+
upgraded := len(item.Owner) == 0
454+
436455
item.Owner = tokens[i].Owner
437456
item.Type = tokens[i].Type
438457
q, err := token2.ToQuantity(tokens[i].Quantity, precision)
439458
if err != nil {
440459
return errors.WithMessagef(err, "failed converting token quantity [%s]", tokens[i].Quantity)
441460
}
442461
item.Quantity = q
462+
463+
eID, rID, err := wm.GetEIDAndRH(ctx, item.Owner, item.OwnerAuditInfo)
464+
if err != nil {
465+
return errors.WithMessagef(err, "failed resolving enrollment id for input [%v]", item.Id)
466+
}
467+
if eID == "" && upgraded {
468+
// an upgrade re-issues the spent tokens to their owner under a fresh
469+
// identity, so the outputs of the very same action carry the
470+
// enrollment ID the input belongs to. The pre-upgrade identity
471+
// itself often resolves to nothing here: it predates the current
472+
// driver and the request metadata carries no audit info for it.
473+
eID, rID = issuedToEIDAndRH(record.Outputs, item.ActionIndex)
474+
}
475+
if eID == "" {
476+
// the owner maps to no single enrollment ID — a composite owner,
477+
// or one whose audit info is unavailable. Leave the input
478+
// unattributed: amount aggregations skip an empty enrollment ID,
479+
// whereas a guessed one would be charged to the wrong party.
480+
continue
481+
}
482+
item.EnrollmentID = eID
483+
item.RevocationHandler = rID
443484
}
444485

445486
return nil
446487
}
447488

489+
// issuedToEIDAndRH returns the enrollment ID and revocation handle the given issue
490+
// action issues to, and empty values when it does not issue to exactly one party.
491+
// An issued output carries both an issuer and an owner; a redeem output carries an
492+
// issuer but no owner. Every issued output of the action must resolve to the same
493+
// enrollment ID: one resolving to none cannot be shown to belong to the same party
494+
// as the others. The handle is kept only while it stays paired with that ID.
495+
func issuedToEIDAndRH(outputs *token.OutputStream, actionIndex int) (string, string) {
496+
issued := outputs.Filter(func(o *token.Output) bool {
497+
return o.ActionIndex == actionIndex && len(o.Issuer) != 0 && len(o.Owner) != 0
498+
}).Outputs()
499+
if len(issued) == 0 {
500+
return "", ""
501+
}
502+
503+
eID, rH := issued[0].EnrollmentID, issued[0].RevocationHandler
504+
if eID == "" {
505+
return "", ""
506+
}
507+
for _, output := range issued[1:] {
508+
if output.EnrollmentID != eID {
509+
return "", ""
510+
}
511+
if output.RevocationHandler != rH {
512+
rH = ""
513+
}
514+
}
515+
516+
return eID, rH
517+
}
518+
448519
// String returns a string representation of the wrapped token request.
449520
func (r *requestWrapper) String() string {
450521
return r.r.String()

0 commit comments

Comments
 (0)