Skip to content

Commit 4fa8965

Browse files
EvanYan1024AkramBitar
authored andcommitted
fix(auditor): attribute empty-EID inputs to their own token owner
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: #2242 (multi-sender representation), #2249 (action-scoped output filter), #2250 (cache entry lifetime), #2251 (audit-info preference order). Fixes #2198 Signed-off-by: Evan <evanyan@sign.global>
1 parent 0dd324a commit 4fa8965

9 files changed

Lines changed: 1258 additions & 79 deletions

File tree

docs/services/auditor.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ 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`, using the audit info attached to the input: the locally stored audit info of the spent token's owner where present, the one carried by the request metadata otherwise — a counterparty's audit info is not necessarily present locally. An owner the identity layer cannot decode counts as resolving to nothing; any other resolution failure — a storage error, a canceled context — fails the audit.
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. A composite owner issues one output row per member, all under one output index: members resolving to one enrollment ID attribute the input to it, while members spanning enrollment IDs fail the audit — a record keeps a single sender per action, and an unattributed input would credit the members without debiting anyone. Issued outputs that only partly resolve fail the audit for the same reason; when none resolve, nothing is credited and the input stays unattributed.
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, or not decodable by, 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+
An action whose inputs attribute to **more than one** enrollment ID — reachable when the tokens to spend are passed explicitly, since they are not constrained to a single wallet — fails the audit with an error naming the cause: a transaction record keeps a single sender per action, so the store cannot represent it. Representing multi-sender actions is tracked separately.
69+
70+
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.
71+
5572
## Distributed EID Locking
5673

5774
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: 179 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/LFDT-Panurus/panurus/token"
1616
"github.com/LFDT-Panurus/panurus/token/core/common/metrics"
17+
"github.com/LFDT-Panurus/panurus/token/services/identity"
1718
"github.com/LFDT-Panurus/panurus/token/services/logging"
1819
"github.com/LFDT-Panurus/panurus/token/services/network"
1920
"github.com/LFDT-Panurus/panurus/token/services/network/driver"
@@ -159,7 +160,13 @@ func (a *Service) Audit(ctx context.Context, tx Transaction) (*token.InputStream
159160
start := time.Now()
160161
logger.DebugfContext(ctx, "audit transaction [%s]....", tx.ID())
161162
request := tx.Request()
162-
record, err := request.AuditRecord(ctx)
163+
tms, err := a.bindProviderTMS(request)
164+
if err != nil {
165+
return nil, nil, err
166+
}
167+
// the record is completed before the enrollment IDs are collected, so that
168+
// the locks cover the enrollment ID every input is finally booked under
169+
record, err := newRequestWrapper(request, tms).AuditRecord(ctx)
163170
if err != nil {
164171
return nil, nil, errors.WithMessagef(err, "failed getting transaction audit record")
165172
}
@@ -217,7 +224,7 @@ func (a *Service) Append(ctx context.Context, tx Transaction) error {
217224
defer func() { a.metrics.AppendDuration.Observe(time.Since(start).Seconds()) }()
218225
defer a.Release(ctx, tx)
219226

220-
tms, err := a.tmsProvider.TokenManagementService(token.WithTMSID(a.tmsID))
227+
tms, err := a.bindProviderTMS(tx.Request())
221228
if err != nil {
222229
return err
223230
}
@@ -254,6 +261,22 @@ func (a *Service) Append(ctx context.Context, tx Transaction) error {
254261
return nil
255262
}
256263

264+
// bindProviderTMS resolves the TMS for the service's TMS ID through the
265+
// provider and rebinds the request to it. The record computation runs through
266+
// request.TokenService, so this is what keeps the request from influencing
267+
// which TMS computes and attributes the record.
268+
func (a *Service) bindProviderTMS(request *token.Request) (dep.TokenManagementServiceWithExtensions, error) {
269+
tms, err := a.tmsProvider.TokenManagementService(token.WithTMSID(a.tmsID))
270+
if err != nil {
271+
return nil, err
272+
}
273+
if err := tms.SetTokenManagementService(request); err != nil {
274+
return nil, err
275+
}
276+
277+
return tms, nil
278+
}
279+
257280
// Release releases the lock acquired of the passed transaction and drops the
258281
// audit record cached for it.
259282
func (a *Service) Release(ctx context.Context, tx Transaction) {
@@ -395,56 +418,197 @@ func (r *requestWrapper) PublicParamsHash() token.PPHash { return r.r.PublicPara
395418

396419
// AuditRecord retrieves the audit record for the wrapped token request and completes any
397420
// 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.
421+
// A record cached by Audit is returned as it stands: it was already attributed
422+
// there, and the locks were taken for the enrollment IDs it carries.
400423
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-
}
424+
// re-running the gap filling on a cached record could attribute an input
425+
// Audit deliberately left empty, booking it under an enrollment ID that
426+
// was never locked
427+
if r.cached != nil {
428+
return r.cached, nil
429+
}
430+
431+
record, err := r.r.AuditRecord(ctx)
432+
if err != nil {
433+
return nil, err
408434
}
409435
if err := r.completeInputsWithEmptyEID(ctx, record); err != nil {
410436
return nil, errors.WithMessagef(err, "failed filling gaps for request [%s]", r.r.Anchor)
411437
}
438+
if err := rejectMultiOwnerActions(record); err != nil {
439+
return nil, err
440+
}
412441

413442
return record, nil
414443
}
415444

445+
// rejectMultiOwnerActions fails when one action spends tokens attributed to
446+
// more than one enrollment ID. A transaction record keeps a single sender per
447+
// action, so the store would reject such a record only at Append time, with
448+
// an error that does not name the cause. It mirrors the store's grouping:
449+
// unattributed inputs are skipped.
450+
func rejectMultiOwnerActions(record *token.AuditRecord) error {
451+
firstEID := map[int]string{}
452+
for _, in := range record.Inputs.Inputs() {
453+
if in.EnrollmentID == "" {
454+
continue
455+
}
456+
eID, ok := firstEID[in.ActionIndex]
457+
if !ok {
458+
firstEID[in.ActionIndex] = in.EnrollmentID
459+
460+
continue
461+
}
462+
if eID != in.EnrollmentID {
463+
return errors.Errorf("action [%d] of request [%s] spends tokens of multiple enrollment IDs ([%s] and [%s]): a transaction record keeps a single sender per action", in.ActionIndex, record.Anchor, eID, in.EnrollmentID)
464+
}
465+
}
466+
467+
return nil
468+
}
469+
416470
// completeInputsWithEmptyEID fills in missing enrollment ID information for inputs in the audit record
417471
// 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.
472+
// Each input is attributed to the enrollment ID resolved from its own token
473+
// owner and the audit info the input carries — the locally stored audit info
474+
// of the owner where present, the one carried by the request otherwise (see
475+
// Request.AuditRecord). An owner the identity layer cannot decode counts as
476+
// resolving to nothing; any other resolution failure — a storage error, a
477+
// canceled context — fails the audit. An input the request describes no sender for is an
478+
// upgrade input, and falls back to the enrollment ID the request issues to;
479+
// an upgrade issued to a composite owner spanning enrollment IDs fails the
480+
// audit (see issuedToEIDAndRH). An owner that maps to no single enrollment ID
481+
// leaves its input unattributed rather than booked under a guessed enrollment
482+
// ID, so a record keeping such an input is not fully attributed on return.
419483
func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record *token.AuditRecord) error {
420484
filter := record.Inputs.ByEnrollmentID("")
421485
if filter.Count() == 0 {
422486
return nil
423487
}
424-
// TODO: extract from the audit tokens
425-
targetEID := record.Outputs.EnrollmentIDs()[0]
426488

427489
// fetch all the tokens
428490
tokens, err := r.tms.Vault().NewQueryEngine().ListAuditTokens(ctx, filter.IDs()...)
429491
if err != nil {
430492
return errors.WithMessagef(err, "failed listing tokens for [%s]", filter.IDs())
431493
}
494+
if filter.Count() != len(tokens) {
495+
return errors.Errorf("expected %d audit tokens, got %d", filter.Count(), len(tokens))
496+
}
432497
precision := r.tms.PublicParametersManager().PublicParameters().Precision()
498+
wm := r.tms.WalletManager()
433499
for i := range filter.Count() {
434500
item := filter.At(i)
435-
item.EnrollmentID = targetEID
501+
if tokens[i] == nil {
502+
return errors.Errorf("failed to audit inputs: nil input at [%d]th input", i)
503+
}
504+
// an input the request describes no sender for: extractIssueInputs fills
505+
// only the token id, so across the built-in drivers this is an upgrade
506+
upgraded := len(item.Owner) == 0
507+
436508
item.Owner = tokens[i].Owner
437509
item.Type = tokens[i].Type
438510
q, err := token2.ToQuantity(tokens[i].Quantity, precision)
439511
if err != nil {
440512
return errors.WithMessagef(err, "failed converting token quantity [%s]", tokens[i].Quantity)
441513
}
442514
item.Quantity = q
515+
516+
eID, rID, err := wm.GetEIDAndRH(ctx, item.Owner, item.OwnerAuditInfo)
517+
if err != nil {
518+
// only a decoding failure counts as "this owner does not resolve";
519+
// anything else — a storage failure, a canceled context — fails the
520+
// audit rather than silently leaving the input unattributed
521+
if ctx.Err() != nil || !errors.Is(err, identity.ErrUnresolvableIdentity) {
522+
return errors.WithMessagef(err, "failed resolving enrollment id for input [%v]", item.Id)
523+
}
524+
logger.DebugfContext(ctx, "owner of input [%v] does not resolve, treating it as unresolved: %v", item.Id, err)
525+
eID, rID = "", ""
526+
}
527+
if eID == "" && upgraded {
528+
// an upgrade re-issues the spent tokens to their owner under a fresh
529+
// identity, so the outputs of the very same action carry the
530+
// enrollment ID the input belongs to. The pre-upgrade identity
531+
// itself often resolves to nothing here: it predates the current
532+
// driver and the request metadata carries no audit info for it.
533+
eID, rID, err = issuedToEIDAndRH(record.Outputs, item.ActionIndex)
534+
if err != nil {
535+
return err
536+
}
537+
}
538+
if eID == "" {
539+
// the owner maps to no single enrollment ID — a composite owner,
540+
// or one whose audit info is unavailable. Leave the input
541+
// unattributed: amount aggregations skip an empty enrollment ID,
542+
// whereas a guessed one would be charged to the wrong party.
543+
continue
544+
}
545+
item.EnrollmentID = eID
546+
item.RevocationHandler = rID
443547
}
444548

445549
return nil
446550
}
447551

552+
// issuedToEIDAndRH returns the enrollment ID and revocation handle the given issue
553+
// action issues to, and empty values when it does not issue to exactly one party.
554+
// An issued output carries both an issuer and an owner; a redeem output carries an
555+
// issuer but no owner. Every issued output of the action must resolve to the same
556+
// enrollment ID. The handle is kept only while it stays paired with that ID.
557+
//
558+
// A composite owner issues one output row per member, all under one output index.
559+
// Members resolving to distinct enrollment IDs leave no single enrollment ID to
560+
// book the input under, and an unattributed input would credit the members
561+
// without debiting anyone: such an action fails the audit instead. Outputs that
562+
// only partly resolve fail the audit for the same reason; when none resolve,
563+
// nothing is credited and the input may stay unattributed.
564+
func issuedToEIDAndRH(outputs *token.OutputStream, actionIndex int) (string, string, error) {
565+
issued := outputs.Filter(func(o *token.Output) bool {
566+
return o.ActionIndex == actionIndex && len(o.Issuer) != 0 && len(o.Owner) != 0
567+
}).Outputs()
568+
if len(issued) == 0 {
569+
return "", "", nil
570+
}
571+
572+
unresolved := 0
573+
eIDByIndex := map[uint64]string{}
574+
for _, output := range issued {
575+
if output.EnrollmentID == "" {
576+
unresolved++
577+
578+
continue
579+
}
580+
if eID, ok := eIDByIndex[output.Index]; ok && eID != output.EnrollmentID {
581+
return "", "", errors.Errorf(
582+
"output [%d] of action [%d] is issued to a composite owner whose members span enrollment IDs ([%s] and [%s]): no single enrollment ID to attribute its input to",
583+
output.Index, actionIndex, eID, output.EnrollmentID,
584+
)
585+
}
586+
eIDByIndex[output.Index] = output.EnrollmentID
587+
}
588+
if unresolved == len(issued) {
589+
// nothing is credited to anybody, so nothing needs debiting
590+
return "", "", nil
591+
}
592+
if unresolved > 0 {
593+
return "", "", errors.Errorf(
594+
"action [%d] issues [%d] of [%d] outputs to owners resolving to no enrollment ID: crediting the resolved ones would leave the input undebited",
595+
actionIndex, unresolved, len(issued),
596+
)
597+
}
598+
599+
eID, rH := issued[0].EnrollmentID, issued[0].RevocationHandler
600+
for _, output := range issued[1:] {
601+
if output.EnrollmentID != eID {
602+
return "", "", nil
603+
}
604+
if output.RevocationHandler != rH {
605+
rH = ""
606+
}
607+
}
608+
609+
return eID, rH, nil
610+
}
611+
448612
// String returns a string representation of the wrapped token request.
449613
func (r *requestWrapper) String() string {
450614
return r.r.String()

0 commit comments

Comments
 (0)