Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/services/auditor.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ Auditors use specialized wallets (Auditor Wallets) managed by the **Identity Ser

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.

## Input Attribution

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.

Each such input is resolved from its own spent token:

1. The spent token is read from the vault, yielding its owner, type, and quantity.
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.

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.

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.

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.

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.

## Distributed EID Locking

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.
Expand Down
194 changes: 179 additions & 15 deletions token/services/auditor/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/LFDT-Panurus/panurus/token"
"github.com/LFDT-Panurus/panurus/token/core/common/metrics"
"github.com/LFDT-Panurus/panurus/token/services/identity"
"github.com/LFDT-Panurus/panurus/token/services/logging"
"github.com/LFDT-Panurus/panurus/token/services/network"
"github.com/LFDT-Panurus/panurus/token/services/network/driver"
Expand Down Expand Up @@ -159,7 +160,13 @@ func (a *Service) Audit(ctx context.Context, tx Transaction) (*token.InputStream
start := time.Now()
logger.DebugfContext(ctx, "audit transaction [%s]....", tx.ID())
request := tx.Request()
record, err := request.AuditRecord(ctx)
tms, err := a.bindProviderTMS(request)
if err != nil {
return nil, nil, err
}
// the record is completed before the enrollment IDs are collected, so that
// the locks cover the enrollment ID every input is finally booked under
record, err := newRequestWrapper(request, tms).AuditRecord(ctx)
if err != nil {
return nil, nil, errors.WithMessagef(err, "failed getting transaction audit record")
}
Expand Down Expand Up @@ -217,7 +224,7 @@ func (a *Service) Append(ctx context.Context, tx Transaction) error {
defer func() { a.metrics.AppendDuration.Observe(time.Since(start).Seconds()) }()
defer a.Release(ctx, tx)

tms, err := a.tmsProvider.TokenManagementService(token.WithTMSID(a.tmsID))
tms, err := a.bindProviderTMS(tx.Request())
if err != nil {
return err
}
Expand Down Expand Up @@ -254,6 +261,22 @@ func (a *Service) Append(ctx context.Context, tx Transaction) error {
return nil
}

// bindProviderTMS resolves the TMS for the service's TMS ID through the
// provider and rebinds the request to it. The record computation runs through
// request.TokenService, so this is what keeps the request from influencing
// which TMS computes and attributes the record.
func (a *Service) bindProviderTMS(request *token.Request) (dep.TokenManagementServiceWithExtensions, error) {
tms, err := a.tmsProvider.TokenManagementService(token.WithTMSID(a.tmsID))
if err != nil {
return nil, err
}
if err := tms.SetTokenManagementService(request); err != nil {
return nil, err
}

return tms, nil
}

// Release releases the lock acquired of the passed transaction and drops the
// audit record cached for it.
func (a *Service) Release(ctx context.Context, tx Transaction) {
Expand Down Expand Up @@ -395,56 +418,197 @@ func (r *requestWrapper) PublicParamsHash() token.PPHash { return r.r.PublicPara

// AuditRecord retrieves the audit record for the wrapped token request and completes any
// inputs with missing enrollment IDs by querying the token vault.
// The gap filling always runs, also on a cached record, because it depends on
// the current vault state.
// A record cached by Audit is returned as it stands: it was already attributed
// there, and the locks were taken for the enrollment IDs it carries.
func (r *requestWrapper) AuditRecord(ctx context.Context) (*token.AuditRecord, error) {
record := r.cached
if record == nil {
var err error
record, err = r.r.AuditRecord(ctx)
if err != nil {
return nil, err
}
// re-running the gap filling on a cached record could attribute an input
// Audit deliberately left empty, booking it under an enrollment ID that
// was never locked
if r.cached != nil {
return r.cached, nil
Comment thread
AkramBitar marked this conversation as resolved.
}

record, err := r.r.AuditRecord(ctx)
if err != nil {
return nil, err
}
if err := r.completeInputsWithEmptyEID(ctx, record); err != nil {
return nil, errors.WithMessagef(err, "failed filling gaps for request [%s]", r.r.Anchor)
}
if err := rejectMultiOwnerActions(record); err != nil {
return nil, err
}

return record, nil
}

// rejectMultiOwnerActions fails when one action spends tokens attributed to
// more than one enrollment ID. A transaction record keeps a single sender per
// action, so the store would reject such a record only at Append time, with
// an error that does not name the cause. It mirrors the store's grouping:
// unattributed inputs are skipped.
func rejectMultiOwnerActions(record *token.AuditRecord) error {
firstEID := map[int]string{}
for _, in := range record.Inputs.Inputs() {
if in.EnrollmentID == "" {
continue
}
eID, ok := firstEID[in.ActionIndex]
if !ok {
firstEID[in.ActionIndex] = in.EnrollmentID

continue
}
if eID != in.EnrollmentID {
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)
}
}

return nil
}

// completeInputsWithEmptyEID fills in missing enrollment ID information for inputs in the audit record
// by querying the token vault. This is necessary when inputs don't have enrollment IDs explicitly set.
// It uses the first output's enrollment ID as the target and retrieves token details from the vault.
// Each input is attributed to the enrollment ID resolved from its own token
// owner and the audit info the input carries — the locally stored audit info
// of the owner where present, the one carried by the request otherwise (see
// Request.AuditRecord). 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. An input the request describes no sender for is an
// upgrade input, and falls back to the enrollment ID the request issues to;
// an upgrade issued to a composite owner spanning enrollment IDs fails the
// audit (see issuedToEIDAndRH). An owner that maps to no single enrollment ID
// leaves its input unattributed rather than booked under a guessed enrollment
// ID, so a record keeping such an input is not fully attributed on return.
func (r *requestWrapper) completeInputsWithEmptyEID(ctx context.Context, record *token.AuditRecord) error {
filter := record.Inputs.ByEnrollmentID("")
if filter.Count() == 0 {
return nil
}
// TODO: extract from the audit tokens
targetEID := record.Outputs.EnrollmentIDs()[0]

// fetch all the tokens
tokens, err := r.tms.Vault().NewQueryEngine().ListAuditTokens(ctx, filter.IDs()...)
if err != nil {
return errors.WithMessagef(err, "failed listing tokens for [%s]", filter.IDs())
}
if filter.Count() != len(tokens) {
return errors.Errorf("expected %d audit tokens, got %d", filter.Count(), len(tokens))
}
precision := r.tms.PublicParametersManager().PublicParameters().Precision()
wm := r.tms.WalletManager()
for i := range filter.Count() {
item := filter.At(i)
item.EnrollmentID = targetEID
if tokens[i] == nil {
return errors.Errorf("failed to audit inputs: nil input at [%d]th input", i)
}
// an input the request describes no sender for: extractIssueInputs fills
// only the token id, so across the built-in drivers this is an upgrade
upgraded := len(item.Owner) == 0

item.Owner = tokens[i].Owner
item.Type = tokens[i].Type
q, err := token2.ToQuantity(tokens[i].Quantity, precision)
if err != nil {
return errors.WithMessagef(err, "failed converting token quantity [%s]", tokens[i].Quantity)
}
item.Quantity = q

eID, rID, err := wm.GetEIDAndRH(ctx, item.Owner, item.OwnerAuditInfo)
if err != nil {
// only a decoding failure counts as "this owner does not resolve";
// anything else — a storage failure, a canceled context — fails the
// audit rather than silently leaving the input unattributed
if ctx.Err() != nil || !errors.Is(err, identity.ErrUnresolvableIdentity) {
return errors.WithMessagef(err, "failed resolving enrollment id for input [%v]", item.Id)
}
logger.DebugfContext(ctx, "owner of input [%v] does not resolve, treating it as unresolved: %v", item.Id, err)
eID, rID = "", ""
}
if eID == "" && upgraded {
// an upgrade re-issues the spent tokens to their owner under a fresh
// identity, so the outputs of the very same action carry the
// enrollment ID the input belongs to. The pre-upgrade identity
// itself often resolves to nothing here: it predates the current
// driver and the request metadata carries no audit info for it.
eID, rID, err = issuedToEIDAndRH(record.Outputs, item.ActionIndex)
if err != nil {
return err
}
}
if eID == "" {
// the owner maps to no single enrollment ID — a composite owner,
// or one whose audit info is unavailable. Leave the input
// unattributed: amount aggregations skip an empty enrollment ID,
// whereas a guessed one would be charged to the wrong party.
continue
}
item.EnrollmentID = eID
item.RevocationHandler = rID
}

return nil
}

// issuedToEIDAndRH returns the enrollment ID and revocation handle the given issue
// action issues to, and empty values when it does not issue to exactly one party.
// An issued output carries both an issuer and an owner; a redeem output carries an
// issuer but no owner. Every issued output of the action must resolve to the same
// enrollment ID. The handle is kept only while it stays paired with that ID.
//
// A composite owner issues one output row per member, all under one output index.
// Members resolving to distinct enrollment IDs leave no single enrollment ID to
// book the input under, and an unattributed input would credit the members
// without debiting anyone: such an action fails the audit instead. Outputs that
// only partly resolve fail the audit for the same reason; when none resolve,
// nothing is credited and the input may stay unattributed.
func issuedToEIDAndRH(outputs *token.OutputStream, actionIndex int) (string, string, error) {
issued := outputs.Filter(func(o *token.Output) bool {
Comment thread
AkramBitar marked this conversation as resolved.
return o.ActionIndex == actionIndex && len(o.Issuer) != 0 && len(o.Owner) != 0
Comment thread
AkramBitar marked this conversation as resolved.
}).Outputs()
if len(issued) == 0 {
return "", "", nil
}

unresolved := 0
eIDByIndex := map[uint64]string{}
for _, output := range issued {
if output.EnrollmentID == "" {
unresolved++

continue
}
if eID, ok := eIDByIndex[output.Index]; ok && eID != output.EnrollmentID {
return "", "", errors.Errorf(
"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",
output.Index, actionIndex, eID, output.EnrollmentID,
)
}
eIDByIndex[output.Index] = output.EnrollmentID
}
if unresolved == len(issued) {
// nothing is credited to anybody, so nothing needs debiting
return "", "", nil
}
if unresolved > 0 {
return "", "", errors.Errorf(
"action [%d] issues [%d] of [%d] outputs to owners resolving to no enrollment ID: crediting the resolved ones would leave the input undebited",
actionIndex, unresolved, len(issued),
)
}

eID, rH := issued[0].EnrollmentID, issued[0].RevocationHandler
for _, output := range issued[1:] {
if output.EnrollmentID != eID {
return "", "", nil
}
if output.RevocationHandler != rH {
rH = ""
}
}

return eID, rH, nil
}

// String returns a string representation of the wrapped token request.
func (r *requestWrapper) String() string {
return r.r.String()
Expand Down
Loading
Loading