Skip to content

Commit bd3ccbf

Browse files
committed
fix(auditor): composite-owner upgrades and undecodable owners in input attribution
An upgrade issued back to a composite owner emits one output row per member under a shared output index. Members resolving to one enrollment ID attribute the upgrade input to it, now covered by tests; members spanning enrollment IDs fail the audit naming both IDs — a record keeps a single sender per action, and leaving the input unattributed would credit the members without debiting anyone. Refs #2242. The identity layer signals an owner it cannot decode — an unknown identity type, a missing deserializer, undecodable audit info — by returning an error. Treat such an owner as unresolved instead of failing the audit: a transfer input stays unattributed, an upgrade input still takes the issued enrollment ID. Also correct the documented audit-info preference order: the locally stored audit info wins wherever it exists, since Request.AuditRecord fills it in before the gap filling runs. Signed-off-by: Evan <evanyan@sign.global>
1 parent 84b055a commit bd3ccbf

3 files changed

Lines changed: 149 additions & 25 deletions

File tree

docs/services/auditor.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ An audit record pairs every input and output with an enrollment ID. Inputs do no
5959
Each such input is resolved from its own spent token:
6060

6161
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.
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.
6363

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.
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.
6565

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.
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.
6767

6868
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.
6969

token/services/auditor/auditor.go

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,15 @@ func rejectMultiOwnerActions(record *token.AuditRecord) error {
449449
// completeInputsWithEmptyEID fills in missing enrollment ID information for inputs in the audit record
450450
// by querying the token vault. This is necessary when inputs don't have enrollment IDs explicitly set.
451451
// Each input is attributed to the enrollment ID resolved from its own token
452-
// owner and the audit info carried by the record (falling back to the locally
453-
// stored audit info). An input the request describes no sender for is an upgrade
454-
// input, and falls back to the enrollment ID the request issues to. An owner that
455-
// maps to no single enrollment ID leaves its input unattributed rather than booked
456-
// under a guessed enrollment ID, so a record keeping such an input is not fully
457-
// attributed on return.
452+
// owner and the audit info the input carries — the locally stored audit info
453+
// of the owner where present, the one carried by the request otherwise (see
454+
// Request.AuditRecord). An owner the identity layer cannot decode counts as
455+
// resolving to nothing. An input the request describes no sender for is an
456+
// upgrade input, and falls back to the enrollment ID the request issues to;
457+
// an upgrade issued to a composite owner spanning enrollment IDs fails the
458+
// audit (see issuedToEIDAndRH). An owner that maps to no single enrollment ID
459+
// leaves its input unattributed rather than booked under a guessed enrollment
460+
// ID, so a record keeping such an input is not fully attributed on return.
458461
func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record *token.AuditRecord) error {
459462
filter := record.Inputs.ByEnrollmentID("")
460463
if filter.Count() == 0 {
@@ -490,15 +493,22 @@ func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record
490493

491494
eID, rID, err := wm.GetEIDAndRH(ctx, item.Owner, item.OwnerAuditInfo)
492495
if err != nil {
493-
return errors.WithMessagef(err, "failed resolving enrollment id for input [%v]", item.Id)
496+
// the identity layer reports an owner it cannot decode — an unknown
497+
// identity type, or audit info it cannot deserialize — as an error;
498+
// such an owner is unresolvable here, not a failure of the audit
499+
logger.DebugfContext(ctx, "owner of input [%v] does not resolve, treating it as unresolved: %v", item.Id, err)
500+
eID, rID = "", ""
494501
}
495502
if eID == "" && upgraded {
496503
// an upgrade re-issues the spent tokens to their owner under a fresh
497504
// identity, so the outputs of the very same action carry the
498505
// enrollment ID the input belongs to. The pre-upgrade identity
499506
// itself often resolves to nothing here: it predates the current
500507
// driver and the request metadata carries no audit info for it.
501-
eID, rID = issuedToEIDAndRH(record.Outputs, item.ActionIndex)
508+
eID, rID, err = issuedToEIDAndRH(record.Outputs, item.ActionIndex)
509+
if err != nil {
510+
return err
511+
}
502512
}
503513
if eID == "" {
504514
// the owner maps to no single enrollment ID — a composite owner,
@@ -520,28 +530,47 @@ func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record
520530
// issuer but no owner. Every issued output of the action must resolve to the same
521531
// enrollment ID: one resolving to none cannot be shown to belong to the same party
522532
// as the others. The handle is kept only while it stays paired with that ID.
523-
func issuedToEIDAndRH(outputs *token.OutputStream, actionIndex int) (string, string) {
533+
//
534+
// A composite owner issues one output row per member, all under one output index.
535+
// Members resolving to distinct enrollment IDs leave no single enrollment ID to
536+
// book the input under, and an unattributed input would credit the members
537+
// without debiting anyone: such an action fails the audit instead.
538+
func issuedToEIDAndRH(outputs *token.OutputStream, actionIndex int) (string, string, error) {
524539
issued := outputs.Filter(func(o *token.Output) bool {
525540
return o.ActionIndex == actionIndex && len(o.Issuer) != 0 && len(o.Owner) != 0
526541
}).Outputs()
527542
if len(issued) == 0 {
528-
return "", ""
543+
return "", "", nil
544+
}
545+
546+
eIDByIndex := map[uint64]string{}
547+
for _, output := range issued {
548+
if output.EnrollmentID == "" {
549+
continue
550+
}
551+
if eID, ok := eIDByIndex[output.Index]; ok && eID != output.EnrollmentID {
552+
return "", "", errors.Errorf(
553+
"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",
554+
output.Index, actionIndex, eID, output.EnrollmentID,
555+
)
556+
}
557+
eIDByIndex[output.Index] = output.EnrollmentID
529558
}
530559

531560
eID, rH := issued[0].EnrollmentID, issued[0].RevocationHandler
532561
if eID == "" {
533-
return "", ""
562+
return "", "", nil
534563
}
535564
for _, output := range issued[1:] {
536565
if output.EnrollmentID != eID {
537-
return "", ""
566+
return "", "", nil
538567
}
539568
if output.RevocationHandler != rH {
540569
rH = ""
541570
}
542571
}
543572

544-
return eID, rH
573+
return eID, rH, nil
545574
}
546575

547576
// String returns a string representation of the wrapped token request.

token/services/auditor/auditor_internal_test.go

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,8 @@ func TestCompleteInputsWithEmptyEID_TransferInputIgnoresIssuedEID(t *testing.T)
447447
assert.Empty(t, record.Inputs.At(0).EnrollmentID)
448448
}
449449

450-
// Issuing to more than one party leaves the upgrade input unattributed: there is
451-
// no single enrollment ID to charge it to.
450+
// Issuing to more than one party — two outputs, each with its own index — leaves
451+
// the upgrade input unattributed: there is no single enrollment ID to charge it to.
452452
func TestCompleteInputsWithEmptyEID_AmbiguousIssuedEIDStaysUnattributed(t *testing.T) {
453453
tmsWithToken, ws := newInternalTestManagementServiceWithTokens(t, []*token2.Token{
454454
{Type: "USD", Quantity: "100", Owner: []byte("pre-upgrade-owner")},
@@ -460,8 +460,8 @@ func TestCompleteInputsWithEmptyEID_AmbiguousIssuedEIDStaysUnattributed(t *testi
460460
record := &token.AuditRecord{
461461
Inputs: token.NewInputStream(nil, []*token.Input{{Id: &token2.ID{TxId: "123"}}}, 0),
462462
Outputs: token.NewOutputStream([]*token.Output{
463-
{Issuer: driver.Identity("issuer"), Owner: []byte("alice"), EnrollmentID: "alice"},
464-
{Issuer: driver.Identity("issuer"), Owner: []byte("bob"), EnrollmentID: "bob"},
463+
{Index: 0, Issuer: driver.Identity("issuer"), Owner: []byte("alice"), EnrollmentID: "alice"},
464+
{Index: 1, Issuer: driver.Identity("issuer"), Owner: []byte("bob"), EnrollmentID: "bob"},
465465
}, 0),
466466
}
467467
err := rw.completeInputsWithEmptyEID(context.Background(), record)
@@ -470,6 +470,57 @@ func TestCompleteInputsWithEmptyEID_AmbiguousIssuedEIDStaysUnattributed(t *testi
470470
assert.Empty(t, record.Inputs.At(0).EnrollmentID)
471471
}
472472

473+
// An upgrade issued back to a composite owner emits one output row per member,
474+
// all under one output index. Members resolving to one enrollment ID attribute
475+
// the input to it; their distinct revocation handles drop the handle.
476+
func TestCompleteInputsWithEmptyEID_CompositeUpgradeSharedEIDAttributed(t *testing.T) {
477+
tmsWithToken, ws := newInternalTestManagementServiceWithTokens(t, []*token2.Token{
478+
{Type: "USD", Quantity: "100", Owner: []byte("pre-upgrade-composite")},
479+
})
480+
ws.GetAuditInfoReturns(nil, nil)
481+
rw := newRequestWrapper(
482+
token.NewRequest(tmsWithToken, token.RequestAnchor("tx-composite-upgrade")), tmsWithToken,
483+
)
484+
record := &token.AuditRecord{
485+
Inputs: token.NewInputStream(nil, []*token.Input{{Id: &token2.ID{TxId: "123"}}}, 0),
486+
Outputs: token.NewOutputStream([]*token.Output{
487+
{Index: 0, Issuer: driver.Identity("issuer"), Owner: []byte("member-0"), EnrollmentID: "alice", RevocationHandler: "rh-0"},
488+
{Index: 0, Issuer: driver.Identity("issuer"), Owner: []byte("member-1"), EnrollmentID: "alice", RevocationHandler: "rh-1"},
489+
}, 0),
490+
}
491+
err := rw.completeInputsWithEmptyEID(context.Background(), record)
492+
require.NoError(t, err)
493+
494+
in := record.Inputs.At(0)
495+
assert.Equal(t, "alice", in.EnrollmentID)
496+
assert.Empty(t, in.RevocationHandler)
497+
}
498+
499+
// Members spanning enrollment IDs leave no single enrollment ID to book the
500+
// input under, and leaving it unattributed would credit the members without
501+
// debiting anyone: the audit fails, naming the enrollment IDs.
502+
func TestCompleteInputsWithEmptyEID_CompositeUpgradeSpanningEIDsFailsAudit(t *testing.T) {
503+
tmsWithToken, ws := newInternalTestManagementServiceWithTokens(t, []*token2.Token{
504+
{Type: "USD", Quantity: "100", Owner: []byte("pre-upgrade-composite")},
505+
})
506+
ws.GetAuditInfoReturns(nil, nil)
507+
rw := newRequestWrapper(
508+
token.NewRequest(tmsWithToken, token.RequestAnchor("tx-composite-span")), tmsWithToken,
509+
)
510+
record := &token.AuditRecord{
511+
Inputs: token.NewInputStream(nil, []*token.Input{{Id: &token2.ID{TxId: "123"}}}, 0),
512+
Outputs: token.NewOutputStream([]*token.Output{
513+
{Index: 0, Issuer: driver.Identity("issuer"), Owner: []byte("member-0"), EnrollmentID: "alice"},
514+
{Index: 0, Issuer: driver.Identity("issuer"), Owner: []byte("member-1"), EnrollmentID: "bob"},
515+
}, 0),
516+
}
517+
err := rw.completeInputsWithEmptyEID(context.Background(), record)
518+
require.Error(t, err)
519+
assert.Contains(t, err.Error(), "composite owner")
520+
assert.Contains(t, err.Error(), "alice")
521+
assert.Contains(t, err.Error(), "bob")
522+
}
523+
473524
// An output of the same action that resolves to no enrollment ID cannot be shown
474525
// to belong to the party the others name, so the action does not count as issuing
475526
// to exactly one party.
@@ -581,21 +632,65 @@ func TestCompleteInputsWithEmptyEID_NilVaultTokenErrors(t *testing.T) {
581632
assert.Contains(t, err.Error(), "nil input at [0]th input")
582633
}
583634

584-
func TestCompleteInputsWithEmptyEID_OwnerResolutionErrorPropagates(t *testing.T) {
635+
// The identity layer signals an owner it cannot decode by returning an error —
636+
// an unknown identity type, a missing deserializer, undecodable audit info.
637+
// Such an owner is unresolvable, not a failure: the input stays unattributed
638+
// instead of failing the whole audit.
639+
func TestCompleteInputsWithEmptyEID_OwnerResolutionErrorLeavesUnattributed(t *testing.T) {
585640
tmsWithToken, ws := newInternalTestManagementServiceWithTokens(t, []*token2.Token{
586641
{Type: "USD", Quantity: "100", Owner: []byte("owner1")},
587642
})
588-
ws.GetAuditInfoReturns(nil, errors.New("no audit info stored"))
643+
ws.GetEIDAndRHReturns("", "", errors.New("no deserializer found for [legacy]"))
589644
rw := newRequestWrapper(
590645
token.NewRequest(tmsWithToken, token.RequestAnchor("tx-res-err")), tmsWithToken,
591646
)
592647
record := &token.AuditRecord{
593-
Inputs: token.NewInputStream(nil, []*token.Input{{Id: &token2.ID{TxId: "123"}}}, 0),
648+
Inputs: token.NewInputStream(nil, []*token.Input{{
649+
Id: &token2.ID{TxId: "123"},
650+
Owner: []byte("owner1"),
651+
OwnerAuditInfo: []byte("legacy-audit-info"),
652+
}}, 0),
594653
Outputs: token.NewOutputStream([]*token.Output{{EnrollmentID: "target"}}, 0),
595654
}
596655
err := rw.completeInputsWithEmptyEID(context.Background(), record)
597-
require.Error(t, err)
598-
assert.Contains(t, err.Error(), "failed resolving enrollment id")
656+
require.NoError(t, err)
657+
658+
in := record.Inputs.At(0)
659+
assert.Empty(t, in.EnrollmentID)
660+
assert.Empty(t, in.RevocationHandler)
661+
// the remaining fields are still filled in from the spent token
662+
assert.Equal(t, token2.Type("USD"), in.Type)
663+
assert.Equal(t, "100", in.Quantity.Decimal())
664+
}
665+
666+
// A pre-upgrade owner from an older driver carries audit info the identity
667+
// layer cannot deserialize: GetEIDAndRH errors. The upgrade fallback still
668+
// runs, so the input takes the issued enrollment ID instead of the resolution
669+
// error blocking the upgrade forever.
670+
func TestCompleteInputsWithEmptyEID_UpgradeOwnerResolutionErrorTakesIssuedEID(t *testing.T) {
671+
tmsWithToken, ws := newInternalTestManagementServiceWithTokens(t, []*token2.Token{
672+
{Type: "USD", Quantity: "100", Owner: []byte("pre-upgrade-owner")},
673+
})
674+
ws.GetAuditInfoReturns([]byte("legacy-audit-info"), nil)
675+
ws.GetEIDAndRHReturns("", "", errors.New("no deserializer found for [legacy]"))
676+
rw := newRequestWrapper(
677+
token.NewRequest(tmsWithToken, token.RequestAnchor("tx-upgrade-res-err")), tmsWithToken,
678+
)
679+
record := &token.AuditRecord{
680+
Inputs: token.NewInputStream(nil, []*token.Input{{Id: &token2.ID{TxId: "123"}}}, 0),
681+
Outputs: token.NewOutputStream([]*token.Output{{
682+
Issuer: driver.Identity("issuer"),
683+
Owner: []byte("post-upgrade-owner"),
684+
EnrollmentID: "alice",
685+
RevocationHandler: "alice-rh",
686+
}}, 0),
687+
}
688+
err := rw.completeInputsWithEmptyEID(context.Background(), record)
689+
require.NoError(t, err)
690+
691+
in := record.Inputs.At(0)
692+
assert.Equal(t, "alice", in.EnrollmentID)
693+
assert.Equal(t, "alice-rh", in.RevocationHandler)
599694
}
600695

601696
func TestRejectMultiOwnerActions(t *testing.T) {

0 commit comments

Comments
 (0)