Background
This is the most cross-cutting issue in the admin set, and deliberately so — the request is for every state change in the system, on-chain or off-chain, to leave a record of who did it, when, and to what. That serves three real needs: support (a merchant disputes an invoice status, and someone needs to reconstruct exactly what happened, in order); security (spotting an admin account behaving unexpectedly); and compliance (a defensible record of every merchant-affecting action).
Two things make this genuinely complex, not just "add a log table":
- Two independent write origins. On-chain state changes arrive via the indexer (an event fires, a handler processes it. Off-chain-only changes (a merchant editing their profile, an admin blocking a merchant, an admin logging in) happen entirely inside a normal Express request with no corresponding chain event at all. Both origins must produce the same shape of log row, through the same code path — not two divergent logging mechanisms that drift apart over time.
- Three actor types with different identity shapes. An action might be performed by an authenticated
Admin (has a name), an authenticated Merchant (has a business name, or just an address if profile isn't complete), or effectively ANONYMOUS (the public pay page, a wallet calling the contract directly without ever touching our backend — the indexer only ever sees an address). The log schema has to represent all three without forcing a fake Merchant/Admin row into existence just to attach a log entry to it.
Proposed Steps
- Add
AdminLog model and ActorType enum (see Schema Changes); prisma migrate dev.
src/services/audit-log.services.ts — a single recordAuditLog(input) function. Nothing else ever writes to AdminLog directly — every service and every indexer handler that needs to log calls this one function, so the schema and the logging contract can only drift in one place if they drift at all.
recordAuditLog({
action: string, // dot-namespaced, e.g. "invoice.voided", "fee.set"
actorType: ActorType,
actorId?: string, // Admin.id or Merchant.id, when known
actorLabel: string, // admin.name | merchant.businessName ?? merchant.address | raw address | "system"
targetType?: string, // e.g. "Invoice", "Merchant", "SubscriptionPlan"
targetId?: string,
metadata?: object, // arbitrary before/after or context
})
A failure inside recordAuditLog (DB hiccup, etc.) must never fail the calling operation — wrap the write in try/catch, log the failure to console.error, and return. An audit-trail bug becoming a user-facing 500 on, say, invoice payment, would be a worse outcome than a missing log row.
- Wire
recordAuditLog into every action listed in the table below. This is the bulk of the work — it touches nearly every service and handler in the codebase, one call each.
GET /admin/logs (mounted in src/routes/admin/logs.routes.ts, authenticateAdmin, no superadmin requirement — read-only) — filterable by action, actorType, actorId, targetType, targetId, from/to date range; paginated; sorted by createdAt desc by default (no other sort needed — logs are inherently chronological).
Action Catalog
Every row below is a required call site. "Source" is either a Soroban event topic or a backend service function (a direct recordAuditLog call at the point of mutation).
Governance & Config (all actorType: ADMIN, resolved by the on-chain admin address against the Admin table — if the address isn't a known Admin, log with actorType: ANONYMOUS and the raw address rather than failing to log at all)
| Action |
Source |
governance.admin_transfer_proposed / _accepted |
AdminTransferProposedEvent / AdminTransferAcceptedEvent |
governance.token_added / token_removed |
TokenAddedEvent / TokenRemovedEvent |
governance.fee_proposed / fee_set |
FeeProposedEvent / FeeSetEvent |
governance.platform_account_set |
PlatformAccountSetEvent |
governance.token_oracle_set |
TokenOracleSetEvent |
governance.account_wasm_hash_set |
AccountWasmHashSetEvent |
governance.contract_paused / _unpaused |
ContractPausedEvent / ContractUnpausedEvent |
governance.contract_upgraded |
ContractUpgradedEvent |
governance.role_granted / role_revoked |
RoleGrantedEvent / RoleRevokedEvent |
Merchant Lifecycle
| Action |
Actor |
Source |
merchant.registered (on-chain) |
SYSTEM |
MerchantRegisteredEvent |
merchant.account_deployed |
SYSTEM |
MerchantAccountDeployedEvent |
merchant.status_changed (activated/deactivated on-chain) |
ADMIN |
MerchantStatusChangedEvent |
merchant.verified |
ADMIN |
MerchantVerifiedEvent |
merchant.webhook_set |
MERCHANT |
MerchantWebhookSetEvent |
merchant.key_set (on-chain upload) |
MERCHANT |
MerchantKeySetEvent |
merchant.tokens_set / token_removed (whitelist) |
MERCHANT |
MerchantTokensSetEvent / MerchantTokenRemovedEvent |
merchant.account_restricted |
ADMIN |
AccountRestrictedEvent |
merchant.profile_registered (off-chain) |
MERCHANT |
registerMerchant |
merchant.profile_updated |
MERCHANT |
updateMyProfile |
merchant.email_verified |
MERCHANT |
verifyEmailOtp |
merchant.otp_resent |
MERCHANT |
resendEmailOtp |
merchant.signing_key_generated |
MERCHANT |
generateMerchantSigningKey |
merchant.blocked (off-chain) |
ADMIN |
|
Invoice Lifecycle
| Action |
Actor |
Source |
invoice.created (off-chain) |
MERCHANT |
createInvoice |
invoice.created (on-chain) |
SYSTEM |
InvoiceCreatedEvent |
invoice.paid |
ANONYMOUS (the payer address — almost never a known Merchant/Admin) |
InvoicePaidEvent |
invoice.payment_split_routed |
SYSTEM |
PaymentSplitRoutedEvent |
invoice.refunded / partially_refunded |
MERCHANT |
InvoiceRefundedEvent / InvoicePartiallyRefundedEvent |
invoice.cancelled (on-chain) |
MERCHANT |
InvoiceCancelledEvent |
invoice.amended (on-chain) |
MERCHANT |
InvoiceAmendedEvent |
invoice.fiat_priced |
SYSTEM |
FiatInvoicePricedEvent |
invoice.voided (off-chain) |
MERCHANT |
voidInvoice |
invoice.amended (off-chain) |
MERCHANT |
amendInvoice |
invoice.email_sent |
MERCHANT |
sendInvoiceEmail |
Subscription Lifecycle
| Action |
Actor |
Source |
subscription_plan.created |
MERCHANT |
SubscriptionPlanCreatedEvent |
subscription_plan.deactivated |
MERCHANT |
PlanDeactivatedEvent |
subscription.created |
ANONYMOUS (customer address) |
SubscribedEvent |
subscription.charged |
SYSTEM |
SubscriptionChargedEvent |
subscription.cancelled |
MERCHANT or ANONYMOUS (either party can cancel — use whichever caller the event reports) |
SubscriptionCancelledEvent |
Account Contract / Withdrawals
| Action |
Actor |
Source |
account.initialized / verified |
SYSTEM |
AccountInitializedEvent / AccountVerifiedEvent |
account.restricted |
ADMIN |
AccountRestrictedEvent (account contract) |
account.withdrawal |
MERCHANT |
WithdrawalToEvent |
account.refund_processed |
SYSTEM |
RefundProcessedEvent |
Admin & Auth (off-chain only, no corresponding contract event)
| Action |
Actor |
Source |
admin.created |
ADMIN (the creator) |
add-admin endpoint |
admin.login_succeeded / login_failed |
ADMIN / ANONYMOUS |
authenticateAdminWallet |
deposit_account.created / assigned / released |
SYSTEM |
DepositAccountService |
Events intentionally not logged: NonceInvalidatedEvent, InitializedEvent, FeeDiscountAppliedEvent (a computed side-effect of invoice.paid/subscription.charged, not an independent action), and BridgePlaceholderEvent (placeholder, no real state change yet). These are low-value noise for an audit trail; revisit if that changes.
Schema Changes
AdminLog (new model)
id String (uuid, PK)
action String (dot-namespaced, e.g. "merchant.blocked")
actorType ActorType
actorId String? (Admin.id or Merchant.id when known; null for ANONYMOUS/SYSTEM)
actorLabel String (display name — see recordAuditLog's contract above)
targetType String?
targetId String?
metadata Json?
createdAt DateTime (default now)
@@index([actorType, actorId])
@@index([targetType, targetId])
@@index([action])
@@index([createdAt])
ActorType (new enum)
ADMIN
MERCHANT
ANONYMOUS
SYSTEM
Acceptance Criteria
Background
This is the most cross-cutting issue in the admin set, and deliberately so — the request is for every state change in the system, on-chain or off-chain, to leave a record of who did it, when, and to what. That serves three real needs: support (a merchant disputes an invoice status, and someone needs to reconstruct exactly what happened, in order); security (spotting an admin account behaving unexpectedly); and compliance (a defensible record of every merchant-affecting action).
Two things make this genuinely complex, not just "add a log table":
Admin(has a name), an authenticatedMerchant(has a business name, or just an address if profile isn't complete), or effectivelyANONYMOUS(the public pay page, a wallet calling the contract directly without ever touching our backend — the indexer only ever sees an address). The log schema has to represent all three without forcing a fakeMerchant/Adminrow into existence just to attach a log entry to it.Proposed Steps
AdminLogmodel andActorTypeenum (see Schema Changes);prisma migrate dev.src/services/audit-log.services.ts— a singlerecordAuditLog(input)function. Nothing else ever writes toAdminLogdirectly — every service and every indexer handler that needs to log calls this one function, so the schema and the logging contract can only drift in one place if they drift at all.recordAuditLog(DB hiccup, etc.) must never fail the calling operation — wrap the write in try/catch, log the failure toconsole.error, and return. An audit-trail bug becoming a user-facing 500 on, say, invoice payment, would be a worse outcome than a missing log row.recordAuditLoginto every action listed in the table below. This is the bulk of the work — it touches nearly every service and handler in the codebase, one call each.GET /admin/logs(mounted insrc/routes/admin/logs.routes.ts,authenticateAdmin, no superadmin requirement — read-only) — filterable byaction,actorType,actorId,targetType,targetId,from/todate range; paginated; sorted bycreatedAt descby default (no other sort needed — logs are inherently chronological).Action Catalog
Every row below is a required call site. "Source" is either a Soroban event topic or a backend service function (a direct
recordAuditLogcall at the point of mutation).Governance & Config (all
actorType: ADMIN, resolved by the on-chain admin address against theAdmintable — if the address isn't a knownAdmin, log withactorType: ANONYMOUSand the raw address rather than failing to log at all)governance.admin_transfer_proposed/_acceptedAdminTransferProposedEvent/AdminTransferAcceptedEventgovernance.token_added/token_removedTokenAddedEvent/TokenRemovedEventgovernance.fee_proposed/fee_setFeeProposedEvent/FeeSetEventgovernance.platform_account_setPlatformAccountSetEventgovernance.token_oracle_setTokenOracleSetEventgovernance.account_wasm_hash_setAccountWasmHashSetEventgovernance.contract_paused/_unpausedContractPausedEvent/ContractUnpausedEventgovernance.contract_upgradedContractUpgradedEventgovernance.role_granted/role_revokedRoleGrantedEvent/RoleRevokedEventMerchant Lifecycle
merchant.registered(on-chain)MerchantRegisteredEventmerchant.account_deployedMerchantAccountDeployedEventmerchant.status_changed(activated/deactivated on-chain)MerchantStatusChangedEventmerchant.verifiedMerchantVerifiedEventmerchant.webhook_setMerchantWebhookSetEventmerchant.key_set(on-chain upload)MerchantKeySetEventmerchant.tokens_set/token_removed(whitelist)MerchantTokensSetEvent/MerchantTokenRemovedEventmerchant.account_restrictedAccountRestrictedEventmerchant.profile_registered(off-chain)registerMerchantmerchant.profile_updatedupdateMyProfilemerchant.email_verifiedverifyEmailOtpmerchant.otp_resentresendEmailOtpmerchant.signing_key_generatedgenerateMerchantSigningKeymerchant.blocked(off-chain)Invoice Lifecycle
invoice.created(off-chain)createInvoiceinvoice.created(on-chain)InvoiceCreatedEventinvoice.paidInvoicePaidEventinvoice.payment_split_routedPaymentSplitRoutedEventinvoice.refunded/partially_refundedInvoiceRefundedEvent/InvoicePartiallyRefundedEventinvoice.cancelled(on-chain)InvoiceCancelledEventinvoice.amended(on-chain)InvoiceAmendedEventinvoice.fiat_pricedFiatInvoicePricedEventinvoice.voided(off-chain)voidInvoiceinvoice.amended(off-chain)amendInvoiceinvoice.email_sentsendInvoiceEmailSubscription Lifecycle
subscription_plan.createdSubscriptionPlanCreatedEventsubscription_plan.deactivatedPlanDeactivatedEventsubscription.createdSubscribedEventsubscription.chargedSubscriptionChargedEventsubscription.cancelledcallerthe event reports)SubscriptionCancelledEventAccount Contract / Withdrawals
account.initialized/verifiedAccountInitializedEvent/AccountVerifiedEventaccount.restrictedAccountRestrictedEvent(account contract)account.withdrawalWithdrawalToEventaccount.refund_processedRefundProcessedEventAdmin & Auth (off-chain only, no corresponding contract event)
admin.createdadmin.login_succeeded/login_failedauthenticateAdminWalletdeposit_account.created/assigned/releasedDepositAccountServiceEvents intentionally not logged:
NonceInvalidatedEvent,InitializedEvent,FeeDiscountAppliedEvent(a computed side-effect ofinvoice.paid/subscription.charged, not an independent action), andBridgePlaceholderEvent(placeholder, no real state change yet). These are low-value noise for an audit trail; revisit if that changes.Schema Changes
AdminLog (new model)
ActorType (new enum)
Acceptance Criteria
AdminLogmodel andActorTypeenum added;prisma migrate devruns cleanlyrecordAuditLogis the only function anywhere in the codebase that writes toprisma.adminLogrecordAuditLognever propagates to or fails the calling operation (test by mocking a DB error inside the log write and asserting the primary operation still succeeds)GET /admin/logssupports all listed filters plus pagination, and returns entries ordered newest-firstinvoice.paid) stores the raw payer address asactorLabelwithactorId: null, not a failed lookup or a thrown error