diff --git a/.env.example b/.env.example
index 79a4cf2a..ccfdc756 100644
--- a/.env.example
+++ b/.env.example
@@ -52,6 +52,31 @@ STELLAR_NETWORK=testnet
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
STELLAR_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
STELLAR_USE_MOCK=true
+# Stellar Anchor assets accepted as a bare ticker, as CODE=ISSUER pairs.
+# A ticker that is not listed can still be requested explicitly as CODE:ISSUER.
+STELLAR_ANCHOR_ASSETS=
+
+# Price oracle — used when a spending policy denominates its limits in a base
+# currency (e.g. USD). Without a source, conversions fail closed: the
+# transaction is blocked instead of the limit being skipped.
+PRICE_ORACLE_ENABLED=false
+PRICE_ORACLE_URL=
+PRICE_ORACLE_API_KEY=
+PRICE_ORACLE_TIMEOUT_MS=3000
+PRICE_ORACLE_MAX_AGE_MS=300000
+# Static quote table consulted before the HTTP source (ASSET=PRICE,...).
+PRICE_ORACLE_STATIC_PRICES=
+
+# Audit retention and archival
+# Records older than AUDIT_ARCHIVE_RETENTION_DAYS are moved out of the
+# actively queried audit_logs table into the compressed archive. The cleanup
+# job hard-deletes records older than AUDIT_RETENTION_DAYS, so the archive
+# cutoff must stay below it.
+AUDIT_ARCHIVE_ENABLED=true
+AUDIT_ARCHIVE_RETENTION_DAYS=30
+AUDIT_ARCHIVE_BATCH_SIZE=500
+AUDIT_ARCHIVE_MAX_BATCHES=20
+AUDIT_RETENTION_DAYS=90
# Storage (S3-compatible)
STORAGE_ENDPOINT=http://localhost:9000
diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md
index 18facfc5..0b10a0e0 100644
--- a/API_DOCUMENTATION.md
+++ b/API_DOCUMENTATION.md
@@ -188,7 +188,7 @@ Create a transaction (runs the full governance pipeline).
| walletId | string | Yes | Valid UUID | Sender wallet ID |
| agentId | string | No | Valid UUID | Initiating agent ID |
| budgetId | string | No | Valid UUID | Budget to charge against |
-| asset | string | No | 1-24 chars | Asset code (default: XLM) |
+| asset | string | No | `CODE` or `CODE:ISSUER`, max 80 chars | Asset code (default: XLM). A Stellar Anchor ticker (e.g. `USDC`) is resolved to its issuing account when it is configured in `STELLAR_ANCHOR_ASSETS`; an issued asset can always be requested explicitly as `CODE:ISSUER` |
| amount | string | Yes | Positive decimal, max 7 decimal places | Transaction amount |
| recipientAddress | string | Yes | Non-empty | Stellar destination address |
| memo | string | No | Max 28 chars | Transaction memo |
@@ -197,14 +197,40 @@ Create a transaction (runs the full governance pipeline).
**Response:** Transaction object with governance evaluation result
+A transaction moves a single asset. A payload that carries a `transfers` array
+is rejected with HTTP `422` and a message pointing at
+`POST /transactions/simulate`, which evaluates a multi-asset transfer against
+the spending policies without moving funds.
+
+**Policy violations:** a transaction blocked by a policy is rejected with HTTP
+`422` and a `POLICY_VIOLATION` problem-details body that lists every violated
+rule, the asset it applies to and the limit that was breached. A blocked
+transaction is never persisted and never reaches the blockchain execution
+queue.
+
### POST `/transactions/simulate`
Dry-run the governance pipeline without moving funds.
**Authentication:** Bearer token required
-**Request Body:** Same as POST `/transactions`
+**Request Body:** Either the single-asset body of `POST /transactions` (without
+`metadata` being required) or a multi-asset payload:
-**Response:** Simulation result with policy evaluation
+| Field | Type | Required | Constraints | Description |
+|-------|------|----------|-------------|-------------|
+| walletId | string | Yes | Valid UUID | Sender wallet ID |
+| agentId | string | No | Valid UUID | Initiating agent ID |
+| budgetId | string | No | Valid UUID | Budget to charge against |
+| asset | string | No | `CODE` or `CODE:ISSUER` | Asset of a single-asset payload |
+| amount | string | No | Positive decimal, max 7 decimal places | Amount of a single-asset payload |
+| transfers | array | No | 1-10 legs | Multi-asset payload: one `{ asset, amount }` leg per asset. Mutually exclusive with `asset`/`amount` |
+| recipientAddress | string | Yes | Non-empty | Stellar destination address |
+| memo | string | No | Max 28 chars | Transaction memo |
+| purpose | string | No | Max 280 chars | Transaction purpose |
+| metadata | object | No | Arbitrary JSON | Additional metadata |
+
+**Response:** Simulation result with policy evaluation, the evaluated
+`transfers` and the risk assessment of the riskiest leg
### GET `/transactions/:id`
Get a specific transaction.
@@ -270,6 +296,8 @@ Create a policy.
| requiresApproval | boolean | Whether approval is required |
| approvalThreshold | number | Amount threshold for approval |
| emergencyLock | boolean | Emergency lock flag (blocks all spending) |
+| assetLimits | object | Per-asset limit overrides keyed by the upper-case asset code (e.g. `{ "USDC": { "maxAmount": 1000, "dailyLimit": 5000 } }`). An issued asset (`USDC:GA5Z…`) matches the `USDC` entry |
+| baseCurrency | string | Currency the amount limits are denominated in (e.g. `USD`). Transfer amounts — and the spend already accumulated for the asset — are converted into that currency before the limits are applied. A missing or stale price rejects the transaction with `PRICE_UNAVAILABLE` (HTTP 503) instead of skipping the limit |
**Response:** Created policy object
@@ -283,8 +311,9 @@ Simulate a transaction intent against active policies.
|-------|------|----------|-------------|-------------|
| agentId | string | No | Valid UUID | Agent ID |
| walletId | string | No | Valid UUID | Wallet ID |
-| asset | string | Yes | Non-empty | Asset code |
-| amount | number | Yes | Positive | Transaction amount |
+| asset | string | No | `CODE` or `CODE:ISSUER` | Asset of a single-asset payload |
+| amount | number | No | Positive | Amount of a single-asset payload |
+| transfers | array | No | 1-10 legs | Multi-asset payload: one `{ asset, amount }` leg per asset. Mutually exclusive with `asset`/`amount` |
| recipientAddress | string | Yes | Non-empty | Destination address |
| spentToday | number | No | Non-negative | Amount spent today |
| spentThisWeek | number | No | Non-negative | Amount spent this week |
@@ -391,6 +420,64 @@ The response `meta` includes `limit`, `hasNext`, and `nextCursor` (null on the f
**Example:** `GET /audit?limit=20&actorId=user-123&action=wallet.created`
+### Audit archival
+
+Audit records older than `AUDIT_ARCHIVE_RETENTION_DAYS` (default 30) are moved
+out of the actively queried `audit_logs` table into a compressed archive
+(`audit_log_archives`) by a scheduled background job. The archive keeps the
+full row content, a SHA-256 checksum and the hash-chain anchor of the batch,
+so the audit history is preserved while the hot table — and every query
+against it — stays small.
+
+- The job runs hourly, processes at most `AUDIT_ARCHIVE_BATCH_SIZE` records
+ (default 500) per organization and batch and at most
+ `AUDIT_ARCHIVE_MAX_BATCHES` (default 20) batches per run.
+- A batch is written and read back (checksum and record count verified)
+ *before* the source rows are removed. A failure in between leaves the
+ records in place and the job is retried with the shared queue backoff.
+- The batch id is derived from the archived row ids, so a retried run reuses
+ the existing archive instead of writing a duplicate.
+- Because the oldest rows are archived first, the archived rows of an
+ organization are always a prefix of its hash chain. `GET
+ /audit/integrity/verify` anchors the live rows on the archived prefix and
+ keeps reporting a valid chain across the archive boundary.
+- The hard-delete retention of the cleanup job (`AUDIT_RETENTION_DAYS`,
+ default 90) stays above the archive cutoff, so records are always archived
+ before they are deleted. The configuration is validated at startup.
+
+### GET `/audit/archives`
+Lists the archive batches of the organization, newest first. Only the batch
+metadata is returned — the compressed payload is fetched one batch at a time.
+
+**Query Parameters:** `limit` defaults to 50 and is bounded to 1-100.
+
+**Example:** `GET /audit/archives?limit=10`
+
+### GET `/audit/archives/{batchId}`
+Returns the audit records of one archived batch. The compressed payload is
+decompressed and its checksum verified before it is returned, so a corrupted
+archive answers with an error instead of silently mangled records. A batch of
+another organization is reported as `404`.
+
+**Example:** `GET /audit/archives/8f1c3d…`
+
+### GET `/audit/archives`
+List the archive batches of the organization, newest first. Only the batch
+metadata is returned (batch id, cutoff, record count, checksum, chain anchor);
+the compressed payloads are not loaded.
+
+**Authentication:** Requires roles: OWNER, ADMIN, AUDITOR
+
+**Query Parameters:** `limit` defaults to 50 and is bounded to 1-100.
+
+### GET `/audit/archives/:batchId`
+Read the audit records of one archived batch. The payload is decompressed and
+its checksum verified before it is returned, so a corrupted archive surfaces as
+an error instead of as silently mangled audit records. A batch of another
+organization is reported as `404`.
+
+**Authentication:** Requires roles: OWNER, ADMIN, AUDITOR
+
## Outbound Webhook Signatures
Webhook creation and secret rotation responses disclose the signing secret once. Later list, get, update, delivery, and audit responses never include it. Store the secret securely and rotate it when compromised.
@@ -523,6 +610,37 @@ All endpoints return errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457
Unhandled server errors always return `500` with `code: "INTERNAL_ERROR"` and a generic `detail`; internal information is only written to the server logs under the `requestId`.
+A transaction that is blocked by a spending policy returns `422` with
+`code: "POLICY_VIOLATION"`. The `details.violations` array lists every
+violated rule with its `policyId`, `policyName`, `code`, `message` and the
+`asset` the rule applies to:
+
+```json
+{
+ "type": "urn:astroid:problem:policy-violation",
+ "title": "Policy Violation",
+ "status": 422,
+ "code": "POLICY_VIOLATION",
+ "detail": "Transaction blocked by spending limit policy: USDC cap: Projected daily spend 600 USDC exceeds limit 500 USDC",
+ "details": {
+ "violations": [
+ {
+ "policyId": "018f...",
+ "policyName": "USDC cap",
+ "code": "DAILY_LIMIT_EXCEEDED",
+ "message": "Projected daily spend 600 USDC exceeds limit 500 USDC",
+ "asset": "USDC"
+ }
+ ]
+ }
+}
+```
+
+When a policy denominates its limits in a base currency and no usable price is
+available, the transaction is rejected with `503` and
+`code: "PRICE_UNAVAILABLE"` instead of being let through with an unknown
+price.
+
### Authentication
Most endpoints require Bearer token authentication in the format:
```
diff --git a/README.md b/README.md
index 380ae9c3..e016dfb7 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,14 @@ Full OpenAPI spec available at `/docs` when the server is running.
## Data Model
-17 Prisma models: `Organization`, `User`, `Agent`, `Wallet`, `Policy`, `Budget`, `Transaction`, `Proposal`, `Approval`, `AuditLog`, `Notification`, `ApiKey`, `Webhook`, `Session`, `MemoryRecord`, `PasskeyCredential`, `DomainEvent`. Schema in [`prisma/`](prisma).
+18 Prisma models: `Organization`, `User`, `Agent`, `Wallet`, `Policy`, `Budget`, `Transaction`, `Proposal`, `Approval`, `AuditLog`, `AuditLogArchive`, `Notification`, `ApiKey`, `Webhook`, `Session`, `MemoryRecord`, `PasskeyCredential`, `DomainEvent`. Schema in [`prisma/`](prisma).
+
+## Spending Policies and Audit Archival
+
+- [`docs/spending-policies.md`](docs/spending-policies.md) — multi-asset policy
+ validation, per-asset limits and base-currency conversion.
+- [`docs/audit-archival.md`](docs/audit-archival.md) — the scheduled archival of
+ expired audit records into the compressed archive.
## Environment Variables
diff --git a/docs/audit-archival.md b/docs/audit-archival.md
new file mode 100644
index 00000000..4d4938e2
--- /dev/null
+++ b/docs/audit-archival.md
@@ -0,0 +1,91 @@
+# Audit Log Archival
+
+Autonomous agents generate audit records at a high rate. The archival job
+moves the records that are older than the configured retention period out of
+the actively queried `audit_logs` table into a compressed archive, so the hot
+table — and every query against it — stays small while the audit history is
+preserved.
+
+## Architecture
+
+| Piece | Location | Responsibility |
+|-------|----------|----------------|
+| Scheduler | `src/modules/audit/audit-archive.scheduler.ts` | Enqueues the archival job once per hour. |
+| Worker | `src/modules/audit/queues/audit-archive.worker.ts` | Consumes the job with the shared retry/backoff policy. |
+| Service | `src/modules/audit/audit-archive.service.ts` | Selects, archives and removes the expired records. |
+| Repository | `src/modules/audit/audit-archive.repository.ts` | Reads the expired records and writes the archive. |
+
+The scheduler only enqueues work; the archival itself runs in the BullMQ
+worker, where it inherits the platform-wide retry policy (three attempts with
+exponential backoff) and the structured worker logging.
+
+## Storage strategy: compressed partitions
+
+The repository has no object-storage client, so the archive is a **compressed
+partition inside PostgreSQL**: one `audit_log_archives` row per batch holding
+
+- the archived rows as a single gzip-compressed JSON document,
+- a SHA-256 checksum of the uncompressed payload,
+- the number of archived rows,
+- the hash-chain anchor of the batch.
+
+This keeps the archive queryable for compliance reviews, needs no new
+infrastructure and satisfies the requirement of the issue ("cold storage or
+compressed partitions"). The table is only ever appended to, never queried by
+the hot path.
+
+## Configuration
+
+| Variable | Default | Meaning |
+|----------|---------|---------|
+| `AUDIT_ARCHIVE_ENABLED` | `true` | Enables the scheduled job. |
+| `AUDIT_ARCHIVE_RETENTION_DAYS` | `30` | Age at which a record becomes eligible for archival. |
+| `AUDIT_ARCHIVE_BATCH_SIZE` | `500` | Maximum number of records per organization and batch. |
+| `AUDIT_ARCHIVE_MAX_BATCHES` | `20` | Maximum number of batches per run. |
+| `AUDIT_RETENTION_DAYS` | `90` | Hard-delete retention of the cleanup job. |
+
+The startup validation rejects an archive cutoff that is not below the
+delete retention: records must be archived before the cleanup job may delete
+them.
+
+## Safety properties
+
+- **Bounded work.** A run selects at most `AUDIT_ARCHIVE_BATCH_SIZE` records
+ per batch and processes at most `AUDIT_ARCHIVE_MAX_BATCHES` batches, so the
+ table is never locked for long and memory usage stays flat.
+- **Nothing is deleted before it is archived.** The order is *write → verify →
+ delete*: the compressed batch is stored, read back and checked (record count
+ and checksum) before the source rows are removed. A failure in between
+ leaves the records in place.
+- **Idempotent.** The batch id is the SHA-256 of the archived row ids, so the
+ same set of records always maps to the same archive. A retried run reuses
+ the existing archive (`skipDuplicates`) instead of writing a second copy,
+ and deleting already-removed rows is a no-op.
+- **Partial failures are safe.** Batches are archived per organization. When
+ one organization fails, the organizations that were already archived stay
+ archived and the run is retried; the failed organization keeps its records.
+- **Chain preserving.** The oldest records are archived first, so the archived
+ rows of an organization are always a *prefix* of its hash chain. The hash of
+ the last archived entry is stored with the batch and anchors the live rows,
+ which keeps `GET /audit/integrity/verify` meaningful across the archive
+ boundary.
+
+## Reading the archive
+
+Archived records stay accessible:
+
+- `GET /audit/archives` lists the batches of the organization (metadata only).
+- `GET /audit/archives/:batchId` returns the records of one batch. The
+ compressed payload is decompressed and its checksum verified before it is
+ returned, so a corrupted archive surfaces as an error instead of as silently
+ mangled audit records. A batch of another organization is reported as `404`.
+
+## Observability
+
+- Structured logs: one line per run (cutoff, batch size, archived records,
+ duration) and one line per archived batch.
+- Metrics: `audit_archived_records_total{status="archived"|"deleted"}` counts
+ the processed records, and the worker instrumentation adds
+ `worker_jobs_total` and `worker_job_duration_seconds` for the queue.
+- A failed run is rethrown so BullMQ retries it; a terminal failure is routed
+ to the dead-letter queue by the shared queue failure listener.
diff --git a/docs/configuration.md b/docs/configuration.md
index 4f73b020..30254ceb 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -122,6 +122,38 @@ are rejected:
| `STELLAR_SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint. |
| `STELLAR_REGISTRY_CONTRACT_ID` | _(empty)_ | Agent registry contract ID. |
| `STELLAR_USE_MOCK` | `true` | `true` or `false`. Use the mock Stellar client. |
+| `STELLAR_ANCHOR_ASSETS` | _(empty)_ | Comma-separated `CODE=ISSUER` pairs of the Stellar Anchor assets the deployment accepts as a bare ticker (e.g. `USDC=GA5Z…`). A bare ticker that is not listed is still accepted by the API but cannot be resolved to a Stellar asset, so the network client rejects it. An issued asset can always be requested explicitly as `CODE:ISSUER`. |
+
+### Price oracle
+
+Used by the spending-policy engine when a policy denominates its limits in a
+base currency (e.g. `USD`). Without a configured source every conversion fails
+closed, so a limit can never be bypassed by treating an unknown price as zero.
+
+| Variable | Default | Description |
+| -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `PRICE_ORACLE_ENABLED` | `false` | Enables the HTTP price source. Static prices below are always honoured. |
+| `PRICE_ORACLE_URL` | _(empty)_ | Provider-neutral JSON endpoint. The oracle appends `?base=&assets=` and expects `{ "quotes": { "XLM": { "price": 0.11, "asOf": "2026-01-01T00:00:00Z" } } }`. |
+| `PRICE_ORACLE_API_KEY` | _(empty)_ | Optional bearer token sent to the price endpoint. |
+| `PRICE_ORACLE_TIMEOUT_MS` | `3000` | HTTP timeout for a single quote request. |
+| `PRICE_ORACLE_MAX_AGE_MS` | `300000` | Quotes older than this are rejected as stale and the transaction is blocked. |
+| `PRICE_ORACLE_STATIC_PRICES` | _(empty)_ | Operator-managed static quote table: `ASSET=PRICE,ASSET2=PRICE2`. Consulted before the HTTP source; useful for offline deployments. |
+
+### Audit retention and archival
+
+The archival job runs hourly. It moves the audit records that are older than
+`AUDIT_ARCHIVE_RETENTION_DAYS` out of the actively queried `audit_logs` table
+into a compressed archive (`audit_log_archives`), so the hot table stays small
+while the audit history is preserved. The archived batches stay readable
+through `GET /audit/archives` and `GET /audit/archives/{batchId}`.
+
+| Variable | Default | Description |
+| --------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `AUDIT_ARCHIVE_ENABLED` | `true` | Enables the scheduled audit archival job. |
+| `AUDIT_ARCHIVE_RETENTION_DAYS` | `30` | Age (in days) at which audit records become eligible for archival. Must be lower than `AUDIT_RETENTION_DAYS`. |
+| `AUDIT_ARCHIVE_BATCH_SIZE` | `500` | Maximum number of records archived per organization and batch. |
+| `AUDIT_ARCHIVE_MAX_BATCHES` | `20` | Maximum number of batches processed in a single archival run. |
+| `AUDIT_RETENTION_DAYS` | `90` | Hard-delete retention applied by the audit cleanup job. Records are archived before this window closes, never after. |
### Storage (S3-compatible)
diff --git a/docs/spending-policies.md b/docs/spending-policies.md
new file mode 100644
index 00000000..e43b4c53
--- /dev/null
+++ b/docs/spending-policies.md
@@ -0,0 +1,118 @@
+# Spending Policies
+
+The spending-policy layer decides whether an agent may move money. It is the
+last gate before a transaction is queued for execution, so every rule is
+evaluated **before** the transaction is persisted and before it can reach the
+blockchain execution queue.
+
+## Layers
+
+| Layer | Location | Responsibility |
+|-------|----------|----------------|
+| Policy engine | `src/modules/policies/policy.engine.ts` | Pure, synchronous evaluation of an intent against the applicable policies. No I/O. |
+| Policy service | `src/modules/policies/policy.service.ts` | Loads the applicable policies, converts amounts into the currency a policy is denominated in and merges the results. |
+| Spending-limit service | `src/modules/transactions/spending-limit.service.ts` | Enriches an intent with the spend already accumulated per asset and records the violation in the audit trail. |
+| Asset registry | `src/modules/policies/asset-registry.service.ts` | Resolves an asset to its canonical Stellar identifier. |
+| Price oracle | `src/modules/policies/price-oracle.service.ts` | Converts an amount into a base currency. Fails closed. |
+
+## Assets
+
+A transfer refers to an asset by its ticker (`XLM`, `USDC`) or by the canonical
+network form `CODE:ISSUER`. The asset registry resolves both to the same
+canonical identifier:
+
+- `XLM` is the native asset.
+- `CODE:ISSUER` is used as-is.
+- A bare ticker that is listed in `STELLAR_ANCHOR_ASSETS` is expanded to
+ `CODE:ISSUER`, so a request for `USDC` and a request for
+ `USDC:GA5Z…` describe the same asset and their spend is aggregated together.
+
+Only the asset *code* is used to match policy configuration, so an issued
+asset (`USDC:GA5Z…`) matches the `USDC` entry of an allow list or of a
+per-asset limit.
+
+## Single-asset and multi-asset transfers
+
+An intent carries one or more transfer *legs*:
+
+- A single-asset payload (`asset` + `amount`) is the degenerate case with
+ exactly one leg, so the behaviour of existing policies is unchanged.
+- A multi-asset payload (`transfers[]`) has one leg per asset. Every leg is
+ evaluated independently, and an asset may appear at most once per payload.
+
+The multi-asset payload is accepted by the simulation endpoints
+(`POST /transactions/simulate` and `POST /policies/simulate`), which evaluate
+a transfer without moving funds. `POST /transactions` moves a single asset per
+transaction — the `Transaction` record models one asset — and rejects a
+`transfers` array with an explanatory error.
+
+## Limits
+
+A policy configuration may declare:
+
+| Field | Meaning |
+|-------|---------|
+| `maxAmount` / `minAmount` | Per-transfer limits, applied to every leg. |
+| `dailyLimit` / `weeklyLimit` / `monthlyLimit` | Periodic limits, compared against the spend accumulated **for the asset of the leg**. |
+| `assetLimits` | Per-asset overrides, keyed by the upper-case asset code. An entry replaces the policy-wide limits for that asset. |
+| `baseCurrency` | Currency the amount limits are denominated in (e.g. `USD`). |
+
+```json
+{
+ "baseCurrency": "USD",
+ "maxAmount": 500,
+ "assetLimits": {
+ "XLM": { "maxAmount": 50, "dailyLimit": 200 },
+ "USDC": { "maxAmount": 1000, "dailyLimit": 5000 }
+ }
+}
+```
+
+## Base-currency conversion
+
+When a policy declares a `baseCurrency`, the transfer amount — and the spend
+already accumulated for the asset — are converted into that currency before
+the limits are applied. The conversion uses the price oracle, which supports
+two sources:
+
+1. `PRICE_ORACLE_STATIC_PRICES` — an operator-managed quote table, consulted
+ first and considered fresh at all times.
+2. `PRICE_ORACLE_URL` — a provider-neutral JSON endpoint that receives
+ `?base=&assets=` and answers with
+ `{ "quotes": { "XLM": { "price": 0.11, "asOf": "2026-01-01T00:00:00Z" } } }`.
+
+A quote that is missing, malformed, unreachable or older than
+`PRICE_ORACLE_MAX_AGE_MS` aborts the evaluation with a `PRICE_UNAVAILABLE`
+error (HTTP 503). An unknown price is never treated as zero: a limit that
+cannot be evaluated blocks the transaction.
+
+## Violations
+
+A blocked transaction is rejected with HTTP `422` and a `POLICY_VIOLATION`
+problem-details body. Every violated rule is reported with its policy, its
+code, a human-readable message and the asset it applies to:
+
+```json
+{
+ "violations": [
+ {
+ "policyId": "018f...",
+ "policyName": "USDC cap",
+ "code": "DAILY_LIMIT_EXCEEDED",
+ "message": "Projected daily spend 600 USDC exceeds limit 500 USDC",
+ "asset": "USDC"
+ }
+ ]
+}
+```
+
+The violation is also written to the audit trail as a `SPENDING_LIMIT_EXCEEDED`
+entry before the request is rejected, so the compliance record is complete even
+for a blocked payment.
+
+## Budgets
+
+A budget is denominated in a single currency. When a transaction names a
+budget, the budget must be active and denominated in the asset of the
+transfer; comparing an XLM amount against a USDC budget would silently mix
+units, so the check fails closed with a `BUDGET_EXCEEDED` error instead.
diff --git a/prisma/migrations/20261020160000_add_audit_log_archives/migration.sql b/prisma/migrations/20261020160000_add_audit_log_archives/migration.sql
new file mode 100644
index 00000000..6b459600
--- /dev/null
+++ b/prisma/migrations/20261020160000_add_audit_log_archives/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "audit_log_archives" (
+ "id" TEXT NOT NULL,
+ "batchId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "cutoff" TIMESTAMP(3) NOT NULL,
+ "recordCount" INTEGER NOT NULL,
+ "payload" BYTEA NOT NULL,
+ "checksum" TEXT NOT NULL,
+ "chainBoundaryHash" TEXT,
+ "archivedThrough" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "audit_log_archives_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "audit_log_archives_batchId_key" ON "audit_log_archives"("batchId");
+
+-- CreateIndex
+CREATE INDEX "audit_log_archives_organizationId_archivedThrough_idx" ON "audit_log_archives"("organizationId", "archivedThrough");
+
+-- CreateIndex
+CREATE INDEX "audit_log_archives_createdAt_idx" ON "audit_log_archives"("createdAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 940ce300..a195767c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -797,6 +797,39 @@ model CleanupJobLog {
@@map("cleanup_job_logs")
}
+// ---------------------------------------------------------------------------
+// Audit Log Archive (compressed cold storage for expired audit rows)
+// ---------------------------------------------------------------------------
+
+// Audit rows that left the actively queried `audit_logs` table. The rows are
+// stored as one gzip-compressed JSON document per batch, together with a
+// checksum and the hash-chain anchor of the batch, so the audit history is
+// preserved while the hot table stays small.
+model AuditLogArchive {
+ id String @id @default(uuid(7))
+ // Deterministic id of the batch (derived from the archived row ids) so a
+ // retried run reuses the same archive instead of creating a duplicate.
+ batchId String @unique
+ organizationId String
+ // Retention cutoff that selected the batch.
+ cutoff DateTime
+ // Number of audit rows stored in this archive.
+ recordCount Int
+ // gzip-compressed JSON array with the archived rows.
+ payload Bytes
+ // SHA-256 of the uncompressed payload.
+ checksum String
+ // Hash of the last archived entry: the chain anchor of the live rows.
+ chainBoundaryHash String?
+ // createdAt of the newest archived row.
+ archivedThrough DateTime
+ createdAt DateTime @default(now())
+
+ @@index([organizationId, archivedThrough])
+ @@index([createdAt])
+ @@map("audit_log_archives")
+}
+
// ---------------------------------------------------------------------------
// Risk Assessment (historical risk scoring for compliance and analytics)
// ---------------------------------------------------------------------------
diff --git a/src/common/constants/error-codes.ts b/src/common/constants/error-codes.ts
index aeb64b10..d2c2df2f 100644
--- a/src/common/constants/error-codes.ts
+++ b/src/common/constants/error-codes.ts
@@ -32,6 +32,8 @@ export enum ErrorCode {
AGENT_NOT_ACTIVE = 'AGENT_NOT_ACTIVE',
EMERGENCY_LOCK = 'EMERGENCY_LOCK',
VELOCITY_LIMIT_EXCEEDED = 'VELOCITY_LIMIT_EXCEEDED',
+ /** A price quote required to evaluate a limit is missing, stale or invalid. */
+ PRICE_UNAVAILABLE = 'PRICE_UNAVAILABLE',
// Stellar
STELLAR_ERROR = 'STELLAR_ERROR',
@@ -69,6 +71,7 @@ export const ERROR_STATUS: Record = {
[ErrorCode.AGENT_NOT_ACTIVE]: 409,
[ErrorCode.EMERGENCY_LOCK]: 423,
[ErrorCode.VELOCITY_LIMIT_EXCEEDED]: 422,
+ [ErrorCode.PRICE_UNAVAILABLE]: 503,
[ErrorCode.STELLAR_ERROR]: 502,
[ErrorCode.INVALID_STELLAR_ADDRESS]: 400,
[ErrorCode.INVALID_STELLAR_TRANSACTION]: 400,
@@ -107,6 +110,7 @@ export const ERROR_TITLE: Record = {
[ErrorCode.AGENT_NOT_ACTIVE]: 'Agent Not Active',
[ErrorCode.EMERGENCY_LOCK]: 'Emergency Lock Active',
[ErrorCode.VELOCITY_LIMIT_EXCEEDED]: 'Velocity Limit Exceeded',
+ [ErrorCode.PRICE_UNAVAILABLE]: 'Price Quote Unavailable',
[ErrorCode.STELLAR_ERROR]: 'Stellar Network Error',
[ErrorCode.INVALID_STELLAR_ADDRESS]: 'Invalid Stellar Address',
[ErrorCode.INVALID_STELLAR_TRANSACTION]: 'Invalid Stellar Transaction',
diff --git a/src/common/filters/problem-details.integration.spec.ts b/src/common/filters/problem-details.integration.spec.ts
index 98770db2..aae9efe7 100644
--- a/src/common/filters/problem-details.integration.spec.ts
+++ b/src/common/filters/problem-details.integration.spec.ts
@@ -17,6 +17,7 @@ import { z } from 'zod';
import { AllExceptionsFilter } from './all-exceptions.filter';
import { ZodValidationPipe } from '../pipes/zod-validation.pipe';
import { PolicyViolationException } from '../exceptions/domain.exception';
+import { PriceUnavailableException } from '../../modules/policies/price-oracle.service';
/**
* Verifies over real HTTP that every error path of the API (validation,
@@ -51,6 +52,16 @@ class ItemsController {
throw new PolicyViolationException('Transfer exceeds the daily limit', { limit: '100' });
}
+ @Post('price')
+ price() {
+ throw new PriceUnavailableException(
+ 'unavailable',
+ 'XLM',
+ 'USD',
+ 'No price source is configured for asset XLM',
+ );
+ }
+
@Get('boom')
boom() {
throw new Error('ECONNREFUSED 10.0.0.5:5432');
@@ -137,6 +148,16 @@ describe('Problem details error responses (integration)', () => {
});
});
+ it('returns an unavailable price as a 503 problem so the caller can retry', async () => {
+ const { res, body } = await call('/items/price', { method: 'POST' });
+
+ expectProblem(res, body, 503);
+ expect(body).toMatchObject({
+ code: 'PRICE_UNAVAILABLE',
+ detail: 'No price source is configured for asset XLM',
+ });
+ });
+
it('maps unhandled exceptions to a 500 problem without leaking internals', async () => {
const { res, body } = await call('/items/boom');
diff --git a/src/common/validators/stellar-asset.schema.ts b/src/common/validators/stellar-asset.schema.ts
new file mode 100644
index 00000000..4664bc6e
--- /dev/null
+++ b/src/common/validators/stellar-asset.schema.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+import { stellarEd25519PublicKeySchema } from './stellar-address.schema';
+import { ASSET_CODE_MAX_LENGTH, isValidAssetCode } from '../../utils/asset.util';
+
+/**
+ * Zod contract for a Stellar asset identifier.
+ *
+ * Accepts either a bare ticker (`XLM`, `USDC`) or the canonical network form
+ * `CODE:ISSUER` used for credit assets. The issuer, when present, must be a
+ * valid Stellar account address (format *and* checksum).
+ */
+export const stellarAssetSchema = z
+ .string({ invalid_type_error: 'Asset must be a string' })
+ .trim()
+ .min(1, 'Asset is required')
+ .max(80, 'Asset must be at most 80 characters')
+ .superRefine((value, ctx) => {
+ const separator = value.indexOf(':');
+ if (separator === -1) {
+ if (!isValidAssetCode(value)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Asset code must be 1-${ASSET_CODE_MAX_LENGTH} alphanumeric characters`,
+ });
+ }
+ return;
+ }
+
+ const code = value.slice(0, separator);
+ const issuer = value.slice(separator + 1);
+ if (!isValidAssetCode(code)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Asset code must be 1-${ASSET_CODE_MAX_LENGTH} alphanumeric characters`,
+ });
+ }
+ if (!stellarEd25519PublicKeySchema.safeParse(issuer).success) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Asset issuer must be a valid Stellar account address (G…)',
+ });
+ }
+ });
+
+export type StellarAsset = z.infer;
diff --git a/src/config/asset-and-price-config.spec.ts b/src/config/asset-and-price-config.spec.ts
new file mode 100644
index 00000000..473e201e
--- /dev/null
+++ b/src/config/asset-and-price-config.spec.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+import { parseAnchorAssets } from './stellar.config';
+import { parseStaticPrices } from './price-oracle.config';
+import { assertValidEnvironment } from './env.validation';
+
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+/** The smallest environment that satisfies every required key. */
+const REQUIRED_ENV = {
+ DATABASE_URL: 'postgresql://astroid:astroid@localhost:5432/astroid',
+ JWT_ACCESS_SECRET: 'access-secret-at-least-16-chars',
+ JWT_REFRESH_SECRET: 'refresh-secret-at-least-16-chars',
+ AI_PROVIDER_KEY: 'nvapi-test-key',
+} as const;
+
+describe('parseAnchorAssets', () => {
+ it('parses an empty list into an empty map', () => {
+ expect(parseAnchorAssets('')).toEqual({});
+ });
+
+ it('parses a CODE=ISSUER pair and upper-cases the code', () => {
+ expect(parseAnchorAssets(`usdc=${ISSUER}`)).toEqual({ USDC: ISSUER });
+ });
+
+ it('parses several pairs and ignores blank entries', () => {
+ expect(parseAnchorAssets(`USDC=${ISSUER}, , EUR=${ISSUER}`)).toEqual({
+ USDC: ISSUER,
+ EUR: ISSUER,
+ });
+ });
+
+ it('rejects an entry without an issuer', () => {
+ expect(() => parseAnchorAssets('USDC')).toThrow(/expected CODE=ISSUER/);
+ });
+
+ it('rejects a malformed issuer address', () => {
+ expect(() => parseAnchorAssets('USDC=not-an-address')).toThrow(/Stellar account address/);
+ });
+
+ it('rejects a malformed asset code', () => {
+ expect(() => parseAnchorAssets(`TOO_LONG_ASSET_CODE=${ISSUER}`)).toThrow(
+ /asset code must be 1-12/,
+ );
+ });
+});
+
+describe('parseStaticPrices', () => {
+ it('parses an empty list into an empty map', () => {
+ expect(parseStaticPrices('')).toEqual({});
+ });
+
+ it('parses an ASSET=PRICE pair and upper-cases the asset', () => {
+ expect(parseStaticPrices('xlm=0.11')).toEqual({ XLM: 0.11 });
+ });
+
+ it('parses several pairs and ignores blank entries', () => {
+ expect(parseStaticPrices('XLM=0.11, , USDC=1')).toEqual({ XLM: 0.11, USDC: 1 });
+ });
+
+ it('rejects an entry without a price', () => {
+ expect(() => parseStaticPrices('XLM')).toThrow(/expected ASSET=PRICE/);
+ });
+
+ it('rejects a non-numeric price', () => {
+ expect(() => parseStaticPrices('XLM=abc')).toThrow(/price must be a positive number/);
+ });
+
+ it('rejects a zero price so a missing quote can never be treated as free', () => {
+ expect(() => parseStaticPrices('XLM=0')).toThrow(/price must be a positive number/);
+ });
+});
+
+describe('audit and price oracle environment variables', () => {
+ it('applies the documented audit archival defaults', () => {
+ const env = assertValidEnvironment({ ...REQUIRED_ENV });
+
+ expect(env.AUDIT_ARCHIVE_ENABLED).toBe(true);
+ expect(env.AUDIT_ARCHIVE_RETENTION_DAYS).toBe(30);
+ expect(env.AUDIT_ARCHIVE_BATCH_SIZE).toBe(500);
+ expect(env.AUDIT_ARCHIVE_MAX_BATCHES).toBe(20);
+ expect(env.AUDIT_RETENTION_DAYS).toBe(90);
+ });
+
+ it('applies the documented price oracle defaults', () => {
+ const env = assertValidEnvironment({ ...REQUIRED_ENV });
+
+ expect(env.PRICE_ORACLE_ENABLED).toBe(false);
+ expect(env.PRICE_ORACLE_URL).toBe('');
+ expect(env.PRICE_ORACLE_TIMEOUT_MS).toBe(3000);
+ expect(env.PRICE_ORACLE_MAX_AGE_MS).toBe(300000);
+ expect(env.PRICE_ORACLE_STATIC_PRICES).toBe('');
+ });
+
+ it('rejects an archive retention that is not below the hard-delete retention', () => {
+ expect(() =>
+ assertValidEnvironment({
+ ...REQUIRED_ENV,
+ AUDIT_ARCHIVE_RETENTION_DAYS: '90',
+ AUDIT_RETENTION_DAYS: '90',
+ }),
+ ).toThrow(/must be lower than AUDIT_RETENTION_DAYS/);
+ });
+
+ it('rejects a non-positive batch size', () => {
+ expect(() =>
+ assertValidEnvironment({ ...REQUIRED_ENV, AUDIT_ARCHIVE_BATCH_SIZE: '0' }),
+ ).toThrow();
+ });
+
+ it('rejects a non-positive price oracle timeout', () => {
+ expect(() =>
+ assertValidEnvironment({ ...REQUIRED_ENV, PRICE_ORACLE_TIMEOUT_MS: '0' }),
+ ).toThrow();
+ });
+
+ it('accepts the anchor asset list', () => {
+ const env = assertValidEnvironment({
+ ...REQUIRED_ENV,
+ STELLAR_ANCHOR_ASSETS: `USDC=${ISSUER}`,
+ });
+
+ expect(env.STELLAR_ANCHOR_ASSETS).toBe(`USDC=${ISSUER}`);
+ });
+});
diff --git a/src/config/audit.config.ts b/src/config/audit.config.ts
new file mode 100644
index 00000000..ca3ad14d
--- /dev/null
+++ b/src/config/audit.config.ts
@@ -0,0 +1,26 @@
+import { registerAs } from '@nestjs/config';
+import { auditEnvSchema, validateEnv } from './env.validation';
+
+export type AuditConfig = {
+ /** Whether the scheduled archival job runs. */
+ archiveEnabled: boolean;
+ /** Age (in days) at which audit records become eligible for archival. */
+ archiveRetentionDays: number;
+ /** Maximum number of records archived per organization and batch. */
+ archiveBatchSize: number;
+ /** Maximum number of batches processed in a single archival run. */
+ archiveMaxBatches: number;
+ /** Hard-delete retention applied by the audit cleanup job (days). */
+ retentionDays: number;
+};
+
+export const auditConfig = registerAs('audit', (): AuditConfig => {
+ const env = validateEnv(auditEnvSchema, process.env);
+ return {
+ archiveEnabled: env.AUDIT_ARCHIVE_ENABLED,
+ archiveRetentionDays: env.AUDIT_ARCHIVE_RETENTION_DAYS,
+ archiveBatchSize: env.AUDIT_ARCHIVE_BATCH_SIZE,
+ archiveMaxBatches: env.AUDIT_ARCHIVE_MAX_BATCHES,
+ retentionDays: env.AUDIT_RETENTION_DAYS,
+ };
+});
diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts
index 837c3d4d..1d503d25 100644
--- a/src/config/env.validation.ts
+++ b/src/config/env.validation.ts
@@ -75,6 +75,11 @@ export const stellarEnvSchema = z.object({
.enum(['true', 'false'])
.default('true')
.transform((value) => value === 'true'),
+ // Comma-separated `CODE=ISSUER` pairs describing the Stellar Anchor assets
+ // the platform accepts as a bare ticker (e.g. `USDC=GA5Z…`). A bare ticker
+ // that is not listed here is still accepted by the API, but it cannot be
+ // resolved to a Stellar asset and is rejected by the network client.
+ STELLAR_ANCHOR_ASSETS: z.string().default(''),
});
export const storageEnvSchema = z.object({
@@ -85,6 +90,57 @@ export const storageEnvSchema = z.object({
STORAGE_SECRET_KEY: z.string().default('astroid-secret'),
});
+/**
+ * Price oracle configuration used by the spending-policy engine when a policy
+ * denominates its limits in a base currency (e.g. USD) instead of the asset's
+ * own unit. Disabled by default: without a configured source every conversion
+ * fails closed, so a policy can never silently treat an unknown price as zero.
+ */
+export const priceOracleEnvSchema = z.object({
+ // Enables the HTTP price source. Static prices below are always honoured.
+ PRICE_ORACLE_ENABLED: z
+ .enum(['true', 'false'])
+ .default('false')
+ .transform((value) => value === 'true'),
+ // Provider-neutral JSON endpoint. The oracle appends `?base=&assets=`
+ // and expects `{ "quotes": { "XLM": { "price": 0.11, "asOf": "2026-01-01T00:00:00Z" } } }`.
+ PRICE_ORACLE_URL: z.string().default(''),
+ // Optional bearer token sent to the price endpoint.
+ PRICE_ORACLE_API_KEY: z.string().default(''),
+ // HTTP timeout for a single quote request (ms).
+ PRICE_ORACLE_TIMEOUT_MS: z.coerce.number().int().positive().max(30_000).default(3_000),
+ // Quotes older than this are rejected as stale and the transaction is blocked.
+ PRICE_ORACLE_MAX_AGE_MS: z.coerce.number().int().positive().max(86_400_000).default(300_000),
+ // Operator-managed static quote table: `ASSET=PRICE,ASSET2=PRICE2`. Consulted
+ // before the HTTP source; useful for offline deployments and deterministic tests.
+ PRICE_ORACLE_STATIC_PRICES: z.string().default(''),
+});
+
+/**
+ * Audit retention and archival configuration.
+ *
+ * `AUDIT_RETENTION_DAYS` is the hard-delete retention already applied by the
+ * audit cleanup job. `AUDIT_ARCHIVE_RETENTION_DAYS` is the (shorter) age at
+ * which records are moved out of the actively queried `audit_logs` table into
+ * the compressed archive. The archive cutoff must stay below the delete
+ * retention, otherwise records could be deleted before they were archived.
+ */
+export const auditEnvSchema = z.object({
+ // Enables the scheduled audit archival job.
+ AUDIT_ARCHIVE_ENABLED: z
+ .enum(['true', 'false'])
+ .default('true')
+ .transform((value) => value === 'true'),
+ // Age (in days) at which audit records become eligible for archival.
+ AUDIT_ARCHIVE_RETENTION_DAYS: z.coerce.number().int().positive().max(3650).default(30),
+ // Maximum number of records archived per organization and batch.
+ AUDIT_ARCHIVE_BATCH_SIZE: z.coerce.number().int().positive().max(5_000).default(500),
+ // Maximum number of batches processed in a single archival run.
+ AUDIT_ARCHIVE_MAX_BATCHES: z.coerce.number().int().positive().max(1_000).default(20),
+ // Hard-delete retention applied by the audit cleanup job (days).
+ AUDIT_RETENTION_DAYS: z.coerce.number().int().positive().max(3650).default(90),
+});
+
export const queueEnvSchema = z.object({
QUEUE_PREFIX: z.string().default('astroid'),
QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5),
@@ -198,6 +254,8 @@ export const environmentSchema = z
...authEnvSchema.shape,
...stellarEnvSchema.shape,
...storageEnvSchema.shape,
+ ...priceOracleEnvSchema.shape,
+ ...auditEnvSchema.shape,
...queueEnvSchema.shape,
...throttleEnvSchema.shape,
...rateLimitEnvSchema.shape,
@@ -206,6 +264,17 @@ export const environmentSchema = z
...encryptionEnvSchema.shape,
})
.superRefine((env, ctx) => {
+ // Audit integrity: records must be archived before the cleanup job is
+ // allowed to delete them, otherwise the archive would silently lose
+ // history that the retention window promised to keep.
+ if (env.AUDIT_ARCHIVE_RETENTION_DAYS >= env.AUDIT_RETENTION_DAYS) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['AUDIT_ARCHIVE_RETENTION_DAYS'],
+ message: `must be lower than AUDIT_RETENTION_DAYS (${env.AUDIT_RETENTION_DAYS}) so records are archived before they are deleted`,
+ });
+ }
+
if (env.NODE_ENV !== 'production') {
return;
}
diff --git a/src/config/index.ts b/src/config/index.ts
index 5c7c95b6..e3f07523 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -12,6 +12,8 @@ import { encryptionConfig } from './encryption.config';
import { rateLimitConfig } from './rate-limit.config';
import { throttlerConfig } from './throttler.config';
import { shutdownConfig } from './shutdown.config';
+import { priceOracleConfig } from './price-oracle.config';
+import { auditConfig } from './audit.config';
export * from './app.config';
export * from './database.config';
@@ -26,6 +28,8 @@ export * from './encryption.config';
export * from './rate-limit.config';
export * from './throttler.config';
export * from './shutdown.config';
+export * from './price-oracle.config';
+export * from './audit.config';
/**
* Global configuration module. Every slice is registered via `registerAs` and
@@ -49,5 +53,7 @@ export const AppConfigModule = ConfigModule.forRoot({
rateLimitConfig,
throttlerConfig,
shutdownConfig,
+ priceOracleConfig,
+ auditConfig,
],
});
diff --git a/src/config/price-oracle.config.ts b/src/config/price-oracle.config.ts
new file mode 100644
index 00000000..026fbd26
--- /dev/null
+++ b/src/config/price-oracle.config.ts
@@ -0,0 +1,56 @@
+import { registerAs } from '@nestjs/config';
+import { priceOracleEnvSchema, validateEnv } from './env.validation';
+
+export type PriceOracleConfig = {
+ /** Whether the HTTP price source may be queried. */
+ enabled: boolean;
+ /** Provider-neutral JSON endpoint returning `{ quotes: { ASSET: { price, asOf } } }`. */
+ url: string;
+ /** Optional bearer token for the price endpoint. */
+ apiKey: string;
+ /** HTTP timeout for a single quote request (ms). */
+ timeoutMs: number;
+ /** Quotes older than this are rejected as stale (ms). */
+ maxAgeMs: number;
+ /** Operator-managed static quote table, keyed by upper-case asset code. */
+ staticPrices: Record;
+};
+
+/**
+ * Parses the `ASSET=PRICE,ASSET2=PRICE2` static quote table. Malformed
+ * entries are rejected at boot so a typo can never silently disable a limit.
+ */
+export function parseStaticPrices(raw: string): Record {
+ const prices: Record = {};
+ for (const entry of raw.split(',')) {
+ const trimmed = entry.trim();
+ if (!trimmed) continue;
+ const separator = trimmed.indexOf('=');
+ if (separator <= 0) {
+ throw new Error(
+ `Invalid PRICE_ORACLE_STATIC_PRICES entry '${trimmed}': expected ASSET=PRICE`,
+ );
+ }
+ const asset = trimmed.slice(0, separator).trim().toUpperCase();
+ const value = Number(trimmed.slice(separator + 1).trim());
+ if (!asset || !Number.isFinite(value) || value <= 0) {
+ throw new Error(
+ `Invalid PRICE_ORACLE_STATIC_PRICES entry '${trimmed}': price must be a positive number`,
+ );
+ }
+ prices[asset] = value;
+ }
+ return prices;
+}
+
+export const priceOracleConfig = registerAs('priceOracle', (): PriceOracleConfig => {
+ const env = validateEnv(priceOracleEnvSchema, process.env);
+ return {
+ enabled: env.PRICE_ORACLE_ENABLED,
+ url: env.PRICE_ORACLE_URL,
+ apiKey: env.PRICE_ORACLE_API_KEY,
+ timeoutMs: env.PRICE_ORACLE_TIMEOUT_MS,
+ maxAgeMs: env.PRICE_ORACLE_MAX_AGE_MS,
+ staticPrices: parseStaticPrices(env.PRICE_ORACLE_STATIC_PRICES),
+ };
+});
diff --git a/src/config/stellar.config.ts b/src/config/stellar.config.ts
index 18dedd3a..ea69ab87 100644
--- a/src/config/stellar.config.ts
+++ b/src/config/stellar.config.ts
@@ -7,8 +7,43 @@ export type StellarConfig = {
sorobanRpcUrl: string;
registryContractId: string;
useMock: boolean;
+ /**
+ * Stellar Anchor assets the platform accepts as a bare ticker, keyed by the
+ * upper-case asset code (e.g. `USDC`) and holding the issuing account.
+ */
+ anchorAssets: Record;
};
+/**
+ * Parses the `CODE=ISSUER` anchor asset list. Malformed entries are rejected
+ * at boot so a typo can never make an asset unresolvable at runtime.
+ */
+export function parseAnchorAssets(raw: string): Record {
+ const assets: Record = {};
+ for (const entry of raw.split(',')) {
+ const trimmed = entry.trim();
+ if (!trimmed) continue;
+ const separator = trimmed.indexOf('=');
+ if (separator <= 0) {
+ throw new Error(`Invalid STELLAR_ANCHOR_ASSETS entry '${trimmed}': expected CODE=ISSUER`);
+ }
+ const code = trimmed.slice(0, separator).trim().toUpperCase();
+ const issuer = trimmed.slice(separator + 1).trim();
+ if (!/^[A-Z0-9]{1,12}$/.test(code)) {
+ throw new Error(
+ `Invalid STELLAR_ANCHOR_ASSETS entry '${trimmed}': asset code must be 1-12 alphanumeric characters`,
+ );
+ }
+ if (!/^G[A-Z2-7]{55}$/.test(issuer)) {
+ throw new Error(
+ `Invalid STELLAR_ANCHOR_ASSETS entry '${trimmed}': issuer must be a Stellar account address (G…)`,
+ );
+ }
+ assets[code] = issuer;
+ }
+ return assets;
+}
+
export const stellarConfig = registerAs('stellar', (): StellarConfig => {
const env = validateEnv(stellarEnvSchema, process.env);
return {
@@ -17,5 +52,6 @@ export const stellarConfig = registerAs('stellar', (): StellarConfig => {
sorobanRpcUrl: env.STELLAR_SOROBAN_RPC_URL,
registryContractId: env.STELLAR_REGISTRY_CONTRACT_ID,
useMock: env.STELLAR_USE_MOCK,
+ anchorAssets: parseAnchorAssets(env.STELLAR_ANCHOR_ASSETS),
};
});
diff --git a/src/modules/audit/audit-archive.repository.ts b/src/modules/audit/audit-archive.repository.ts
new file mode 100644
index 00000000..b7bf0ebd
--- /dev/null
+++ b/src/modules/audit/audit-archive.repository.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../database/prisma.service';
+
+/**
+ * An audit row as it is stored in the archive. The shape mirrors the
+ * `audit_logs` table so the archived payload can be restored verbatim.
+ */
+export interface ArchivableAuditLog {
+ id: string;
+ organizationId: string;
+ userId: string | null;
+ action: string;
+ entity: string;
+ entityId: string | null;
+ oldValue: Prisma.JsonValue | null;
+ newValue: Prisma.JsonValue | null;
+ ipAddress: string | null;
+ device: string | null;
+ requestId: string | null;
+ sourceEventId: string | null;
+ previousHash: string | null;
+ hash: string | null;
+ createdAt: Date;
+}
+
+/** The archive row that holds one batch of archived audit records. */
+export interface AuditArchiveRecord {
+ batchId: string;
+ organizationId: string;
+ cutoff: Date;
+ recordCount: number;
+ payload: Buffer;
+ checksum: string;
+ chainBoundaryHash: string | null;
+ archivedThrough: Date;
+}
+
+/**
+ * The metadata of one archive batch, without the compressed payload. It is the
+ * shape returned by the archive listing so a caller can discover which
+ * batches exist before fetching the records of one of them.
+ */
+export interface AuditArchiveSummary {
+ batchId: string;
+ organizationId: string;
+ cutoff: Date;
+ recordCount: number;
+ checksum: string;
+ chainBoundaryHash: string | null;
+ archivedThrough: Date;
+ createdAt: Date;
+}
+
+/**
+ * Persistence for the audit archive.
+ *
+ * The archive is a compressed partition: one row per batch holding the
+ * gzip-compressed JSON of the archived audit records. Batches are addressed by
+ * a deterministic `batchId`, which makes writing a batch idempotent.
+ */
+@Injectable()
+export class AuditArchiveRepository {
+ constructor(private readonly prisma: PrismaService) {}
+
+ /**
+ * Returns the audit rows that are older than the cutoff, oldest first.
+ *
+ * The ordering matters: the oldest rows are always archived first, so the
+ * archived set of an organization is always a *prefix* of its hash chain and
+ * the chain stays verifiable across the archive boundary.
+ */
+ findArchivable(cutoff: Date, limit: number): Promise {
+ return this.prisma.auditLog.findMany({
+ where: { createdAt: { lt: cutoff } },
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
+ take: limit,
+ });
+ }
+
+ /**
+ * Stores a batch. A batch that already exists is left untouched, so a
+ * retried run never creates a duplicate archive.
+ */
+ async createArchive(record: AuditArchiveRecord): Promise {
+ await this.prisma.auditLogArchive.createMany({
+ data: [
+ {
+ batchId: record.batchId,
+ organizationId: record.organizationId,
+ cutoff: record.cutoff,
+ recordCount: record.recordCount,
+ payload: record.payload,
+ checksum: record.checksum,
+ chainBoundaryHash: record.chainBoundaryHash,
+ archivedThrough: record.archivedThrough,
+ },
+ ],
+ skipDuplicates: true,
+ });
+ }
+
+ /** Reads a stored batch back so it can be verified before the source is deleted. */
+ findArchiveByBatchId(batchId: string) {
+ return this.prisma.auditLogArchive.findUnique({ where: { batchId } });
+ }
+
+ /**
+ * Reads the metadata of one batch of an organization. Scoping the lookup by
+ * the organization keeps a batch of another tenant unreadable, and querying
+ * the batch directly keeps the lookup correct no matter how many archives
+ * the organization has.
+ */
+ findArchiveSummary(organizationId: string, batchId: string): Promise {
+ return this.prisma.auditLogArchive
+ .findUnique({
+ where: { batchId },
+ select: {
+ batchId: true,
+ organizationId: true,
+ cutoff: true,
+ recordCount: true,
+ checksum: true,
+ chainBoundaryHash: true,
+ archivedThrough: true,
+ createdAt: true,
+ },
+ })
+ .then((row) => (row && row.organizationId === organizationId ? row : null));
+ }
+
+ /**
+ * Reads the raw (still compressed) payload of a batch. The caller
+ * decompresses and verifies it, so a corrupted batch never surfaces as
+ * readable audit records.
+ */
+ findArchivePayload(batchId: string): Promise {
+ return this.prisma.auditLogArchive
+ .findUnique({ where: { batchId }, select: { payload: true } })
+ .then((row) => (row ? Buffer.from(row.payload) : null));
+ }
+
+ /**
+ * Lists the archive batches of an organization, newest first. Only the
+ * metadata is returned — the compressed payloads are fetched one batch at a
+ * time so a listing never loads the whole archive into memory.
+ */
+ findArchiveSummaries(organizationId: string, limit: number): Promise {
+ return this.prisma.auditLogArchive.findMany({
+ where: { organizationId },
+ orderBy: [{ archivedThrough: 'desc' }, { createdAt: 'desc' }],
+ take: limit,
+ select: {
+ batchId: true,
+ organizationId: true,
+ cutoff: true,
+ recordCount: true,
+ checksum: true,
+ chainBoundaryHash: true,
+ archivedThrough: true,
+ createdAt: true,
+ },
+ });
+ }
+
+ /** Removes the archived rows from the actively queried table. */
+ deleteAuditLogs(ids: string[]): Promise {
+ return this.prisma.auditLog.deleteMany({ where: { id: { in: ids } } });
+ }
+}
diff --git a/src/modules/audit/audit-archive.scheduler.ts b/src/modules/audit/audit-archive.scheduler.ts
new file mode 100644
index 00000000..863f07bd
--- /dev/null
+++ b/src/modules/audit/audit-archive.scheduler.ts
@@ -0,0 +1,77 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Queue } from 'bullmq';
+import { Cron, CronExpression } from '@nestjs/schedule';
+import { Queues } from '../../queues/queues.constants';
+import { buildJobOptions } from '../../queues/queue-policy';
+import { AuditConfig } from '../../config/audit.config';
+
+/** Job name used for the enqueued archival jobs. */
+export const AUDIT_ARCHIVE_JOB_NAME = 'audit-archive';
+
+/** Length of the window the job id is derived from (one hour). */
+const JOB_ID_WINDOW_MS = 3_600_000;
+
+/**
+ * Schedules the audit archival job.
+ *
+ * The tick only *enqueues* the work: the archival itself runs in the BullMQ
+ * worker, where it benefits from the shared retry and backoff policy and from
+ * the structured worker logging. The job id is derived from the hour the tick
+ * belongs to, so a duplicated tick — or a restart within the same hour —
+ * never queues the same archival twice.
+ */
+@Injectable()
+export class AuditArchiveScheduler {
+ private readonly logger = new Logger(AuditArchiveScheduler.name);
+
+ constructor(
+ @InjectQueue(Queues.AuditArchive) private readonly queue: Queue,
+ private readonly config: ConfigService,
+ ) {}
+
+ /** The resolved archival configuration. */
+ private get settings(): AuditConfig {
+ return (
+ this.config.get('audit') ?? {
+ archiveEnabled: true,
+ archiveRetentionDays: 30,
+ archiveBatchSize: 500,
+ archiveMaxBatches: 20,
+ retentionDays: 90,
+ }
+ );
+ }
+
+ /** Enqueues the archival job once per hour. */
+ @Cron(CronExpression.EVERY_HOUR)
+ async enqueueArchiveJob(): Promise {
+ await this.enqueueAt(new Date());
+ }
+
+ /**
+ * Enqueues one archival job. Failures are logged but never propagate into
+ * the scheduler: a transient Redis outage must not crash the application.
+ */
+ async enqueueAt(now: Date): Promise {
+ if (!this.settings.archiveEnabled) {
+ this.logger.debug('Audit archival is disabled; no job was enqueued');
+ return;
+ }
+
+ const jobId = `${AUDIT_ARCHIVE_JOB_NAME}-${Math.floor(now.getTime() / JOB_ID_WINDOW_MS)}`;
+ try {
+ await this.queue.add(
+ AUDIT_ARCHIVE_JOB_NAME,
+ { requestedAt: now.toISOString() },
+ { ...buildJobOptions(), jobId },
+ );
+ this.logger.debug(`Enqueued audit archival job ${jobId}`);
+ } catch (error) {
+ this.logger.error(
+ `Failed to enqueue the audit archival job ${jobId}: ${(error as Error).message}`,
+ );
+ }
+ }
+}
diff --git a/src/modules/audit/audit-archive.service.ts b/src/modules/audit/audit-archive.service.ts
new file mode 100644
index 00000000..1a6d31e9
--- /dev/null
+++ b/src/modules/audit/audit-archive.service.ts
@@ -0,0 +1,326 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { gzipSync, gunzipSync } from 'node:zlib';
+import { createHash } from 'node:crypto';
+import {
+ AuditArchiveRepository,
+ AuditArchiveSummary,
+ ArchivableAuditLog,
+} from './audit-archive.repository';
+import { MetricsService } from '../metrics/metrics.service';
+import { AuditConfig } from '../../config/audit.config';
+import { NotFoundException } from '../../common/exceptions/domain.exception';
+
+/** Result of one archival run. */
+export interface AuditArchiveRunResult {
+ /** The retention cutoff the run applied. */
+ cutoff: Date;
+ /** Number of batches that were archived. */
+ batches: number;
+ /** Number of audit records that were archived. */
+ archivedRecords: number;
+ /** Number of audit records that were removed from the active table. */
+ deletedRecords: number;
+ /** Wall-clock duration of the run (ms). */
+ durationMs: number;
+}
+
+/** Milliseconds in a day, used to turn the retention window into a cutoff. */
+const DAY_MS = 86_400_000;
+
+/**
+ * Scheduled archival of expired audit records.
+ *
+ * The job moves audit rows that are older than the configured retention
+ * period out of the actively queried `audit_logs` table into a compressed
+ * archive (`audit_log_archives`). The archive keeps the full row content plus
+ * a checksum, so the audit history is preserved while the hot table — and
+ * every query against it — stays small.
+ *
+ * Safety properties:
+ *
+ * - **Bounded work.** Records are selected in batches of at most
+ * `AUDIT_ARCHIVE_BATCH_SIZE` rows and at most `AUDIT_ARCHIVE_MAX_BATCHES`
+ * batches are processed per run, so the job never locks the table for long.
+ * - **Nothing is deleted before it is archived.** The compressed batch is
+ * written and read back (checksum + record count verified) *before* the
+ * source rows are removed. A failure in between leaves the records in place.
+ * - **Idempotent.** The batch id is derived from the archived row ids, so a
+ * retried run reuses the existing archive instead of writing a second copy,
+ * and the deletion of already-removed rows is a no-op.
+ * - **Chain preserving.** The oldest rows are archived first, so the archived
+ * set of an organization is always a prefix of its hash chain. The hash of
+ * the last archived entry is stored with the batch and anchors the live
+ * rows, which keeps `GET /audit/integrity/verify` meaningful.
+ */
+@Injectable()
+export class AuditArchiveService {
+ private readonly logger = new Logger(AuditArchiveService.name);
+
+ constructor(
+ private readonly repository: AuditArchiveRepository,
+ private readonly config: ConfigService,
+ private readonly metrics: MetricsService,
+ ) {}
+
+ /** The resolved archival configuration. */
+ private get settings(): AuditConfig {
+ return (
+ this.config.get('audit') ?? {
+ archiveEnabled: true,
+ archiveRetentionDays: 30,
+ archiveBatchSize: 500,
+ archiveMaxBatches: 20,
+ retentionDays: 90,
+ }
+ );
+ }
+
+ /** The age (in days) at which audit records become eligible for archival. */
+ get retentionDays(): number {
+ return this.settings.archiveRetentionDays;
+ }
+
+ /** The newest timestamp that is still eligible for archival. */
+ retentionCutoff(now: Date = new Date()): Date {
+ return new Date(now.getTime() - this.retentionDays * DAY_MS);
+ }
+
+ /**
+ * Runs one archival pass: selects the expired records in bounded batches,
+ * archives them and removes them from the active table.
+ *
+ * @throws when a batch cannot be archived or verified. The already archived
+ * batches stay archived, and the queue retries the run.
+ */
+ async archive(now: Date = new Date()): Promise {
+ if (!this.settings.archiveEnabled) {
+ this.logger.log('Audit archival is disabled; no records were archived');
+ return {
+ cutoff: this.retentionCutoff(now),
+ batches: 0,
+ archivedRecords: 0,
+ deletedRecords: 0,
+ durationMs: 0,
+ };
+ }
+
+ const cutoff = this.retentionCutoff(now);
+ const batchSize = this.settings.archiveBatchSize;
+ const maxBatches = this.settings.archiveMaxBatches;
+ const startedAt = Date.now();
+
+ let batches = 0;
+ let archivedRecords = 0;
+ let deletedRecords = 0;
+
+ this.logger.log(
+ `Starting audit archival run (cutoff ${cutoff.toISOString()}, batch size ${batchSize}, max batches ${maxBatches})`,
+ );
+
+ try {
+ while (batches < maxBatches) {
+ const records = await this.repository.findArchivable(cutoff, batchSize);
+ if (records.length === 0) {
+ break;
+ }
+
+ const groups = groupByOrganization(records);
+ let deletedInIteration = 0;
+
+ for (const [organizationId, organizationRecords] of groups) {
+ if (batches >= maxBatches) {
+ break;
+ }
+ const result = await this.archiveBatch(organizationId, organizationRecords, cutoff);
+ batches += 1;
+ archivedRecords += result.archivedRecords;
+ deletedRecords += result.deletedRecords;
+ deletedInIteration += result.deletedRecords;
+ }
+
+ // Nothing could be removed: stop instead of selecting the same rows again.
+ if (deletedInIteration === 0) {
+ break;
+ }
+ }
+ } catch (error) {
+ const durationMs = Date.now() - startedAt;
+ this.metrics.recordAuditArchive(archivedRecords, 'failed');
+ this.logger.error(
+ `Audit archival failed after ${archivedRecords} archived record(s) in ${batches} batch(es) (${durationMs}ms): ${(error as Error).message}`,
+ );
+ throw error;
+ }
+
+ const durationMs = Date.now() - startedAt;
+ this.metrics.recordAuditArchive(archivedRecords, 'archived');
+ this.metrics.recordAuditArchive(deletedRecords, 'deleted');
+
+ const result: AuditArchiveRunResult = {
+ cutoff,
+ batches,
+ archivedRecords,
+ deletedRecords,
+ durationMs,
+ };
+
+ this.logger.log(
+ `Audit archival completed: ${archivedRecords} records archived and removed from the active table in ${batches} batch(es) in ${durationMs}ms`,
+ );
+ return result;
+ }
+
+ // ── read path ──────────────────────────────────────────────────────────
+
+ /**
+ * Lists the archive batches of an organization, newest first.
+ *
+ * The compressed payloads are not loaded: a listing only describes the
+ * batches so a caller can pick one and fetch its records.
+ */
+ async listArchives(organizationId: string, limit = 50): Promise {
+ return this.repository.findArchiveSummaries(organizationId, Math.min(Math.max(limit, 1), 100));
+ }
+
+ /**
+ * Reads back the records of one archive batch.
+ *
+ * The payload is decompressed and its checksum verified before it is
+ * returned, so a corrupted archive surfaces as an error instead of as
+ * silently mangled audit records.
+ *
+ * @throws NotFoundException when the batch does not exist or belongs to
+ * another organization.
+ */
+ async readArchive(organizationId: string, batchId: string): Promise {
+ const batch = await this.repository.findArchiveSummary(organizationId, batchId);
+ if (!batch) {
+ throw new NotFoundException(`Audit archive '${batchId}' was not found`);
+ }
+
+ const payload = await this.repository.findArchivePayload(batchId);
+ if (!payload) {
+ throw new NotFoundException(`Audit archive '${batchId}' was not found`);
+ }
+
+ const decompressed = gunzipSync(payload);
+ if (sha256(decompressed) !== batch.checksum) {
+ throw new Error(`Audit archive '${batchId}' failed its checksum and cannot be read`);
+ }
+
+ const records = JSON.parse(decompressed.toString('utf8')) as ArchivableAuditLog[];
+ return records.map((record) => ({
+ ...record,
+ // Dates are serialized as ISO strings; restore them so the callers see
+ // the same shape as a live audit row.
+ createdAt: new Date(record.createdAt),
+ }));
+ }
+
+ // ── internals ──────────────────────────────────────────────────────────
+
+ /**
+ * Archives one batch of records of a single organization.
+ *
+ * The order is deliberate: write → verify → delete. The source rows are only
+ * removed once the compressed batch has been read back and its checksum and
+ * record count match.
+ */
+ private async archiveBatch(
+ organizationId: string,
+ records: ArchivableAuditLog[],
+ cutoff: Date,
+ ): Promise<{ archivedRecords: number; deletedRecords: number }> {
+ const batchId = batchIdFor(records);
+ const serialized = Buffer.from(JSON.stringify(records), 'utf8');
+ const payload = gzipSync(serialized);
+ const checksum = sha256(serialized);
+ const chainBoundaryHash = lastHashOf(records);
+ const archivedThrough = records[records.length - 1].createdAt;
+
+ await this.repository.createArchive({
+ batchId,
+ organizationId,
+ cutoff,
+ recordCount: records.length,
+ payload,
+ checksum,
+ chainBoundaryHash,
+ archivedThrough,
+ });
+
+ await this.verifyArchive(batchId, checksum, records.length);
+
+ const deleted = await this.repository.deleteAuditLogs(records.map((record) => record.id));
+
+ this.logger.log(
+ `Archived ${records.length} audit record(s) of organization ${organizationId} (batch ${batchId.slice(0, 12)}…, ${deleted.count} removed from the active table)`,
+ );
+
+ return { archivedRecords: records.length, deletedRecords: deleted.count };
+ }
+
+ /**
+ * Reads the stored batch back and verifies it. Throws when the archive is
+ * missing, incomplete or corrupted, which keeps the source rows in place.
+ */
+ private async verifyArchive(
+ batchId: string,
+ checksum: string,
+ expectedCount: number,
+ ): Promise {
+ const stored = await this.repository.findArchiveByBatchId(batchId);
+ if (!stored) {
+ throw new Error(`Audit archive ${batchId} could not be read back; source records are kept`);
+ }
+ if (stored.recordCount !== expectedCount) {
+ throw new Error(
+ `Audit archive ${batchId} holds ${stored.recordCount} records instead of ${expectedCount}; source records are kept`,
+ );
+ }
+ const decompressed = gunzipSync(Buffer.from(stored.payload));
+ if (sha256(decompressed) !== stored.checksum || stored.checksum !== checksum) {
+ throw new Error(`Audit archive ${batchId} failed its checksum; source records are kept`);
+ }
+ }
+}
+
+/** Groups the selected records by organization, preserving the chain order. */
+function groupByOrganization(records: ArchivableAuditLog[]): Map {
+ const groups = new Map();
+ for (const record of records) {
+ const group = groups.get(record.organizationId);
+ if (group) {
+ group.push(record);
+ } else {
+ groups.set(record.organizationId, [record]);
+ }
+ }
+ return groups;
+}
+
+/**
+ * Deterministic id of a batch: the same set of records always produces the
+ * same id, which is what makes writing the archive idempotent.
+ */
+export function batchIdFor(records: ArchivableAuditLog[]): string {
+ const ids = records.map((record) => record.id).sort();
+ return sha256(ids.join(','));
+}
+
+/** The hash of the last archived entry that carries one — the chain anchor. */
+function lastHashOf(records: ArchivableAuditLog[]): string | null {
+ for (let index = records.length - 1; index >= 0; index -= 1) {
+ const hash = records[index].hash;
+ if (hash) {
+ return hash;
+ }
+ }
+ return null;
+}
+
+/** SHA-256 of a buffer, hex encoded. */
+function sha256(value: Buffer | string): string {
+ return createHash('sha256').update(value).digest('hex');
+}
diff --git a/src/modules/audit/audit-hash.service.spec.ts b/src/modules/audit/audit-hash.service.spec.ts
index 0943d7ab..6b0e2f8f 100644
--- a/src/modules/audit/audit-hash.service.spec.ts
+++ b/src/modules/audit/audit-hash.service.spec.ts
@@ -8,6 +8,9 @@ describe('AuditHashService', () => {
findFirst: ReturnType;
findMany: ReturnType;
};
+ auditLogArchive: {
+ findFirst: ReturnType;
+ };
};
beforeEach(() => {
@@ -16,6 +19,9 @@ describe('AuditHashService', () => {
findFirst: vi.fn(),
findMany: vi.fn(),
},
+ auditLogArchive: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
};
service = new AuditHashService(mockPrisma as unknown as import('../../database/prisma.service').PrismaService);
@@ -320,6 +326,70 @@ describe('AuditHashService', () => {
expect(result.valid).toBe(true);
expect(result.totalChecked).toBe(2);
});
+
+ it('anchors the chain on the archive when older entries were archived', async () => {
+ const entry: AuditHashInput = {
+ organizationId: 'org-123',
+ userId: 'user-1',
+ action: 'AGENT_PAYMENT_COMPLETED',
+ entity: 'Transaction',
+ entityId: 'tx-1',
+ oldValue: { amount: 200 },
+ newValue: { amount: 200 },
+ ipAddress: '127.0.0.1',
+ device: 'AgentRunner/1.0',
+ createdAt: new Date('2026-08-30T10:05:00Z'),
+ };
+
+ // The archived prefix ends with this hash, so the first live entry has
+ // to point at it.
+ const archiveAnchor = 'archived-boundary-hash';
+ const hash = service.computeEntryHash(entry, archiveAnchor);
+
+ mockPrisma.auditLogArchive.findFirst.mockResolvedValueOnce({
+ chainBoundaryHash: archiveAnchor,
+ });
+ mockPrisma.auditLog.findMany.mockResolvedValueOnce([
+ { id: 'entry-1', ...entry, ...hash },
+ ]);
+
+ const result = await service.verifyChainIntegrity('org-123');
+
+ expect(result.valid).toBe(true);
+ expect(result.totalChecked).toBe(1);
+ });
+
+ it('reports a broken chain when the live entries do not point at the archive', async () => {
+ const entry: AuditHashInput = {
+ organizationId: 'org-123',
+ userId: 'user-1',
+ action: 'AGENT_PAYMENT_COMPLETED',
+ entity: 'Transaction',
+ entityId: 'tx-1',
+ oldValue: { amount: 200 },
+ newValue: { amount: 200 },
+ ipAddress: '127.0.0.1',
+ device: 'AgentRunner/1.0',
+ createdAt: new Date('2026-08-30T10:05:00Z'),
+ };
+
+ mockPrisma.auditLogArchive.findFirst.mockResolvedValueOnce({
+ chainBoundaryHash: 'archived-boundary-hash',
+ });
+ mockPrisma.auditLog.findMany.mockResolvedValueOnce([
+ {
+ id: 'entry-1',
+ ...entry,
+ previousHash: null,
+ hash: service.computeEntryHash(entry, null).hash,
+ },
+ ]);
+
+ const result = await service.verifyChainIntegrity('org-123');
+
+ expect(result.valid).toBe(false);
+ expect(result.brokenAtEntryId).toBe('entry-1');
+ });
});
describe('verifyEntryIntegrity', () => {
diff --git a/src/modules/audit/audit-hash.service.ts b/src/modules/audit/audit-hash.service.ts
index 3890d087..65c7a560 100644
--- a/src/modules/audit/audit-hash.service.ts
+++ b/src/modules/audit/audit-hash.service.ts
@@ -75,6 +75,11 @@ export class AuditHashService {
/**
* Validates the integrity of the entire audit chain for an organization.
* Traverses all entries and verifies each hash links correctly to the previous.
+ *
+ * Records that were moved into the compressed archive are not part of the
+ * live table any more. The archive stores the hash of the last archived
+ * entry, which anchors the chain: the first live entry is expected to point
+ * at that hash instead of at `null`.
*/
async verifyChainIntegrity(
organizationId: string,
@@ -99,7 +104,8 @@ export class AuditHashService {
},
});
- let expectedPreviousHash: string | null = null;
+ const archiveAnchor = await this.findArchiveAnchor(organizationId);
+ let expectedPreviousHash: string | null = archiveAnchor;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -207,6 +213,23 @@ export class AuditHashService {
return { valid: true, message: `Entry ${entryId} integrity verified` };
}
+ /**
+ * The hash of the newest archived entry of an organization, if any. It is
+ * the anchor the live entries chain onto after the archival moved the older
+ * entries into the compressed archive.
+ *
+ * Only batches that carry an anchor are considered, so a batch of entries
+ * that predate the hash chain can never mask the anchor of an earlier batch.
+ */
+ private async findArchiveAnchor(organizationId: string): Promise {
+ const archive = await this.prisma.auditLogArchive.findFirst({
+ where: { organizationId, chainBoundaryHash: { not: null } },
+ orderBy: [{ archivedThrough: 'desc' }, { createdAt: 'desc' }],
+ select: { chainBoundaryHash: true },
+ });
+ return archive?.chainBoundaryHash ?? null;
+ }
+
/**
* Builds a canonical string representation of the audit entry for hashing.
* The representation is deterministic and includes all fields that define
diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts
index 6a4abd1e..99d2be9b 100644
--- a/src/modules/audit/audit.controller.ts
+++ b/src/modules/audit/audit.controller.ts
@@ -11,6 +11,7 @@ import {
import { UserRole } from '@prisma/client';
import { Response } from 'express';
import { AuditService } from './audit.service';
+import { AuditArchiveService } from './audit-archive.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
@@ -30,7 +31,10 @@ import { AuditListQuery, auditListQuerySchema } from './audit-list.dto';
@Controller('audit')
@Roles(UserRole.OWNER, UserRole.ADMIN, UserRole.AUDITOR)
export class AuditController {
- constructor(private readonly auditService: AuditService) {}
+ constructor(
+ private readonly auditService: AuditService,
+ private readonly auditArchiveService: AuditArchiveService,
+ ) {}
@Get('export')
@ApiOperation({
@@ -156,6 +160,48 @@ export class AuditController {
return this.auditService.verifyEntryIntegrity(id, organizationId);
}
+ @Get('archives')
+ @ApiOperation({
+ summary: 'List the audit archives of the organization',
+ description:
+ 'Lists the batches of audit records that the archival job moved out of the actively queried `audit_logs` table. ' +
+ 'Only the batch metadata is returned; use `GET /audit/archives/{batchId}` to read the records of a batch.',
+ })
+ @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Max batches to return (default 50, max 100)' })
+ @ApiResponse({ status: 200, description: 'Archive batches, newest first' })
+ @ApiResponse({ status: 401, description: 'Not authenticated' })
+ @ApiResponse({ status: 403, description: 'Insufficient permissions' })
+ async listArchives(
+ @CurrentUser('organizationId') organizationId: string,
+ @Query('limit') limit?: string,
+ ) {
+ const parsed = Number(limit);
+ const batches = await this.auditArchiveService.listArchives(
+ organizationId,
+ Number.isFinite(parsed) && parsed > 0 ? parsed : 50,
+ );
+ return { batches, count: batches.length };
+ }
+
+ @Get('archives/:batchId')
+ @ApiOperation({
+ summary: 'Read the records of an audit archive batch',
+ description:
+ 'Returns the audit records of one archived batch. The compressed payload is decompressed and its checksum verified before it is returned.',
+ })
+ @ApiParam({ name: 'batchId', description: 'Archive batch id', example: '8f1c…' })
+ @ApiResponse({ status: 200, description: 'The archived audit records' })
+ @ApiResponse({ status: 401, description: 'Not authenticated' })
+ @ApiResponse({ status: 403, description: 'Insufficient permissions' })
+ @ApiResponse({ status: 404, description: 'Audit archive batch not found' })
+ async readArchive(
+ @CurrentUser('organizationId') organizationId: string,
+ @Param('batchId') batchId: string,
+ ) {
+ const records = await this.auditArchiveService.readArchive(organizationId, batchId);
+ return { records, count: records.length };
+ }
+
@Get(':id')
@ApiOperation({
summary: 'Get a single audit log entry',
diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts
index 18bbb4a7..5c9855c9 100644
--- a/src/modules/audit/audit.module.ts
+++ b/src/modules/audit/audit.module.ts
@@ -5,10 +5,15 @@ import { AuditService } from './audit.service';
import { AuditRepository } from './audit.repository';
import { AuditHashService } from './audit-hash.service';
import { AuditListener } from './audit.listener';
+import { AuditArchiveService } from './audit-archive.service';
+import { AuditArchiveRepository } from './audit-archive.repository';
+import { AuditArchiveScheduler } from './audit-archive.scheduler';
+import { AuditArchiveWorker } from './queues/audit-archive.worker';
import { Queues } from '../../queues/queues.constants';
import { redisConfig } from '../../config/redis.config';
import { AuditCleanupQueue } from './queues/audit-cleanup.queue';
import { createQueueRegistrationOptions } from '../../queues/queue-policy';
+import { MetricsModule } from '../metrics/metrics.module';
@Global()
@Module({
@@ -22,9 +27,21 @@ import { createQueueRegistrationOptions } from '../../queues/queue-policy';
},
}),
BullModule.registerQueue(createQueueRegistrationOptions(Queues.AuditCleanup)),
+ BullModule.registerQueue(createQueueRegistrationOptions(Queues.AuditArchive)),
+ MetricsModule,
],
controllers: [AuditController],
- providers: [AuditService, AuditRepository, AuditHashService, AuditListener, AuditCleanupQueue],
- exports: [AuditService],
+ providers: [
+ AuditService,
+ AuditRepository,
+ AuditHashService,
+ AuditListener,
+ AuditCleanupQueue,
+ AuditArchiveService,
+ AuditArchiveRepository,
+ AuditArchiveScheduler,
+ AuditArchiveWorker,
+ ],
+ exports: [AuditService, AuditArchiveService],
})
export class AuditModule {}
diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts
index 6513c175..24551751 100644
--- a/src/modules/audit/index.ts
+++ b/src/modules/audit/index.ts
@@ -1,3 +1,5 @@
export * from './audit.service';
export * from './audit-hash.service';
+export * from './audit-archive.service';
+export * from './audit-archive.repository';
export * from './audit.module';
diff --git a/src/modules/audit/queues/audit-archive.worker.ts b/src/modules/audit/queues/audit-archive.worker.ts
new file mode 100644
index 00000000..263dd5ad
--- /dev/null
+++ b/src/modules/audit/queues/audit-archive.worker.ts
@@ -0,0 +1,46 @@
+import { Processor, WorkerHost } from '@nestjs/bullmq';
+import { Logger, OnModuleDestroy } from '@nestjs/common';
+import type { Job } from 'bullmq';
+import { Queues } from '../../../queues/queues.constants';
+import { runWorkerJob } from '../../../workers/job-worker';
+import { AuditArchiveService } from '../audit-archive.service';
+import { WorkerMetricsService } from '../../metrics/worker-metrics.service';
+
+/**
+ * Consumes the audit archival queue.
+ *
+ * The worker owns the failure handling: a failed archival is rethrown so
+ * BullMQ applies the shared retry and backoff policy, and the terminal failure
+ * is routed to the dead-letter queue by the queue failure listener. The
+ * archival itself is idempotent, so a retry can never duplicate an archive or
+ * lose a record.
+ */
+@Processor(Queues.AuditArchive)
+export class AuditArchiveWorker extends WorkerHost implements OnModuleDestroy {
+ private readonly logger = new Logger(AuditArchiveWorker.name);
+
+ constructor(
+ private readonly archiveService: AuditArchiveService,
+ private readonly workerMetrics: WorkerMetricsService,
+ ) {
+ super();
+ }
+
+ async process(job: Job): Promise {
+ await runWorkerJob({
+ queue: Queues.AuditArchive,
+ job,
+ logger: this.logger,
+ defaultJobName: 'audit-archive',
+ metrics: this.workerMetrics,
+ handler: () => this.archiveService.archive(),
+ });
+ }
+
+ async onModuleDestroy(): Promise {
+ if (this.worker) {
+ await this.worker.close();
+ this.logger.log('AuditArchiveWorker closed successfully');
+ }
+ }
+}
diff --git a/src/modules/audit/tests/audit-archive.scheduler.spec.ts b/src/modules/audit/tests/audit-archive.scheduler.spec.ts
new file mode 100644
index 00000000..5682f726
--- /dev/null
+++ b/src/modules/audit/tests/audit-archive.scheduler.spec.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { ConfigService } from '@nestjs/config';
+import { Queue } from 'bullmq';
+import { AuditArchiveScheduler, AUDIT_ARCHIVE_JOB_NAME } from '../audit-archive.scheduler';
+
+describe('AuditArchiveScheduler', () => {
+ let queue: { add: ReturnType };
+ let scheduler: AuditArchiveScheduler;
+
+ function build(archiveEnabled = true): void {
+ queue = { add: vi.fn().mockResolvedValue({ id: 'job-1' }) };
+ const config = {
+ get: vi.fn().mockReturnValue({
+ archiveEnabled,
+ archiveRetentionDays: 30,
+ archiveBatchSize: 500,
+ archiveMaxBatches: 20,
+ retentionDays: 90,
+ }),
+ } as unknown as ConfigService;
+ scheduler = new AuditArchiveScheduler(queue as unknown as Queue, config);
+ }
+
+ beforeEach(() => {
+ build();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('enqueues the archival job on the audit archive queue', async () => {
+ await scheduler.enqueueArchiveJob();
+
+ expect(queue.add).toHaveBeenCalledTimes(1);
+ const [name, payload] = queue.add.mock.calls[0] as [string, { requestedAt: string }];
+ expect(name).toBe(AUDIT_ARCHIVE_JOB_NAME);
+ expect(payload.requestedAt).toBeDefined();
+ });
+
+ it('uses the shared retry and backoff policy of the queue', async () => {
+ await scheduler.enqueueArchiveJob();
+
+ const options = queue.add.mock.calls[0][2] as { attempts: number; backoff: { type: string } };
+ expect(options.attempts).toBe(3);
+ expect(options.backoff.type).toBe('exponential');
+ });
+
+ it('derives a stable job id from the hour so a tick is never queued twice', async () => {
+ const at = new Date('2026-10-20T12:30:00.000Z');
+
+ await scheduler.enqueueAt(at);
+ await scheduler.enqueueAt(new Date('2026-10-20T12:45:00.000Z'));
+
+ const first = queue.add.mock.calls[0][2] as { jobId: string };
+ const second = queue.add.mock.calls[1][2] as { jobId: string };
+ expect(first.jobId).toBe(second.jobId);
+ });
+
+ it('uses a different job id for a different hour', async () => {
+ await scheduler.enqueueAt(new Date('2026-10-20T12:30:00.000Z'));
+ await scheduler.enqueueAt(new Date('2026-10-20T13:30:00.000Z'));
+
+ const first = queue.add.mock.calls[0][2] as { jobId: string };
+ const second = queue.add.mock.calls[1][2] as { jobId: string };
+ expect(first.jobId).not.toBe(second.jobId);
+ });
+
+ it('does not enqueue anything when the archival is disabled', async () => {
+ build(false);
+
+ await scheduler.enqueueArchiveJob();
+
+ expect(queue.add).not.toHaveBeenCalled();
+ });
+
+ it('never lets a queue outage escape into the scheduler', async () => {
+ queue.add.mockRejectedValue(new Error('redis unavailable'));
+
+ await expect(scheduler.enqueueArchiveJob()).resolves.toBeUndefined();
+ });
+
+ it('runs once per scheduled tick', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-20T12:00:00.000Z'));
+
+ await scheduler.enqueueArchiveJob();
+ vi.advanceTimersByTime(60 * 60 * 1000);
+ await scheduler.enqueueArchiveJob();
+
+ expect(queue.add).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/modules/audit/tests/audit-archive.service.spec.ts b/src/modules/audit/tests/audit-archive.service.spec.ts
new file mode 100644
index 00000000..3492eb2a
--- /dev/null
+++ b/src/modules/audit/tests/audit-archive.service.spec.ts
@@ -0,0 +1,417 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { gunzipSync } from 'node:zlib';
+import { createHash } from 'node:crypto';
+import { AuditArchiveService, batchIdFor } from '../audit-archive.service';
+import { AuditArchiveRepository, ArchivableAuditLog } from '../audit-archive.repository';
+import { MetricsService } from '../../metrics/metrics.service';
+import { ConfigService } from '@nestjs/config';
+
+const NOW = new Date('2026-10-20T12:00:00.000Z');
+
+/** Builds an audit row that is `days` old relative to {@link NOW}. */
+function auditRow(overrides: Partial = {}): ArchivableAuditLog {
+ return {
+ id: 'log-1',
+ organizationId: 'org-1',
+ userId: 'user-1',
+ action: 'TRANSFER_FUNDS',
+ entity: 'transaction',
+ entityId: 'tx-1',
+ oldValue: null,
+ newValue: { amount: '10' },
+ ipAddress: null,
+ device: null,
+ requestId: null,
+ sourceEventId: null,
+ previousHash: 'prev-hash',
+ hash: 'hash-1',
+ createdAt: new Date(NOW.getTime() - 40 * 86_400_000),
+ ...overrides,
+ };
+}
+
+/** In-memory stand-in for the archive repository. */
+class FakeArchiveRepository {
+ readonly archives = new Map>();
+ readonly deleted: string[][] = [];
+ failOnCreate = false;
+ failOnRead = false;
+ corruptOnRead = false;
+
+ constructor(private pending: ArchivableAuditLog[] = []) {}
+
+ findArchivable = vi.fn(async (_cutoff: Date, limit: number) => this.pending.slice(0, limit));
+
+ createArchive = vi.fn(
+ async (record: {
+ batchId: string;
+ payload: Buffer;
+ checksum: string;
+ recordCount: number;
+ chainBoundaryHash: string | null;
+ archivedThrough: Date;
+ organizationId: string;
+ cutoff: Date;
+ }) => {
+ if (this.failOnCreate) {
+ throw new Error('archive write failed');
+ }
+ if (!this.archives.has(record.batchId)) {
+ this.archives.set(record.batchId, { ...record, createdAt: new Date() });
+ }
+ },
+ );
+
+ findArchiveByBatchId = vi.fn(async (batchId: string) => {
+ if (this.failOnRead) {
+ throw new Error('archive read failed');
+ }
+ const stored = this.archives.get(batchId);
+ if (!stored) {
+ return null;
+ }
+ if (this.corruptOnRead) {
+ return { ...stored, checksum: 'tampered' };
+ }
+ return stored;
+ });
+
+ findArchiveSummaries = vi.fn(async (organizationId: string, limit: number) =>
+ [...this.archives.values()]
+ .filter((entry) => entry.organizationId === organizationId)
+ .slice(0, limit),
+ );
+
+ findArchiveSummary = vi.fn(async (organizationId: string, batchId: string) => {
+ const stored = this.archives.get(batchId);
+ if (!stored || stored.organizationId !== organizationId) {
+ return null;
+ }
+ return stored;
+ });
+
+ findArchivePayload = vi.fn(async (batchId: string) => {
+ const stored = this.archives.get(batchId);
+ if (!stored) {
+ return null;
+ }
+ return Buffer.from(stored.payload as Buffer);
+ });
+
+ deleteAuditLogs = vi.fn(async (ids: string[]) => {
+ this.deleted.push(ids);
+ const removed = new Set(ids);
+ this.pending = this.pending.filter((row) => !removed.has(row.id));
+ return { count: ids.length };
+ });
+}
+
+describe('AuditArchiveService', () => {
+ let repository: FakeArchiveRepository;
+ let metrics: { recordAuditArchive: ReturnType };
+ let service: AuditArchiveService;
+
+ function buildService(
+ selection: ArchivableAuditLog[] = [],
+ settings: Partial<{
+ archiveEnabled: boolean;
+ archiveRetentionDays: number;
+ archiveBatchSize: number;
+ archiveMaxBatches: number;
+ retentionDays: number;
+ }> = {},
+ ): void {
+ repository = new FakeArchiveRepository(selection);
+ metrics = { recordAuditArchive: vi.fn() };
+ const config = {
+ get: vi.fn().mockReturnValue({
+ archiveEnabled: true,
+ archiveRetentionDays: 30,
+ archiveBatchSize: 500,
+ archiveMaxBatches: 20,
+ retentionDays: 90,
+ ...settings,
+ }),
+ } as unknown as ConfigService;
+ service = new AuditArchiveService(
+ repository as unknown as AuditArchiveRepository,
+ config,
+ metrics as unknown as MetricsService,
+ );
+ }
+
+ beforeEach(() => {
+ buildService();
+ });
+
+ describe('retention cutoff', () => {
+ it('computes the cutoff from the configured retention', () => {
+ buildService([], { archiveRetentionDays: 30 });
+ expect(service.retentionCutoff(NOW).toISOString()).toBe(
+ new Date(NOW.getTime() - 30 * 86_400_000).toISOString(),
+ );
+ });
+
+ it('uses the configured retention days', () => {
+ buildService([], { archiveRetentionDays: 7 });
+ expect(service.retentionDays).toBe(7);
+ });
+
+ it('selects only the records that are older than the cutoff', async () => {
+ await service.archive(NOW);
+ const [cutoff] = repository.findArchivable.mock.calls[0] as [Date, number];
+ expect(cutoff.getTime()).toBe(NOW.getTime() - 30 * 86_400_000);
+ });
+ });
+
+ describe('archive', () => {
+ it('does nothing when there is nothing to archive', async () => {
+ const result = await service.archive(NOW);
+
+ expect(result.archivedRecords).toBe(0);
+ expect(result.batches).toBe(0);
+ expect(repository.createArchive).not.toHaveBeenCalled();
+ expect(repository.deleteAuditLogs).not.toHaveBeenCalled();
+ });
+
+ it('archives a batch and removes it from the active table', async () => {
+ const rows = [auditRow({ id: 'log-1' }), auditRow({ id: 'log-2', hash: 'hash-2' })];
+ buildService([...rows]);
+
+ const result = await service.archive(NOW);
+
+ expect(result.archivedRecords).toBe(2);
+ expect(result.deletedRecords).toBe(2);
+ expect(repository.createArchive).toHaveBeenCalledTimes(1);
+ expect(repository.deleteAuditLogs).toHaveBeenCalledWith(['log-1', 'log-2']);
+ });
+
+ it('stores the compressed payload with a checksum and the chain anchor', async () => {
+ const rows = [
+ auditRow({ id: 'log-1', hash: 'hash-1' }),
+ auditRow({ id: 'log-2', hash: 'hash-2' }),
+ ];
+ buildService([...rows]);
+
+ await service.archive(NOW);
+
+ const stored = [...repository.archives.values()][0] as {
+ payload: Buffer;
+ checksum: string;
+ chainBoundaryHash: string;
+ recordCount: number;
+ };
+ expect(stored.recordCount).toBe(2);
+ expect(stored.chainBoundaryHash).toBe('hash-2');
+ const decompressed = gunzipSync(stored.payload).toString('utf8');
+ expect(JSON.parse(decompressed)).toHaveLength(2);
+ expect(stored.checksum).toBe(
+ createHash('sha256').update(Buffer.from(decompressed, 'utf8')).digest('hex'),
+ );
+ });
+
+ it('never deletes the source records when the archive cannot be written', async () => {
+ buildService([auditRow()]);
+ repository.failOnCreate = true;
+
+ await expect(service.archive(NOW)).rejects.toThrow('archive write failed');
+ expect(repository.deleteAuditLogs).not.toHaveBeenCalled();
+ });
+
+ it('never deletes the source records when the archive cannot be read back', async () => {
+ buildService([auditRow()]);
+ repository.failOnRead = true;
+
+ await expect(service.archive(NOW)).rejects.toThrow('archive read failed');
+ expect(repository.deleteAuditLogs).not.toHaveBeenCalled();
+ });
+
+ it('never deletes the source records when the archive fails its checksum', async () => {
+ buildService([auditRow()]);
+ repository.corruptOnRead = true;
+
+ await expect(service.archive(NOW)).rejects.toThrow(/checksum/);
+ expect(repository.deleteAuditLogs).not.toHaveBeenCalled();
+ });
+
+ it('archives the organizations of a batch independently', async () => {
+ buildService([
+ auditRow({ id: 'log-1', organizationId: 'org-1' }),
+ auditRow({ id: 'log-2', organizationId: 'org-2' }),
+ ]);
+
+ const result = await service.archive(NOW);
+
+ expect(result.batches).toBe(2);
+ expect(repository.createArchive).toHaveBeenCalledTimes(2);
+ const organizations = repository.createArchive.mock.calls.map(
+ (call) => (call[0] as { organizationId: string }).organizationId,
+ );
+ expect(organizations).toEqual(['org-1', 'org-2']);
+ });
+
+ it('keeps the batches that were archived when a later batch fails', async () => {
+ buildService([
+ auditRow({ id: 'log-1', organizationId: 'org-1' }),
+ auditRow({ id: 'log-2', organizationId: 'org-2' }),
+ ]);
+ repository.createArchive = vi.fn(async (record: { batchId: string }) => {
+ if (record.batchId === batchIdFor([auditRow({ id: 'log-2', organizationId: 'org-2' })])) {
+ throw new Error('archive write failed');
+ }
+ repository.archives.set(record.batchId, { ...record });
+ });
+
+ await expect(service.archive(NOW)).rejects.toThrow('archive write failed');
+ // The first organization was archived and removed before the failure.
+ expect(repository.deleted).toEqual([['log-1']]);
+ });
+
+ it('stops after the configured number of batches', async () => {
+ buildService(
+ [auditRow({ id: 'log-1' }), auditRow({ id: 'log-2' }), auditRow({ id: 'log-3' })],
+ { archiveMaxBatches: 2, archiveBatchSize: 1 },
+ );
+
+ const result = await service.archive(NOW);
+
+ expect(result.batches).toBe(2);
+ expect(repository.createArchive).toHaveBeenCalledTimes(2);
+ });
+
+ it('never selects more records than the configured batch size', async () => {
+ buildService([auditRow({ id: 'log-1' })], { archiveBatchSize: 1 });
+
+ await service.archive(NOW);
+
+ expect(repository.findArchivable).toHaveBeenCalledWith(expect.any(Date), 1);
+ });
+
+ it('is idempotent: a second run finds nothing left to archive', async () => {
+ const rows = [auditRow({ id: 'log-1' })];
+ buildService([...rows]);
+
+ await service.archive(NOW);
+ const firstArchives = repository.archives.size;
+ const second = await service.archive(NOW);
+
+ expect(firstArchives).toBe(1);
+ expect(second.archivedRecords).toBe(0);
+ expect(repository.createArchive).toHaveBeenCalledTimes(1);
+ });
+
+ it('reuses the same archive when the same records are archived again', async () => {
+ const rows = [auditRow({ id: 'log-1' })];
+ buildService([...rows]);
+
+ await service.archive(NOW);
+ const batchId = batchIdFor(rows);
+ // Simulate a retry that selects the very same rows again.
+ repository.createArchive.mockClear();
+ await service.archive(NOW);
+
+ expect(repository.archives.size).toBe(1);
+ expect(repository.archives.has(batchId)).toBe(true);
+ });
+
+ it('reports the processed records to the metrics', async () => {
+ buildService([auditRow({ id: 'log-1' }), auditRow({ id: 'log-2' })]);
+
+ await service.archive(NOW);
+
+ expect(metrics.recordAuditArchive).toHaveBeenCalledWith(2, 'archived');
+ expect(metrics.recordAuditArchive).toHaveBeenCalledWith(2, 'deleted');
+ });
+
+ it('records a failure metric when the archival fails', async () => {
+ buildService([auditRow()]);
+ repository.failOnCreate = true;
+
+ await expect(service.archive(NOW)).rejects.toThrow('archive write failed');
+
+ expect(metrics.recordAuditArchive).toHaveBeenCalledWith(0, 'failed');
+ });
+
+ it('does not archive anything while the archival is disabled', async () => {
+ buildService([auditRow()], { archiveEnabled: false });
+
+ const result = await service.archive(NOW);
+
+ expect(result.archivedRecords).toBe(0);
+ expect(result.batches).toBe(0);
+ expect(repository.createArchive).not.toHaveBeenCalled();
+ expect(repository.deleteAuditLogs).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('read path', () => {
+ it('lists the archived batches of an organization', async () => {
+ buildService([auditRow({ id: 'log-1' })]);
+ await service.archive(NOW);
+
+ const batches = await service.listArchives('org-1');
+
+ expect(batches).toHaveLength(1);
+ expect(batches[0].recordCount).toBe(1);
+ expect(batches[0].organizationId).toBe('org-1');
+ });
+
+ it('never lists the batches of another organization', async () => {
+ buildService([auditRow({ id: 'log-1', organizationId: 'org-1' })]);
+ await service.archive(NOW);
+
+ const batches = await service.listArchives('org-2');
+
+ expect(batches).toHaveLength(0);
+ });
+
+ it('reads the records of an archived batch back', async () => {
+ buildService([auditRow({ id: 'log-1' }), auditRow({ id: 'log-2' })]);
+ await service.archive(NOW);
+
+ const records = await service.readArchive(
+ 'org-1',
+ batchIdFor([auditRow({ id: 'log-1' }), auditRow({ id: 'log-2' })]),
+ );
+
+ expect(records).toHaveLength(2);
+ expect(records[0].id).toBe('log-1');
+ expect(records[0].action).toBe('TRANSFER_FUNDS');
+ // The dates survive the round trip through the compressed payload.
+ expect(records[0].createdAt).toBeInstanceOf(Date);
+ });
+
+ it('refuses to read a batch that belongs to another organization', async () => {
+ buildService([auditRow({ id: 'log-1' })]);
+ await service.archive(NOW);
+
+ await expect(
+ service.readArchive('org-2', batchIdFor([auditRow({ id: 'log-1' })])),
+ ).rejects.toThrow(/was not found/);
+ });
+
+ it('refuses to read a batch that fails its checksum', async () => {
+ buildService([auditRow({ id: 'log-1' })]);
+ await service.archive(NOW);
+
+ const stored = [...repository.archives.values()][0] as { checksum: string };
+ stored.checksum = 'tampered';
+
+ await expect(
+ service.readArchive('org-1', batchIdFor([auditRow({ id: 'log-1' })])),
+ ).rejects.toThrow(/checksum/);
+ });
+ });
+
+ describe('batchIdFor', () => {
+ it('is stable regardless of the order of the records', () => {
+ const first = batchIdFor([auditRow({ id: 'a' }), auditRow({ id: 'b' })]);
+ const second = batchIdFor([auditRow({ id: 'b' }), auditRow({ id: 'a' })]);
+ expect(first).toBe(second);
+ });
+
+ it('differs for a different set of records', () => {
+ expect(batchIdFor([auditRow({ id: 'a' })])).not.toBe(batchIdFor([auditRow({ id: 'b' })]));
+ });
+ });
+});
diff --git a/src/modules/audit/tests/audit-archive.worker.spec.ts b/src/modules/audit/tests/audit-archive.worker.spec.ts
new file mode 100644
index 00000000..c3712d99
--- /dev/null
+++ b/src/modules/audit/tests/audit-archive.worker.spec.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { Job } from 'bullmq';
+import { AuditArchiveWorker } from '../queues/audit-archive.worker';
+import { AuditArchiveService } from '../audit-archive.service';
+import { WorkerMetricsService } from '../../metrics/worker-metrics.service';
+import { Queues } from '../../../queues/queues.constants';
+
+describe('AuditArchiveWorker', () => {
+ let worker: AuditArchiveWorker;
+ let archive: { archive: ReturnType };
+ let instrumentJob: ReturnType;
+
+ beforeEach(() => {
+ archive = { archive: vi.fn().mockResolvedValue({ archivedRecords: 3, deletedRecords: 3 }) };
+ instrumentJob = vi.fn(async (_queue: string, _job: string, fn: () => Promise) => fn());
+ worker = new AuditArchiveWorker(
+ archive as unknown as AuditArchiveService,
+ { instrumentJob } as unknown as WorkerMetricsService,
+ );
+ });
+
+ it('runs the archival', async () => {
+ await worker.process({ id: 'job-1', name: 'audit-archive', data: {} } as Job);
+
+ expect(archive.archive).toHaveBeenCalledTimes(1);
+ });
+
+ it('instruments the job so its duration and outcome are measured', async () => {
+ await worker.process({ id: 'job-1', name: 'audit-archive', data: {} } as Job);
+
+ expect(instrumentJob).toHaveBeenCalledWith(
+ Queues.AuditArchive,
+ 'audit-archive',
+ expect.any(Function),
+ );
+ });
+
+ it('rethrows a failed archival so the queue can retry it', async () => {
+ archive.archive.mockRejectedValue(new Error('archive write failed'));
+
+ await expect(
+ worker.process({ id: 'job-1', name: 'audit-archive', data: {} } as Job),
+ ).rejects.toThrow('archive write failed');
+ });
+});
diff --git a/src/modules/budgets/budget.service-lock.spec.ts b/src/modules/budgets/budget.service-lock.spec.ts
index 5d97ff4f..955d93e2 100644
--- a/src/modules/budgets/budget.service-lock.spec.ts
+++ b/src/modules/budgets/budget.service-lock.spec.ts
@@ -29,6 +29,7 @@ function mockBudget(
spent: number;
name: string;
enabled: boolean;
+ currency: string;
}> = {},
): Budget {
return {
@@ -37,7 +38,7 @@ function mockBudget(
parentBudgetId: overrides.parentBudgetId ?? 'budget-parent-1',
agentId: null,
name: overrides.name ?? 'Child Budget',
- currency: 'USDC',
+ currency: overrides.currency ?? 'USDC',
limitAmount: new Decimal(overrides.limitAmount ?? 5000),
spent: new Decimal(overrides.spent ?? 0),
period: 'MONTHLY',
@@ -307,6 +308,56 @@ describe('BudgetService — distributed locking', () => {
expect(lockReleased).toBe(true);
});
});
+
+ describe('assertWithinBudget — asset and state guards', () => {
+ it('accepts a transfer whose asset matches the budget currency', async () => {
+ const budget = mockBudget({ id: 'budget-1', currency: 'USDC', spent: 0, limitAmount: 10000 });
+ vi.mocked(repository.findById).mockResolvedValue(budget);
+
+ await expect(
+ service.assertWithinBudget('org-1', 'budget-1', 500, 'USDC'),
+ ).resolves.toBeDefined();
+ });
+
+ it('accepts a credit asset whose code matches the budget currency', async () => {
+ const budget = mockBudget({ id: 'budget-1', currency: 'USDC', spent: 0, limitAmount: 10000 });
+ vi.mocked(repository.findById).mockResolvedValue(budget);
+
+ await expect(
+ service.assertWithinBudget(
+ 'org-1',
+ 'budget-1',
+ 500,
+ 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ ),
+ ).resolves.toBeDefined();
+ });
+
+ it('fails closed when the transfer asset differs from the budget currency', async () => {
+ const budget = mockBudget({ id: 'budget-1', currency: 'USDC', spent: 0, limitAmount: 10000 });
+ vi.mocked(repository.findById).mockResolvedValue(budget);
+
+ await expect(service.assertWithinBudget('org-1', 'budget-1', 500, 'XLM')).rejects.toThrow(
+ BudgetExceededException,
+ );
+ });
+
+ it('fails closed when the budget is disabled', async () => {
+ const budget = mockBudget({ id: 'budget-1', enabled: false, spent: 0, limitAmount: 10000 });
+ vi.mocked(repository.findById).mockResolvedValue(budget);
+
+ await expect(
+ service.assertWithinBudget('org-1', 'budget-1', 500, 'USDC'),
+ ).rejects.toThrow(BudgetExceededException);
+ });
+
+ it('keeps the legacy behaviour when no asset is provided', async () => {
+ const budget = mockBudget({ id: 'budget-1', currency: 'USDC', spent: 0, limitAmount: 10000 });
+ vi.mocked(repository.findById).mockResolvedValue(budget);
+
+ await expect(service.assertWithinBudget('org-1', 'budget-1', 500)).resolves.toBeDefined();
+ });
+ });
});
diff --git a/src/modules/budgets/budget.service.ts b/src/modules/budgets/budget.service.ts
index 81e5c0ec..4961a702 100644
--- a/src/modules/budgets/budget.service.ts
+++ b/src/modules/budgets/budget.service.ts
@@ -159,13 +159,43 @@ export class BudgetService {
* Pre-flight check used by the transactions pipeline. Throws
* BudgetExceededException when the spend would breach the limit. Does not
* mutate state — call {@link consume} after the payment succeeds.
+ *
+ * A budget is denominated in a single currency, so when the asset of the
+ * transfer is known the budget must be denominated in that very asset.
+ * Comparing an XLM amount against a USDC budget would silently mix units, so
+ * the check fails closed instead. A budget that has been disabled is never
+ * used as headroom either.
*/
- async assertWithinBudget(organizationId: string, budgetId: string, amount: number) {
+ async assertWithinBudget(
+ organizationId: string,
+ budgetId: string,
+ amount: number,
+ asset?: string,
+ ) {
// Lock the budget for the duration of the check-and-consume cycle so
// concurrent callers see a consistent view of the remaining headroom.
const lockKey = `budget:check:${budgetId}`;
return this.redisLock.withLock(lockKey, async () => {
const budget = await this.getOrThrow(organizationId, budgetId);
+
+ if (!budget.enabled) {
+ throw new BudgetExceededException('The selected budget is not active', {
+ budgetId,
+ enabled: false,
+ });
+ }
+
+ if (asset && !this.budgetMatchesAsset(budget, asset)) {
+ throw new BudgetExceededException(
+ 'The selected budget is denominated in a different asset than the transfer',
+ {
+ budgetId,
+ budgetCurrency: budget.currency,
+ asset,
+ },
+ );
+ }
+
const spendAfter = new Decimal(budget.spent).plus(amount);
if (spendAfter.greaterThan(budget.limitAmount)) {
await this.eventBus.emit(
@@ -184,6 +214,17 @@ export class BudgetService {
});
}
+ /**
+ * True when the budget's currency matches the asset of the transfer. Only
+ * the asset code is compared, so `USDC` and `USDC:GA5Z…` both match a
+ * `USDC` budget.
+ */
+ private budgetMatchesAsset(budget: Budget, asset: string): boolean {
+ const separator = asset.indexOf(':');
+ const code = (separator === -1 ? asset : asset.slice(0, separator)).trim().toUpperCase();
+ return budget.currency.trim().toUpperCase() === code;
+ }
+
/** Records realised spend after a payment completes; emits warnings near cap. */
/**
* Atomically reserves (deducts) budget by incrementing spent.
diff --git a/src/modules/metrics/metrics.service.ts b/src/modules/metrics/metrics.service.ts
index 66be1709..ffd9278d 100644
--- a/src/modules/metrics/metrics.service.ts
+++ b/src/modules/metrics/metrics.service.ts
@@ -52,6 +52,13 @@ export class MetricsService implements OnModuleDestroy {
registers: [this.registry],
});
+ private readonly auditArchivedRecordsTotal = new Counter({
+ name: 'audit_archived_records_total',
+ help: 'Total audit log records moved out of the active table into the compressed archive',
+ labelNames: ['status'],
+ registers: [this.registry],
+ });
+
constructor(private readonly prisma: PrismaService) {
const rConfig = redisConfig();
const connection = {
@@ -93,6 +100,18 @@ export class MetricsService implements OnModuleDestroy {
this.workerJobDurationSeconds.observe(labels, durationSeconds);
}
+ /**
+ * Records the number of audit records the archival job moved into the
+ * compressed archive (`archived`) and removed from the active table
+ * (`deleted`).
+ */
+ public recordAuditArchive(records: number, status: 'archived' | 'deleted' | 'failed'): void {
+ if (records <= 0) {
+ return;
+ }
+ this.auditArchivedRecordsTotal.inc({ status }, records);
+ }
+
private async collectQueueMetrics(): Promise {
for (const [qName, queue] of this.queues.entries()) {
try {
diff --git a/src/modules/policies/asset-registry.service.spec.ts b/src/modules/policies/asset-registry.service.spec.ts
new file mode 100644
index 00000000..95f98ee4
--- /dev/null
+++ b/src/modules/policies/asset-registry.service.spec.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { ConfigService } from '@nestjs/config';
+import { AssetRegistryService } from './asset-registry.service';
+import { ValidationException } from '../../common/exceptions/domain.exception';
+
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+function registry(anchorAssets: Record = {}): AssetRegistryService {
+ const config = {
+ get: vi.fn().mockReturnValue({ anchorAssets }),
+ } as unknown as ConfigService;
+ return new AssetRegistryService(config);
+}
+
+describe('AssetRegistryService', () => {
+ let service: AssetRegistryService;
+
+ beforeEach(() => {
+ service = registry();
+ });
+
+ describe('canonicalize', () => {
+ it('keeps the native asset untouched', () => {
+ expect(service.canonicalize('XLM')).toBe('XLM');
+ });
+
+ it('keeps an explicit credit asset untouched', () => {
+ expect(service.canonicalize(`USDC:${ISSUER}`)).toBe(`USDC:${ISSUER}`);
+ });
+
+ it('expands a configured Stellar Anchor ticker to CODE:ISSUER', () => {
+ const anchored = registry({ USDC: ISSUER });
+ expect(anchored.canonicalize('USDC')).toBe(`USDC:${ISSUER}`);
+ expect(anchored.canonicalize('usdc')).toBe(`USDC:${ISSUER}`);
+ });
+
+ it('keeps an unknown ticker as-is so the network client reports it', () => {
+ expect(service.canonicalize('FOO')).toBe('FOO');
+ });
+
+ it('rejects a malformed identifier with a validation error', () => {
+ expect(() => service.canonicalize('USDC:bad-issuer')).toThrow(ValidationException);
+ });
+
+ it('rejects an empty identifier with a validation error', () => {
+ expect(() => service.canonicalize('')).toThrow(ValidationException);
+ expect(() => service.canonicalize(' ')).toThrow(ValidationException);
+ });
+
+ it('rejects an asset code longer than the Stellar maximum', () => {
+ expect(() => service.canonicalize('ABCDEFGHIJKLM')).toThrow(ValidationException);
+ });
+
+ it('rejects the native asset with an issuer', () => {
+ expect(() => service.canonicalize(`XLM:${ISSUER}`)).toThrow(ValidationException);
+ });
+ });
+
+ describe('isAnchorAsset / issuerFor', () => {
+ it('reports whether a ticker is a configured anchor', () => {
+ const anchored = registry({ USDC: ISSUER });
+ expect(anchored.isAnchorAsset('USDC')).toBe(true);
+ expect(anchored.isAnchorAsset('EURC')).toBe(false);
+ expect(anchored.isAnchorAsset('XLM')).toBe(false);
+ });
+
+ it('resolves the issuer of a configured anchor', () => {
+ const anchored = registry({ USDC: ISSUER });
+ expect(anchored.issuerFor('usdc')).toBe(ISSUER);
+ expect(anchored.issuerFor('EURC')).toBeUndefined();
+ });
+ });
+});
diff --git a/src/modules/policies/asset-registry.service.ts b/src/modules/policies/asset-registry.service.ts
new file mode 100644
index 00000000..76bc9804
--- /dev/null
+++ b/src/modules/policies/asset-registry.service.ts
@@ -0,0 +1,72 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { AssetIdentifier, NATIVE_ASSET_CODE, parseAssetIdentifier } from '../../utils/asset.util';
+import { ValidationException } from '../../common/exceptions/domain.exception';
+import { StellarConfig } from '../../config/stellar.config';
+
+/**
+ * Resolves the assets a request refers to into canonical Stellar identifiers.
+ *
+ * The registry is the single place that knows which Stellar Anchor assets the
+ * deployment supports (see `STELLAR_ANCHOR_ASSETS`). A bare ticker that is
+ * configured as an anchor is expanded to the canonical `CODE:ISSUER` form the
+ * network understands, so a request for `USDC` and a request for
+ * `USDC:GA5Z…` describe the very same asset and aggregate together.
+ *
+ * Unknown tickers are *not* rejected here: the API has always accepted any
+ * asset string and the network client produces the authoritative error for
+ * assets that do not exist. Rejecting them centrally would change the
+ * behaviour of existing single-asset requests.
+ */
+@Injectable()
+export class AssetRegistryService {
+ constructor(private readonly config: ConfigService) {}
+
+ /** The configured Stellar Anchor assets, keyed by upper-case ticker. */
+ get anchorAssets(): Record {
+ return this.config.get('stellar')?.anchorAssets ?? {};
+ }
+
+ /**
+ * Parses a submitted asset string into its descriptor.
+ *
+ * @throws ValidationException when the identifier is malformed.
+ */
+ describe(raw: string): AssetIdentifier {
+ let parsed: AssetIdentifier;
+ try {
+ parsed = parseAssetIdentifier(raw);
+ } catch (error) {
+ throw new ValidationException((error as Error).message, { asset: raw });
+ }
+ return parsed;
+ }
+
+ /**
+ * Returns the canonical identifier used for persistence and aggregation:
+ * `XLM` for the native asset, `CODE:ISSUER` for a credit asset whose issuer
+ * is known, and the bare `CODE` otherwise.
+ */
+ canonicalize(raw: string): string {
+ const parsed = this.describe(raw);
+ if (parsed.native) {
+ return NATIVE_ASSET_CODE;
+ }
+ if (parsed.issuer) {
+ return parsed.canonical;
+ }
+ const issuer = this.anchorAssets[parsed.code];
+ return issuer ? `${parsed.code}:${issuer}` : parsed.code;
+ }
+
+ /** True when the asset code refers to a configured Stellar Anchor asset. */
+ isAnchorAsset(raw: string): boolean {
+ const parsed = this.describe(raw);
+ return !parsed.native && Boolean(this.anchorAssets[parsed.code]);
+ }
+
+ /** The issuer of a configured anchor asset, if the code is one. */
+ issuerFor(code: string): string | undefined {
+ return this.anchorAssets[code.toUpperCase()];
+ }
+}
diff --git a/src/modules/policies/index.ts b/src/modules/policies/index.ts
index 1ed451d1..fcdd671c 100644
--- a/src/modules/policies/index.ts
+++ b/src/modules/policies/index.ts
@@ -8,3 +8,6 @@ export * from './policy-override-expired.event';
export * from './services/policy-override-cleanup.service';
export * from './guards/agent-policy.guard';
export * from './decorators/agent-policy.decorator';
+export * from './asset-registry.service';
+export * from './price-oracle.service';
+export * from './transfer-payload';
diff --git a/src/modules/policies/policy.dto.ts b/src/modules/policies/policy.dto.ts
index d95eaf41..c72f5d07 100644
--- a/src/modules/policies/policy.dto.ts
+++ b/src/modules/policies/policy.dto.ts
@@ -2,6 +2,8 @@ import { z } from 'zod';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PolicyType } from '@prisma/client';
import { policyConfigurationSchemaStrict } from './policy.types';
+import { stellarAssetSchema } from '../../common/validators/stellar-asset.schema';
+import { assetTransferSchema } from './transfer-payload';
export const createPolicySchema = z
.object({
@@ -20,16 +22,39 @@ export type CreatePolicyInput = z.infer;
export const updatePolicySchema = createPolicySchema.partial();
export type UpdatePolicyInput = z.infer;
-export const simulatePolicySchema = z.object({
- agentId: z.string().uuid().optional(),
- walletId: z.string().uuid().optional(),
- asset: z.string().min(1),
- amount: z.number().positive(),
- recipientAddress: z.string().min(1),
- spentToday: z.number().nonnegative().optional(),
- spentThisWeek: z.number().nonnegative().optional(),
- spentThisMonth: z.number().nonnegative().optional(),
-});
+export const simulatePolicySchema = z
+ .object({
+ agentId: z.string().uuid().optional(),
+ walletId: z.string().uuid().optional(),
+ asset: stellarAssetSchema.optional(),
+ amount: z.number().positive().optional(),
+ transfers: z.array(assetTransferSchema).min(1).max(10).optional(),
+ recipientAddress: z.string().min(1),
+ spentToday: z.number().nonnegative().optional(),
+ spentThisWeek: z.number().nonnegative().optional(),
+ spentThisMonth: z.number().nonnegative().optional(),
+ })
+ .superRefine((data, ctx) => {
+ const hasTransfers = data.transfers !== undefined;
+ const hasSingle = data.amount !== undefined || data.asset !== undefined;
+
+ if (hasTransfers && hasSingle) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Provide either an asset/amount pair or a transfers array, not both',
+ path: ['transfers'],
+ });
+ return;
+ }
+
+ if (!hasTransfers && data.amount === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'amount is required when transfers is not provided',
+ path: ['amount'],
+ });
+ }
+ });
export type SimulatePolicyInput = z.infer;
diff --git a/src/modules/policies/policy.engine.spec.ts b/src/modules/policies/policy.engine.spec.ts
index 565d882c..b5365fd1 100644
--- a/src/modules/policies/policy.engine.spec.ts
+++ b/src/modules/policies/policy.engine.spec.ts
@@ -87,6 +87,90 @@ describe('PolicyEngine', () => {
expect(result.passed).toBe(true);
});
+ describe('limit boundaries', () => {
+ it('allows an amount that is exactly the maximum', () => {
+ const result = engine.evaluate(baseIntent, [policy({ configuration: { maxAmount: 100 } })]);
+ expect(result.passed).toBe(true);
+ });
+
+ it('rejects an amount that is one unit above the maximum', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 100.01 },
+ [policy({ configuration: { maxAmount: 100 } })],
+ );
+ expect(result.violations[0].code).toBe('MAX_AMOUNT_EXCEEDED');
+ });
+
+ it('allows an amount that is exactly the minimum', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 100 },
+ [policy({ configuration: { minAmount: 100 } })],
+ );
+ expect(result.passed).toBe(true);
+ });
+
+ it('rejects an amount that is one unit below the minimum', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 99.99 },
+ [policy({ configuration: { minAmount: 100 } })],
+ );
+ expect(result.violations[0].code).toBe('MIN_AMOUNT_NOT_MET');
+ });
+
+ it('allows a projected spend that is exactly the daily limit', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 100, spentToday: 900 },
+ [policy({ configuration: { dailyLimit: 1000 } })],
+ );
+ expect(result.passed).toBe(true);
+ });
+
+ it('allows a projected spend that is exactly the weekly limit', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 100, spentThisWeek: 900 },
+ [policy({ configuration: { weeklyLimit: 1000 } })],
+ );
+ expect(result.passed).toBe(true);
+ });
+
+ it('allows a projected spend that is exactly the monthly limit', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 100, spentThisMonth: 900 },
+ [policy({ configuration: { monthlyLimit: 1000 } })],
+ );
+ expect(result.passed).toBe(true);
+ });
+
+ it('allows a leg that is exactly at its own per-asset limit', () => {
+ const result = engine.evaluate(
+ {
+ ...baseIntent,
+ transfers: [
+ { asset: 'XLM', amount: 100 },
+ { asset: 'USDC', amount: 50 },
+ ],
+ },
+ [policy({ configuration: { assetLimits: { USDC: { maxAmount: 50 } } } })],
+ );
+ expect(result.passed).toBe(true);
+ });
+
+ it('rejects the leg that exceeds its own per-asset limit', () => {
+ const result = engine.evaluate(
+ {
+ ...baseIntent,
+ transfers: [
+ { asset: 'XLM', amount: 100 },
+ { asset: 'USDC', amount: 50.01 },
+ ],
+ },
+ [policy({ configuration: { assetLimits: { USDC: { maxAmount: 50 } } } })],
+ );
+ expect(result.violations[0].code).toBe('MAX_AMOUNT_EXCEEDED');
+ expect(result.violations[0].asset).toBe('USDC');
+ });
+ });
+
it('requires approval when configured', () => {
const result = engine.evaluate(baseIntent, [
policy({ configuration: { requiresApproval: true } }),
@@ -196,3 +280,115 @@ describe('PolicyEngine temporary overrides (issue #21)', () => {
expect(engine.evaluate({ ...baseIntent, amount: 200, at }, [p]).passed).toBe(false);
});
});
+
+describe('PolicyEngine multi-asset evaluation (issue #444)', () => {
+ const engine = new PolicyEngine();
+ const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+ const multiAssetIntent: TransactionIntent = {
+ organizationId: 'org-1',
+ agentId: 'agent-1',
+ asset: 'XLM',
+ amount: 10,
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 100 },
+ ],
+ };
+
+ it('passes a valid multi-asset transfer', () => {
+ const result = engine.evaluate(multiAssetIntent, [
+ policy({ configuration: { maxAmount: 1000, dailyLimit: 5000 } }),
+ ]);
+ expect(result.passed).toBe(true);
+ expect(result.violations).toHaveLength(0);
+ });
+
+ it('reports the offending asset in the violation', () => {
+ const result = engine.evaluate(multiAssetIntent, [
+ policy({ configuration: { maxAmount: 50 } }),
+ ]);
+ expect(result.passed).toBe(false);
+ const violation = result.violations.find((v) => v.code === 'MAX_AMOUNT_EXCEEDED');
+ expect(violation?.asset).toBe('USDC');
+ expect(violation?.message).toContain('100 USDC');
+ });
+
+ it('enforces per-asset limits instead of the policy-wide limit', () => {
+ const result = engine.evaluate(multiAssetIntent, [
+ policy({
+ configuration: {
+ maxAmount: 1000,
+ assetLimits: { XLM: { maxAmount: 5 }, USDC: { maxAmount: 500 } },
+ },
+ }),
+ ]);
+ expect(result.passed).toBe(false);
+ const violation = result.violations.find((v) => v.code === 'MAX_AMOUNT_EXCEEDED');
+ expect(violation?.asset).toBe('XLM');
+ });
+
+ it('applies the per-asset daily limit to the spend of that asset only', () => {
+ const result = engine.evaluate(
+ {
+ ...multiAssetIntent,
+ spentByAsset: {
+ XLM: { spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 },
+ USDC: { spentToday: 450, spentThisWeek: 450, spentThisMonth: 450 },
+ },
+ },
+ [policy({ configuration: { assetLimits: { USDC: { dailyLimit: 500 } } } })],
+ );
+ expect(result.passed).toBe(false);
+ const violation = result.violations.find((v) => v.code === 'DAILY_LIMIT_EXCEEDED');
+ expect(violation?.asset).toBe('USDC');
+ expect(violation?.message).toContain('Projected daily spend 550 USDC exceeds limit 500 USDC');
+ });
+
+ it('does not report the same policy-level violation once per leg', () => {
+ const result = engine.evaluate(multiAssetIntent, [
+ policy({ configuration: { emergencyLock: true } }),
+ ]);
+ expect(result.violations).toHaveLength(1);
+ expect(result.violations[0].code).toBe('EMERGENCY_LOCK');
+ });
+
+ it('applies the asset allow list to every leg', () => {
+ const result = engine.evaluate(multiAssetIntent, [
+ policy({ configuration: { allowedAssets: ['XLM'] } }),
+ ]);
+ expect(result.passed).toBe(false);
+ const violation = result.violations.find((v) => v.code === 'ASSET_NOT_ALLOWED');
+ expect(violation?.asset).toBe('USDC');
+ });
+
+ it('matches an issued asset against the ticker of its allow list', () => {
+ const result = engine.evaluate(
+ {
+ ...multiAssetIntent,
+ transfers: [{ asset: `USDC:${ISSUER}`, amount: 100 }],
+ },
+ [policy({ configuration: { allowedAssets: ['USDC'] } })],
+ );
+ expect(result.passed).toBe(true);
+ });
+
+ it('requires approval when a leg reaches the approval threshold', () => {
+ const result = engine.evaluate(multiAssetIntent, [
+ policy({ configuration: { requiresApproval: true, approvalThreshold: 50 } }),
+ ]);
+ expect(result.passed).toBe(true);
+ expect(result.requiresApproval).toBe(true);
+ });
+
+ it('keeps single-asset intents behaving exactly as before', () => {
+ const result = engine.evaluate(
+ { ...baseIntent, amount: 100, spentToday: 950 },
+ [policy({ configuration: { maxAmount: 1000, dailyLimit: 1000 } })],
+ );
+ expect(result.passed).toBe(false);
+ expect(result.violations.map((v) => v.code)).toEqual(['DAILY_LIMIT_EXCEEDED']);
+ expect(result.violations[0].asset).toBe('USDC');
+ });
+});
diff --git a/src/modules/policies/policy.engine.ts b/src/modules/policies/policy.engine.ts
index 4cd55440..2cba3b55 100644
--- a/src/modules/policies/policy.engine.ts
+++ b/src/modules/policies/policy.engine.ts
@@ -1,17 +1,49 @@
import { Injectable } from '@nestjs/common';
import {
+ AssetLimitConfiguration,
+ AssetTransfer,
EvaluablePolicy,
PolicyConfiguration,
PolicyEvaluationResult,
PolicyViolation,
+ SpendPeriods,
TransactionIntent,
} from './policy.types';
+/** Zero spend used when an asset has no recorded spend in any period. */
+const NO_SPEND: SpendPeriods = { spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 };
+
+/**
+ * Upper-case asset code of an identifier, without the issuer. Never throws, so
+ * the engine stays pure even when it is handed an unusual asset string.
+ */
+function assetKey(asset: string): string {
+ const separator = asset.indexOf(':');
+ return (separator === -1 ? asset : asset.slice(0, separator)).trim().toUpperCase();
+}
+
/**
* Pure, deterministic policy evaluation engine. Given a transaction intent and
* the set of applicable policies, it returns every violation and whether human
* approval is required. Contains NO I/O — it is trivially unit-testable and is
* the heart of Astroid's "AI should never spend money freely" guarantee.
+ *
+ * ## Multi-asset evaluation
+ *
+ * An intent carries one or more {@link AssetTransfer} legs. A single-asset
+ * intent is the degenerate case with exactly one leg, so the behaviour of
+ * existing policies is unchanged. For every leg the engine applies:
+ *
+ * - the per-asset limit overrides (`configuration.assetLimits[CODE]`) when the
+ * leg's asset has an entry, otherwise the policy-wide limits;
+ * - the asset allow/block lists;
+ * - the recipient lists (using the leg recipient when it has one);
+ * - the periodic limits, compared against the spend accumulated **for that
+ * asset**.
+ *
+ * Policy-wide rules that are not asset-specific (time window, emergency lock)
+ * are evaluated once per policy so a multi-asset transfer never reports the
+ * same policy-level violation twice.
*/
@Injectable()
export class PolicyEngine {
@@ -29,16 +61,19 @@ export class PolicyEngine {
const violations: PolicyViolation[] = [];
let requiresApproval = false;
const at = intent.at ?? new Date();
+ const legs = this.legsOf(intent);
for (const policy of active) {
const config = policy.configuration;
- violations.push(...this.checkAmount(policy, config, intent, at));
- violations.push(...this.checkAssets(policy, config, intent));
- violations.push(...this.checkRecipients(policy, config, intent));
- violations.push(...this.checkPeriodicLimits(policy, config, intent));
+ for (const leg of legs) {
+ violations.push(...this.checkAmount(policy, config, leg, intent, at));
+ violations.push(...this.checkAssets(policy, config, leg));
+ violations.push(...this.checkRecipients(policy, config, leg, intent));
+ violations.push(...this.checkPeriodicLimits(policy, config, leg, intent));
+ }
violations.push(...this.checkTimeWindow(policy, config, at));
violations.push(...this.checkEmergencyLock(policy, config));
- if (this.demandsApproval(config, intent)) {
+ if (this.demandsApproval(config, legs)) {
requiresApproval = true;
}
}
@@ -52,34 +87,73 @@ export class PolicyEngine {
};
}
+ /** The legs of an intent: the explicit transfers, or the single legacy pair. */
+ private legsOf(intent: TransactionIntent): AssetTransfer[] {
+ if (intent.transfers && intent.transfers.length > 0) {
+ return intent.transfers;
+ }
+ return [
+ {
+ asset: intent.asset,
+ amount: intent.amount,
+ recipientAddress: intent.recipientAddress,
+ },
+ ];
+ }
+
private appliesToAgent(policy: EvaluablePolicy, intent: TransactionIntent): boolean {
// A policy with no agentId is organization-wide; otherwise it must match.
return !policy.agentId || policy.agentId === intent.agentId;
}
+ /** Per-asset limit overrides for a leg, if the policy declares any. */
+ private assetLimitsFor(
+ config: PolicyConfiguration,
+ leg: AssetTransfer,
+ ): AssetLimitConfiguration | undefined {
+ if (!config.assetLimits) {
+ return undefined;
+ }
+ const key = assetKey(leg.asset);
+ return config.assetLimits[key] ?? config.assetLimits[key.toLowerCase()];
+ }
+
+ /** The unit the limits of an evaluation are denominated in. */
+ private unitFor(intent: TransactionIntent, leg: AssetTransfer): string {
+ return intent.limitCurrency ?? assetKey(leg.asset);
+ }
+
private checkAmount(
policy: EvaluablePolicy,
config: PolicyConfiguration,
+ leg: AssetTransfer,
intent: TransactionIntent,
at: Date,
): PolicyViolation[] {
const out: PolicyViolation[] = [];
- const effectiveMax = this.effectiveMaxAmount(policy, config, at);
- if (effectiveMax !== undefined && intent.amount > effectiveMax) {
+ const assetLimits = this.assetLimitsFor(config, leg);
+ const unit = this.unitFor(intent, leg);
+
+ const effectiveMax = this.effectiveMaxAmount(policy, config, assetLimits, at);
+ if (effectiveMax !== undefined && leg.amount > effectiveMax) {
out.push(
this.violation(
policy,
'MAX_AMOUNT_EXCEEDED',
- `Amount ${intent.amount} exceeds maximum ${effectiveMax}`,
+ `Amount ${leg.amount} ${unit} exceeds maximum ${effectiveMax} ${unit}`,
+ leg,
),
);
}
- if (config.minAmount !== undefined && intent.amount < config.minAmount) {
+
+ const effectiveMin = assetLimits?.minAmount ?? config.minAmount;
+ if (effectiveMin !== undefined && leg.amount < effectiveMin) {
out.push(
this.violation(
policy,
'MIN_AMOUNT_NOT_MET',
- `Amount ${intent.amount} is below minimum ${config.minAmount}`,
+ `Amount ${leg.amount} ${unit} is below minimum ${effectiveMin} ${unit}`,
+ leg,
),
);
}
@@ -90,10 +164,12 @@ export class PolicyEngine {
// temporary override is active (overrideLimit set, overrideUntil in the
// future), the overrideLimit wins. Otherwise the engine falls back to the
// recorded originalLimit, or the configured maxAmount when no override was
- // ever captured.
+ // ever captured. A per-asset override takes precedence over the policy-wide
+ // limit, but never over an active spending override.
private effectiveMaxAmount(
policy: EvaluablePolicy,
config: PolicyConfiguration,
+ assetLimits: AssetLimitConfiguration | undefined,
at: Date,
): number | undefined {
const overrideActive =
@@ -103,22 +179,23 @@ export class PolicyEngine {
if (overrideActive) {
return policy.overrideLimit ?? undefined;
}
- return policy.originalLimit ?? config.maxAmount;
+ return assetLimits?.maxAmount ?? policy.originalLimit ?? config.maxAmount;
}
private checkAssets(
policy: EvaluablePolicy,
config: PolicyConfiguration,
- intent: TransactionIntent,
+ leg: AssetTransfer,
): PolicyViolation[] {
const out: PolicyViolation[] = [];
- if (config.allowedAssets && !config.allowedAssets.includes(intent.asset)) {
+ const code = assetKey(leg.asset);
+ if (config.allowedAssets && !config.allowedAssets.some((asset) => asset.toUpperCase() === code)) {
out.push(
- this.violation(policy, 'ASSET_NOT_ALLOWED', `Asset ${intent.asset} is not in the allow list`),
+ this.violation(policy, 'ASSET_NOT_ALLOWED', `Asset ${code} is not in the allow list`, leg),
);
}
- if (config.blockedAssets && config.blockedAssets.includes(intent.asset)) {
- out.push(this.violation(policy, 'ASSET_BLOCKED', `Asset ${intent.asset} is blocked`));
+ if (config.blockedAssets && config.blockedAssets.some((asset) => asset.toUpperCase() === code)) {
+ out.push(this.violation(policy, 'ASSET_BLOCKED', `Asset ${code} is blocked`, leg));
}
return out;
}
@@ -126,21 +203,23 @@ export class PolicyEngine {
private checkRecipients(
policy: EvaluablePolicy,
config: PolicyConfiguration,
+ leg: AssetTransfer,
intent: TransactionIntent,
): PolicyViolation[] {
const out: PolicyViolation[] = [];
- const recipient = intent.recipientAddress;
+ const recipient = leg.recipientAddress ?? intent.recipientAddress;
if (config.allowedRecipients && !config.allowedRecipients.includes(recipient)) {
out.push(
this.violation(
policy,
'RECIPIENT_NOT_ALLOWED',
`Recipient ${recipient} is not in the allow list`,
+ leg,
),
);
}
if (config.blockedRecipients && config.blockedRecipients.includes(recipient)) {
- out.push(this.violation(policy, 'RECIPIENT_BLOCKED', `Recipient ${recipient} is blocked`));
+ out.push(this.violation(policy, 'RECIPIENT_BLOCKED', `Recipient ${recipient} is blocked`, leg));
}
return out;
}
@@ -148,25 +227,41 @@ export class PolicyEngine {
private checkPeriodicLimits(
policy: EvaluablePolicy,
config: PolicyConfiguration,
+ leg: AssetTransfer,
intent: TransactionIntent,
): PolicyViolation[] {
const out: PolicyViolation[] = [];
- const checks: Array<[number | undefined, number | undefined, string, string]> = [
- [config.dailyLimit, intent.spentToday, 'DAILY_LIMIT_EXCEEDED', 'daily'],
- [config.weeklyLimit, intent.spentThisWeek, 'WEEKLY_LIMIT_EXCEEDED', 'weekly'],
- [config.monthlyLimit, intent.spentThisMonth, 'MONTHLY_LIMIT_EXCEEDED', 'monthly'],
+ const assetLimits = this.assetLimitsFor(config, leg);
+ const unit = this.unitFor(intent, leg);
+ const spent = this.spendFor(intent, leg);
+
+ const checks: Array<[number | undefined, number, string, string]> = [
+ [assetLimits?.dailyLimit ?? config.dailyLimit, spent.spentToday, 'DAILY_LIMIT_EXCEEDED', 'daily'],
+ [
+ assetLimits?.weeklyLimit ?? config.weeklyLimit,
+ spent.spentThisWeek,
+ 'WEEKLY_LIMIT_EXCEEDED',
+ 'weekly',
+ ],
+ [
+ assetLimits?.monthlyLimit ?? config.monthlyLimit,
+ spent.spentThisMonth,
+ 'MONTHLY_LIMIT_EXCEEDED',
+ 'monthly',
+ ],
];
- for (const [limit, spent, code, label] of checks) {
+ for (const [limit, spentInPeriod, code, label] of checks) {
if (limit === undefined) {
continue;
}
- const projected = (spent ?? 0) + intent.amount;
+ const projected = spentInPeriod + leg.amount;
if (projected > limit) {
out.push(
this.violation(
policy,
code,
- `Projected ${label} spend ${projected} exceeds limit ${limit}`,
+ `Projected ${label} spend ${projected} ${unit} exceeds limit ${limit} ${unit}`,
+ leg,
),
);
}
@@ -174,6 +269,28 @@ export class PolicyEngine {
return out;
}
+ /** Spend already accumulated for the asset of a leg. */
+ private spendFor(intent: TransactionIntent, leg: AssetTransfer): SpendPeriods {
+ if (intent.spentByAsset) {
+ const direct = intent.spentByAsset[leg.asset];
+ if (direct) return direct;
+ const byCode = intent.spentByAsset[assetKey(leg.asset)];
+ if (byCode) return byCode;
+ return NO_SPEND;
+ }
+ // Without per-asset aggregates the flat period fields describe the primary
+ // asset of the intent; other assets of a multi-asset transfer have not
+ // spent anything yet.
+ if (assetKey(leg.asset) !== assetKey(intent.asset)) {
+ return NO_SPEND;
+ }
+ return {
+ spentToday: intent.spentToday ?? 0,
+ spentThisWeek: intent.spentThisWeek ?? 0,
+ spentThisMonth: intent.spentThisMonth ?? 0,
+ };
+ }
+
private checkTimeWindow(
policy: EvaluablePolicy,
config: PolicyConfiguration,
@@ -215,17 +332,34 @@ export class PolicyEngine {
return [];
}
- private demandsApproval(config: PolicyConfiguration, intent: TransactionIntent): boolean {
+ private demandsApproval(
+ config: PolicyConfiguration,
+ legs: AssetTransfer[],
+ ): boolean {
if (config.requiresApproval) {
return true;
}
- if (config.approvalThreshold !== undefined && intent.amount >= config.approvalThreshold) {
- return true;
+ if (config.approvalThreshold === undefined) {
+ return false;
}
- return false;
+ return legs.some((leg) => leg.amount >= config.approvalThreshold!);
}
- private violation(policy: EvaluablePolicy, code: string, message: string): PolicyViolation {
- return { policyId: policy.id, policyName: policy.name, code, message };
+ private violation(
+ policy: EvaluablePolicy,
+ code: string,
+ message: string,
+ leg?: AssetTransfer,
+ ): PolicyViolation {
+ const violation: PolicyViolation = {
+ policyId: policy.id,
+ policyName: policy.name,
+ code,
+ message,
+ };
+ if (leg) {
+ violation.asset = assetKey(leg.asset);
+ }
+ return violation;
}
}
diff --git a/src/modules/policies/policy.module.ts b/src/modules/policies/policy.module.ts
index 486d9f51..0d9cfcd1 100644
--- a/src/modules/policies/policy.module.ts
+++ b/src/modules/policies/policy.module.ts
@@ -4,6 +4,8 @@ import { PolicyService } from './policy.service';
import { SpendingPolicyService } from './spending-policy.service';
import { SpendingPolicyRepository } from './spending-policy.repository';
import { PolicyEngine } from './policy.engine';
+import { PriceOracleService } from './price-oracle.service';
+import { AssetRegistryService } from './asset-registry.service';
import { PolicyOverrideCleanupService } from './services/policy-override-cleanup.service';
import { AgentPolicyGuard } from './guards/agent-policy.guard';
@@ -22,6 +24,8 @@ import { AgentPolicyGuard } from './guards/agent-policy.guard';
SpendingPolicyService,
SpendingPolicyRepository,
PolicyEngine,
+ PriceOracleService,
+ AssetRegistryService,
PolicyOverrideCleanupService,
AgentPolicyGuard,
],
@@ -29,6 +33,8 @@ import { AgentPolicyGuard } from './guards/agent-policy.guard';
PolicyService,
SpendingPolicyService,
PolicyEngine,
+ PriceOracleService,
+ AssetRegistryService,
PolicyOverrideCleanupService,
AgentPolicyGuard,
],
diff --git a/src/modules/policies/policy.service.evaluation.spec.ts b/src/modules/policies/policy.service.evaluation.spec.ts
new file mode 100644
index 00000000..694481a7
--- /dev/null
+++ b/src/modules/policies/policy.service.evaluation.spec.ts
@@ -0,0 +1,330 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { Policy } from '@prisma/client';
+import { PolicyService } from './policy.service';
+import { PolicyEngine } from './policy.engine';
+import { PriceOracleService, PriceUnavailableException } from './price-oracle.service';
+import { AssetRegistryService } from './asset-registry.service';
+import { SpendingPolicyService } from './spending-policy.service';
+import { EventBusService } from '../../events/event-bus.service';
+
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+function policyRow(overrides: Partial = {}): Policy {
+ return {
+ id: 'policy-1',
+ organizationId: 'org-1',
+ agentId: null,
+ name: 'Spend policy',
+ description: null,
+ type: 'MAX_AMOUNT',
+ configuration: {},
+ priority: 100,
+ enabled: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ overrideLimit: null,
+ overrideUntil: null,
+ originalLimit: null,
+ ...overrides,
+ } as unknown as Policy;
+}
+
+describe('PolicyService evaluation', () => {
+ let service: PolicyService;
+ let listActiveForEvaluation: ReturnType;
+ let convert: ReturnType;
+ let emit: ReturnType;
+
+ beforeEach(() => {
+ listActiveForEvaluation = vi.fn().mockResolvedValue([]);
+ convert = vi.fn().mockImplementation(async (amount: number) => amount);
+ emit = vi.fn().mockResolvedValue(undefined);
+
+ service = new PolicyService(
+ { listActiveForEvaluation } as unknown as SpendingPolicyService,
+ new PolicyEngine(),
+ { emit } as unknown as EventBusService,
+ { convert } as unknown as PriceOracleService,
+ { canonicalize: (asset: string) => asset } as unknown as AssetRegistryService,
+ );
+ });
+
+ it('passes a valid single-asset intent when no policy applies', async () => {
+ const result = await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 10,
+ recipientAddress: 'GABC',
+ });
+
+ expect(result.passed).toBe(true);
+ expect(result.violations).toHaveLength(0);
+ });
+
+ it('evaluates every leg of a multi-asset intent', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { assetLimits: { USDC: { maxAmount: 50 } } } }),
+ ]);
+
+ const result = await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 10,
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 100 },
+ ],
+ });
+
+ expect(result.passed).toBe(false);
+ expect(result.violations).toHaveLength(1);
+ expect(result.violations[0].code).toBe('MAX_AMOUNT_EXCEEDED');
+ expect(result.violations[0].asset).toBe('USDC');
+ });
+
+ it('converts the amounts when the policy denominates its limits in a base currency', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { baseCurrency: 'USD', maxAmount: 50 } }),
+ ]);
+ convert.mockImplementation(async (amount: number) => amount * 0.5);
+
+ const result = await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 100,
+ recipientAddress: 'GABC',
+ });
+
+ // 100 XLM → 50 USD, which is exactly at the limit and therefore allowed.
+ expect(convert).toHaveBeenCalledWith(100, 'XLM', 'USD');
+ expect(result.passed).toBe(true);
+ });
+
+ it('rejects a transfer whose converted amount exceeds the base-currency limit', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { baseCurrency: 'USD', maxAmount: 40 } }),
+ ]);
+ convert.mockImplementation(async (amount: number) => amount * 0.5);
+
+ const result = await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 100,
+ recipientAddress: 'GABC',
+ });
+
+ expect(result.passed).toBe(false);
+ expect(result.violations[0].code).toBe('MAX_AMOUNT_EXCEEDED');
+ expect(result.violations[0].message).toContain('50 USD');
+ });
+
+ it('never converts an amount when no policy declares a base currency', async () => {
+ listActiveForEvaluation.mockResolvedValue([policyRow({ configuration: { maxAmount: 50 } })]);
+
+ await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 100,
+ recipientAddress: 'GABC',
+ });
+
+ expect(convert).not.toHaveBeenCalled();
+ });
+
+ it('fails closed when the price required for a base-currency limit is unavailable', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { baseCurrency: 'USD', maxAmount: 50 } }),
+ ]);
+ convert.mockRejectedValue(
+ new PriceUnavailableException('unavailable', 'XLM', 'USD', 'no quote'),
+ );
+
+ await expect(
+ service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 100,
+ recipientAddress: 'GABC',
+ }),
+ ).rejects.toBeInstanceOf(PriceUnavailableException);
+ });
+
+ it('evaluates policies with different base currencies independently', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ id: 'policy-usd', configuration: { baseCurrency: 'USD', maxAmount: 10 } }),
+ policyRow({ id: 'policy-xlm', configuration: { maxAmount: 1000 } }),
+ ]);
+ convert.mockImplementation(async (amount: number) => amount * 0.5);
+
+ const result = await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 100,
+ recipientAddress: 'GABC',
+ });
+
+ expect(result.passed).toBe(false);
+ expect(result.violations).toHaveLength(1);
+ expect(result.violations[0].policyId).toBe('policy-usd');
+ expect(result.evaluatedPolicyIds).toEqual(['policy-usd', 'policy-xlm']);
+ });
+
+ it('emits the policy evaluation event with the evaluated transfers', async () => {
+ listActiveForEvaluation.mockResolvedValue([policyRow({ configuration: { maxAmount: 1000 } })]);
+
+ await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 10,
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: `USDC:${ISSUER}`, amount: 20 },
+ ],
+ });
+
+ expect(emit).toHaveBeenCalledWith(
+ 'policy.evaluated',
+ expect.objectContaining({
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: `USDC:${ISSUER}`, amount: 20 },
+ ],
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('converts every leg of a multi-asset transfer into the base currency', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { baseCurrency: 'USD', maxAmount: 1000 } }),
+ ]);
+ convert.mockImplementation(async (amount: number) => amount * 0.5);
+
+ const result = await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 100,
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: 100 },
+ { asset: 'USDC', amount: 200 },
+ ],
+ });
+
+ expect(convert).toHaveBeenCalledWith(100, 'XLM', 'USD');
+ expect(convert).toHaveBeenCalledWith(200, 'USDC', 'USD');
+ expect(result.passed).toBe(true);
+ });
+
+ it('converts the spend already accumulated for every asset', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { baseCurrency: 'USD', dailyLimit: 1000 } }),
+ ]);
+ convert.mockImplementation(async (amount: number) => amount * 0.5);
+
+ await service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 10,
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 20 },
+ ],
+ spentByAsset: {
+ XLM: { spentToday: 100, spentThisWeek: 100, spentThisMonth: 100 },
+ USDC: { spentToday: 200, spentThisWeek: 200, spentThisMonth: 200 },
+ },
+ });
+
+ expect(convert).toHaveBeenCalledWith(100, 'XLM', 'USD');
+ expect(convert).toHaveBeenCalledWith(200, 'USDC', 'USD');
+ });
+
+ it('fails closed when the price of one leg of a multi-asset transfer is unavailable', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { baseCurrency: 'USD', maxAmount: 1000 } }),
+ ]);
+ convert.mockImplementation(async (amount: number, asset: string) => {
+ if (asset === 'USDC') {
+ throw new PriceUnavailableException('unavailable', 'USDC', 'USD', 'no quote');
+ }
+ return amount;
+ });
+
+ await expect(
+ service.evaluateIntent({
+ organizationId: 'org-1',
+ asset: 'XLM',
+ amount: 10,
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 20 },
+ ],
+ }),
+ ).rejects.toBeInstanceOf(PriceUnavailableException);
+ });
+});
+
+describe('PolicyService simulate', () => {
+ let service: PolicyService;
+ let listActiveForEvaluation: ReturnType;
+
+ beforeEach(() => {
+ listActiveForEvaluation = vi.fn().mockResolvedValue([]);
+ service = new PolicyService(
+ { listActiveForEvaluation } as unknown as SpendingPolicyService,
+ new PolicyEngine(),
+ { emit: vi.fn().mockResolvedValue(undefined) } as unknown as EventBusService,
+ { convert: vi.fn(async (amount: number) => amount) } as unknown as PriceOracleService,
+ {
+ canonicalize: (asset: string) => (asset === 'USDC' ? `USDC:${ISSUER}` : asset),
+ } as unknown as AssetRegistryService,
+ );
+ });
+
+ it('canonicalizes every leg of a multi-asset payload', async () => {
+ const result = await service.simulate('org-1', {
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: '10' },
+ { asset: 'USDC', amount: '20' },
+ ],
+ });
+
+ expect(result.passed).toBe(true);
+ expect(listActiveForEvaluation).toHaveBeenCalled();
+ });
+
+ it('falls back to the first leg when the payload has no top-level amount', async () => {
+ const result = await service.simulate('org-1', {
+ recipientAddress: 'GABC',
+ transfers: [{ asset: 'USDC', amount: '20' }],
+ });
+
+ expect(result.passed).toBe(true);
+ });
+
+ it('reports the violations of the simulated policies', async () => {
+ listActiveForEvaluation.mockResolvedValue([
+ policyRow({ configuration: { assetLimits: { USDC: { maxAmount: 10 } } } }),
+ ]);
+
+ const result = await service.simulate('org-1', {
+ recipientAddress: 'GABC',
+ transfers: [
+ { asset: 'XLM', amount: '5' },
+ { asset: 'USDC', amount: '50' },
+ ],
+ });
+
+ expect(result.passed).toBe(false);
+ expect(result.violations).toHaveLength(1);
+ expect(result.violations[0].code).toBe('MAX_AMOUNT_EXCEEDED');
+ expect(result.violations[0].asset).toBe('USDC');
+ });
+});
diff --git a/src/modules/policies/policy.service.spec.ts b/src/modules/policies/policy.service.spec.ts
index 4a8e1437..bce4c92a 100644
--- a/src/modules/policies/policy.service.spec.ts
+++ b/src/modules/policies/policy.service.spec.ts
@@ -11,6 +11,8 @@ describe('PolicyService velocity limit delegation', () => {
{ checkVelocityLimit } as never,
{} as never,
{ emit: vi.fn() } as never,
+ { convert: vi.fn(), getQuote: vi.fn() } as never,
+ { canonicalize: (asset: string) => asset } as never,
);
});
diff --git a/src/modules/policies/policy.service.ts b/src/modules/policies/policy.service.ts
index e88b5f4f..932c1a6e 100644
--- a/src/modules/policies/policy.service.ts
+++ b/src/modules/policies/policy.service.ts
@@ -2,12 +2,24 @@ import { Injectable } from '@nestjs/common';
import { Policy } from '@prisma/client';
import { SpendingPolicyService } from './spending-policy.service';
import { PolicyEngine } from './policy.engine';
+import { PriceOracleService } from './price-oracle.service';
+import { AssetRegistryService } from './asset-registry.service';
import { CreatePolicyInput, SimulatePolicyInput, UpdatePolicyInput } from './policy.dto';
-import { EvaluablePolicy, PolicyConfiguration, PolicyEvaluationResult, TransactionIntent } from './policy.types';
+import {
+ EvaluablePolicy,
+ PolicyConfiguration,
+ PolicyEvaluationResult,
+ PolicyViolation,
+ SpendPeriods,
+ TransactionIntent,
+} from './policy.types';
import { PaginationQuery } from '../../common/helpers/pagination';
import { EventBusService } from '../../events/event-bus.service';
import { DomainEventName } from '../../events/event-names';
+/** Policies that do not declare a base currency share the "no conversion" group. */
+const NO_BASE_CURRENCY = '';
+
/**
* Controller-facing façade over the policy domain. Delegates persistence and
* spending-policy enforcement to {@link SpendingPolicyService} and wraps every
@@ -19,6 +31,8 @@ export class PolicyService {
private readonly spendingPolicyService: SpendingPolicyService,
private readonly engine: PolicyEngine,
private readonly eventBus: EventBusService,
+ private readonly priceOracle: PriceOracleService,
+ private readonly assetRegistry: AssetRegistryService,
) {}
async create(organizationId: string, actorId: string, input: CreatePolicyInput) {
@@ -63,6 +77,13 @@ export class PolicyService {
* Evaluates an intent against all applicable stored policies. Emits a
* PolicyEvaluated event (and PolicyViolated on failure) for the ledger.
* Also persists an audit log entry for compliance tracking.
+ *
+ * Policies may denominate their limits in a base currency (`baseCurrency`).
+ * In that case the transfer amounts — and the spend already accumulated for
+ * the asset — are converted into that currency before the comparison. A
+ * missing or stale price aborts the evaluation with a
+ * {@link PriceUnavailableException} so a limit can never be bypassed by
+ * treating an unknown price as zero.
*/
async evaluateIntent(
intent: TransactionIntent,
@@ -72,7 +93,7 @@ export class PolicyService {
intent.organizationId,
intent.agentId,
);
- const result = this.engine.evaluate(intent, policies.map(toEvaluable));
+ const result = await this.evaluateAgainstPolicies(intent, policies);
await this.eventBus.emit(
DomainEventName.PolicyEvaluated,
@@ -83,6 +104,7 @@ export class PolicyService {
amount: intent.amount,
asset: intent.asset,
recipientAddress: intent.recipientAddress,
+ transfers: intent.transfers?.map((leg) => ({ asset: leg.asset, amount: leg.amount })),
},
{
organizationId: intent.organizationId,
@@ -114,19 +136,29 @@ export class PolicyService {
/** POST /policies/simulate — dry run without creating a transaction. */
async simulate(organizationId: string, input: SimulatePolicyInput) {
+ const transfers = input.transfers?.map((leg) => ({
+ asset: this.assetRegistry.canonicalize(leg.asset),
+ amount: Number(leg.amount),
+ }));
const intent: TransactionIntent = {
organizationId,
agentId: input.agentId,
walletId: input.walletId,
- asset: input.asset,
- amount: input.amount,
+ asset: input.asset
+ ? this.assetRegistry.canonicalize(input.asset)
+ : transfers?.[0]?.asset ?? 'XLM',
+ amount: input.amount ?? transfers?.[0]?.amount ?? 0,
recipientAddress: input.recipientAddress,
spentToday: input.spentToday,
spentThisWeek: input.spentThisWeek,
spentThisMonth: input.spentThisMonth,
+ ...(transfers ? { transfers } : {}),
};
- const policies = await this.spendingPolicyService.listActiveForEvaluation(organizationId, input.agentId);
- const result = this.engine.evaluate(intent, policies.map(toEvaluable));
+ const policies = await this.spendingPolicyService.listActiveForEvaluation(
+ organizationId,
+ input.agentId,
+ );
+ const result = await this.evaluateAgainstPolicies(intent, policies);
return {
passed: result.passed,
requiresApproval: result.requiresApproval,
@@ -151,6 +183,145 @@ export class PolicyService {
): Promise {
return this.spendingPolicyService.checkVelocityLimit(agentId, amount, assetCode);
}
+
+ // ── internals ──────────────────────────────────────────────────────────
+
+ /**
+ * Evaluates an intent against the supplied policies.
+ *
+ * Policies are grouped by the currency their limits are denominated in. Each
+ * group is evaluated with an intent whose amounts were converted into that
+ * currency, so the pure engine only ever compares values in the same unit.
+ */
+ private async evaluateAgainstPolicies(
+ intent: TransactionIntent,
+ policies: Policy[],
+ ): Promise {
+ const evaluable = policies.map(toEvaluable);
+ const groups = this.groupByLimitCurrency(evaluable);
+
+ const violations: PolicyViolation[] = [];
+ const evaluated: Array<{ id: string; index: number }> = [];
+ let requiresApproval = false;
+ let matchedPolicyId: string | undefined;
+
+ for (const [currency, group] of groups) {
+ const prepared = await this.prepareIntent(intent, currency);
+ const result = this.engine.evaluate(prepared, group);
+ violations.push(...result.violations);
+ for (const id of result.evaluatedPolicyIds) {
+ const index = evaluable.findIndex((policy) => policy.id === id);
+ evaluated.push({ id, index: index === -1 ? Number.MAX_SAFE_INTEGER : index });
+ }
+ requiresApproval = requiresApproval || result.requiresApproval;
+ matchedPolicyId = matchedPolicyId ?? result.matchedPolicyId;
+ }
+
+ evaluated.sort((left, right) => left.index - right.index);
+
+ return {
+ passed: violations.length === 0,
+ requiresApproval,
+ violations,
+ evaluatedPolicyIds: evaluated.map((entry) => entry.id),
+ matchedPolicyId,
+ };
+ }
+
+ /** Groups policies by the currency their limits are denominated in. */
+ private groupByLimitCurrency(policies: EvaluablePolicy[]): Map {
+ const groups = new Map();
+ for (const policy of policies) {
+ const currency = (policy.configuration.baseCurrency ?? NO_BASE_CURRENCY).toUpperCase();
+ const group = groups.get(currency);
+ if (group) {
+ group.push(policy);
+ } else {
+ groups.set(currency, [policy]);
+ }
+ }
+ return groups;
+ }
+
+ /**
+ * Returns the intent with its amounts expressed in the limit currency.
+ *
+ * Without a base currency the intent is returned untouched, so policies that
+ * do not opt into conversion keep their previous behaviour exactly.
+ */
+ private async prepareIntent(
+ intent: TransactionIntent,
+ currency: string,
+ ): Promise {
+ if (!currency) {
+ return intent;
+ }
+
+ const transfers = intent.transfers
+ ? await Promise.all(
+ intent.transfers.map(async (leg) => ({
+ ...leg,
+ amount: await this.priceOracle.convert(leg.amount, leg.asset, currency),
+ })),
+ )
+ : undefined;
+
+ const amount =
+ transfers === undefined
+ ? await this.priceOracle.convert(intent.amount, intent.asset, currency)
+ : intent.amount;
+
+ const spentByAsset = intent.spentByAsset
+ ? await this.convertSpendByAsset(intent.spentByAsset, currency)
+ : undefined;
+
+ const flatSpend =
+ intent.spentToday === undefined &&
+ intent.spentThisWeek === undefined &&
+ intent.spentThisMonth === undefined
+ ? undefined
+ : {
+ spentToday:
+ intent.spentToday === undefined
+ ? undefined
+ : await this.priceOracle.convert(intent.spentToday, intent.asset, currency),
+ spentThisWeek:
+ intent.spentThisWeek === undefined
+ ? undefined
+ : await this.priceOracle.convert(intent.spentThisWeek, intent.asset, currency),
+ spentThisMonth:
+ intent.spentThisMonth === undefined
+ ? undefined
+ : await this.priceOracle.convert(intent.spentThisMonth, intent.asset, currency),
+ };
+
+ return {
+ ...intent,
+ amount,
+ ...(transfers ? { transfers } : {}),
+ ...(spentByAsset ? { spentByAsset } : {}),
+ ...(flatSpend ? { ...flatSpend } : {}),
+ limitCurrency: currency,
+ };
+ }
+
+ /** Converts the per-asset spend aggregates into the limit currency. */
+ private async convertSpendByAsset(
+ spentByAsset: Record,
+ currency: string,
+ ): Promise> {
+ const entries = await Promise.all(
+ Object.entries(spentByAsset).map(async ([asset, spend]) => {
+ const converted: SpendPeriods = {
+ spentToday: await this.priceOracle.convert(spend.spentToday, asset, currency),
+ spentThisWeek: await this.priceOracle.convert(spend.spentThisWeek, asset, currency),
+ spentThisMonth: await this.priceOracle.convert(spend.spentThisMonth, asset, currency),
+ };
+ return [asset, converted] as const;
+ }),
+ );
+ return Object.fromEntries(entries);
+ }
}
/** Projects a Prisma Policy into the engine's decoupled EvaluablePolicy shape. */
diff --git a/src/modules/policies/policy.types.ts b/src/modules/policies/policy.types.ts
index e2d53362..6eae56ed 100644
--- a/src/modules/policies/policy.types.ts
+++ b/src/modules/policies/policy.types.ts
@@ -1,5 +1,24 @@
import { z } from 'zod';
+/**
+ * Per-asset limit overrides. A policy may declare tighter (or looser) limits
+ * for a specific asset than the policy-wide limits. Keys are upper-case asset
+ * codes (`XLM`, `USDC`); an issued asset (`USDC:GA5Z…`) matches the `USDC`
+ * entry because only the code is compared.
+ */
+export const assetLimitSchema = z
+ .object({
+ maxAmount: z.number().nonnegative().optional(),
+ minAmount: z.number().nonnegative().optional(),
+ dailyLimit: z.number().nonnegative().optional(),
+ weeklyLimit: z.number().nonnegative().optional(),
+ monthlyLimit: z.number().nonnegative().optional(),
+ })
+ .strict();
+
+/** Map of upper-case asset code to its limit overrides. */
+export const assetLimitsSchema = z.record(z.string().min(1).max(12), assetLimitSchema);
+
/**
* The JSONB `configuration` shape stored on a Policy. A single flexible object
* lets us support many policy types (max/min amount, asset allow/block,
@@ -32,6 +51,14 @@ export const policyConfigurationSchema = z
approvalThreshold: z.number().nonnegative().optional(),
/** When true, blocks ALL spending (emergency lock / kill switch). */
emergencyLock: z.boolean().optional(),
+ /** Per-asset limit overrides keyed by the upper-case asset code. */
+ assetLimits: assetLimitsSchema.optional(),
+ /**
+ * Base currency the amount limits above are denominated in (e.g. `USD`).
+ * When set, transfer amounts are converted into this currency before they
+ * are compared with the limits.
+ */
+ baseCurrency: z.string().min(1).max(12).optional(),
})
.strict();
@@ -72,6 +99,14 @@ export const policyConfigurationSchemaStrict = z
approvalThreshold: z.number().nonnegative().optional(),
/** When true, blocks ALL spending (emergency lock / kill switch). */
emergencyLock: z.boolean().optional(),
+ /** Per-asset limit overrides keyed by the upper-case asset code. */
+ assetLimits: assetLimitsSchema.optional(),
+ /**
+ * Base currency the amount limits above are denominated in (e.g. `USD`).
+ * When set, transfer amounts are converted into this currency before they
+ * are compared with the limits.
+ */
+ baseCurrency: z.string().min(1).max(12).optional(),
})
.strict()
.refine(
@@ -119,10 +154,49 @@ export const policyConfigurationSchemaStrict = z
return true;
},
{ message: 'approvalThreshold requires requiresApproval to be true' },
+ )
+ .refine(
+ (data) => {
+ // A per-asset limit must not contradict itself either.
+ if (!data.assetLimits) return true;
+ return Object.values(data.assetLimits).every((limits) => {
+ if (limits.maxAmount !== undefined && limits.minAmount !== undefined) {
+ return limits.maxAmount > limits.minAmount;
+ }
+ return true;
+ });
+ },
+ { message: 'assetLimits[].maxAmount must be greater than assetLimits[].minAmount when both are specified' },
);
export type PolicyConfiguration = z.infer;
+/** Per-asset limit overrides stored on a policy. */
+export type AssetLimitConfiguration = z.infer;
+
+/**
+ * One asset leg of a transaction. A single-asset transaction has exactly one
+ * leg; a multi-asset transfer has one leg per asset.
+ */
+export interface AssetTransfer {
+ /** Canonical asset identifier of the leg (`XLM` or `CODE:ISSUER`). */
+ asset: string;
+ /**
+ * Amount of the leg, expressed in the unit the limits are denominated in
+ * (the asset's own unit, or the base currency when the policy declares one).
+ */
+ amount: number;
+ /** Optional per-leg recipient; falls back to the intent recipient. */
+ recipientAddress?: string;
+}
+
+/** Spend already made in each period, for one asset. */
+export interface SpendPeriods {
+ spentToday: number;
+ spentThisWeek: number;
+ spentThisMonth: number;
+}
+
/** The transaction intent evaluated against policies. */
export interface TransactionIntent {
organizationId: string;
@@ -137,6 +211,22 @@ export interface TransactionIntent {
spentToday?: number;
spentThisWeek?: number;
spentThisMonth?: number;
+ /**
+ * Multi-asset legs. When present they replace the single `asset`/`amount`
+ * pair: every leg is evaluated against the policy limits independently.
+ */
+ transfers?: AssetTransfer[];
+ /**
+ * Spend already made per asset, keyed by the canonical asset identifier.
+ * When present it takes precedence over the flat period fields.
+ */
+ spentByAsset?: Record;
+ /**
+ * Currency the amounts in this intent are denominated in. Set by the
+ * evaluation layer when a policy declares a `baseCurrency` and the amounts
+ * had to be converted; used in violation messages.
+ */
+ limitCurrency?: string;
}
export interface PolicyViolation {
@@ -144,6 +234,8 @@ export interface PolicyViolation {
policyName: string;
code: string;
message: string;
+ /** Upper-case asset code the violation was raised for, when asset-specific. */
+ asset?: string;
}
export interface PolicyEvaluationResult {
diff --git a/src/modules/policies/price-oracle.service.spec.ts b/src/modules/policies/price-oracle.service.spec.ts
new file mode 100644
index 00000000..e92f0980
--- /dev/null
+++ b/src/modules/policies/price-oracle.service.spec.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { ConfigService } from '@nestjs/config';
+import { PriceOracleService, PriceUnavailableException } from './price-oracle.service';
+import { PriceOracleConfig } from '../../config/price-oracle.config';
+
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+function oracle(overrides: Partial = {}): PriceOracleService {
+ const settings: PriceOracleConfig = {
+ enabled: false,
+ url: '',
+ apiKey: '',
+ timeoutMs: 3_000,
+ maxAgeMs: 300_000,
+ staticPrices: {},
+ ...overrides,
+ };
+ const config = { get: vi.fn().mockReturnValue(settings) } as unknown as ConfigService;
+ return new PriceOracleService(config);
+}
+
+describe('PriceOracleService', () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+ });
+
+ describe('getQuote', () => {
+ it('returns a unit quote when the asset already is the base currency', async () => {
+ const service = oracle();
+ const quote = await service.getQuote('USDC', 'USDC');
+ expect(quote.price).toBe(1);
+ expect(quote.asset).toBe('USDC');
+ });
+
+ it('uses the static price table when configured', async () => {
+ const service = oracle({ staticPrices: { XLM: 0.25 } });
+ const quote = await service.getQuote('XLM', 'USD');
+ expect(quote).toMatchObject({ asset: 'XLM', base: 'USD', price: 0.25, source: 'static' });
+ });
+
+ it('fails closed when no price source is configured', async () => {
+ const service = oracle();
+ await expect(service.getQuote('XLM', 'USD')).rejects.toBeInstanceOf(
+ PriceUnavailableException,
+ );
+ });
+
+ it('reads a quote from the http oracle', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ quotes: { XLM: { price: 0.11, asOf: new Date().toISOString() } },
+ }),
+ }) as unknown as typeof fetch;
+
+ const service = oracle({ enabled: true, url: 'https://prices.example/v1' });
+ const quote = await service.getQuote('XLM', 'USD');
+
+ expect(quote).toMatchObject({ asset: 'XLM', base: 'USD', price: 0.11, source: 'http' });
+ const requested = (global.fetch as unknown as ReturnType).mock
+ .calls[0][0] as string;
+ expect(requested).toContain('base=USD');
+ expect(requested).toContain('assets=XLM');
+ });
+
+ it('rejects a stale quote', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ quotes: {
+ XLM: { price: 0.11, asOf: new Date(Date.now() - 3_600_000).toISOString() },
+ },
+ }),
+ }) as unknown as typeof fetch;
+
+ const service = oracle({ enabled: true, url: 'https://prices.example/v1', maxAgeMs: 1_000 });
+ await expect(service.getQuote('XLM', 'USD')).rejects.toMatchObject({
+ reason: 'stale',
+ });
+ });
+
+ it('rejects an invalid price', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ quotes: { XLM: { price: 0, asOf: new Date().toISOString() } },
+ }),
+ }) as unknown as typeof fetch;
+
+ const service = oracle({ enabled: true, url: 'https://prices.example/v1' });
+ await expect(service.getQuote('XLM', 'USD')).rejects.toMatchObject({ reason: 'invalid' });
+ });
+
+ it('rejects a missing quote', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ quotes: {} }),
+ }) as unknown as typeof fetch;
+
+ const service = oracle({ enabled: true, url: 'https://prices.example/v1' });
+ await expect(service.getQuote('XLM', 'USD')).rejects.toMatchObject({ reason: 'invalid' });
+ });
+
+ it('reports an http failure as unavailable', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: async () => ({}),
+ }) as unknown as typeof fetch;
+
+ const service = oracle({ enabled: true, url: 'https://prices.example/v1' });
+ await expect(service.getQuote('XLM', 'USD')).rejects.toMatchObject({ reason: 'unavailable' });
+ });
+ });
+
+ describe('convert', () => {
+ it('multiplies the amount with the quote', async () => {
+ const service = oracle({ staticPrices: { XLM: 0.25 } });
+ await expect(service.convert(100, 'XLM', 'USD')).resolves.toBe(25);
+ });
+
+ it('never converts an unknown price to zero', async () => {
+ const service = oracle();
+ await expect(service.convert(100, 'XLM', 'USD')).rejects.toBeInstanceOf(
+ PriceUnavailableException,
+ );
+ });
+
+ it('converts an issued asset by its code', async () => {
+ const service = oracle({ staticPrices: { USDC: 1 } });
+ await expect(service.convert(42, `USDC:${ISSUER}`, 'USD')).resolves.toBe(42);
+ });
+ });
+});
diff --git a/src/modules/policies/price-oracle.service.ts b/src/modules/policies/price-oracle.service.ts
new file mode 100644
index 00000000..36461683
--- /dev/null
+++ b/src/modules/policies/price-oracle.service.ts
@@ -0,0 +1,247 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { DomainException } from '../../common/exceptions/domain.exception';
+import { ErrorCode } from '../../common/constants/error-codes';
+import { PriceOracleConfig } from '../../config/price-oracle.config';
+import { assetCodeOf } from '../../utils/asset.util';
+
+/** A price quote for one asset, denominated in a base currency. */
+export interface PriceQuote {
+ /** Upper-case asset code the quote is for. */
+ asset: string;
+ /** Upper-case base currency the price is expressed in. */
+ base: string;
+ /** Price of one unit of the asset, expressed in `base`. */
+ price: number;
+ /** When the quote was produced. */
+ asOf: Date;
+ /** Where the quote came from. */
+ source: 'static' | 'http';
+}
+
+/** Why a quote could not be produced. */
+export type PriceUnavailableReason = 'unavailable' | 'stale' | 'invalid' | 'disabled';
+
+/**
+ * Raised when a price quote required to evaluate a limit is not available.
+ *
+ * The failure is deliberately fatal for the transaction: a limit denominated
+ * in a base currency can only be enforced when the price is known, so the
+ * platform blocks the transfer instead of treating the unknown price as zero
+ * (which would let any amount through).
+ */
+export class PriceUnavailableException extends DomainException {
+ constructor(
+ readonly reason: PriceUnavailableReason,
+ asset: string,
+ base: string,
+ message: string,
+ ) {
+ super(ErrorCode.PRICE_UNAVAILABLE, message, { reason, asset, base });
+ }
+}
+
+interface CachedQuote {
+ quote: PriceQuote;
+ expiresAt: number;
+}
+
+/**
+ * Converts asset amounts into a base currency.
+ *
+ * Two sources are supported and always consulted in this order:
+ *
+ * 1. **Static prices** — an operator-managed table (`PRICE_ORACLE_STATIC_PRICES`).
+ * Deterministic, offline-friendly and considered fresh at all times.
+ * 2. **HTTP oracle** — a provider-neutral JSON endpoint that receives
+ * `?base=&assets=` and answers with
+ * `{ "quotes": { "": { "price": 0.11, "asOf": "2026-01-01T00:00:00Z" } } }`.
+ *
+ * Quotes older than `PRICE_ORACLE_MAX_AGE_MS` are rejected as stale. Every
+ * failure path raises {@link PriceUnavailableException}; a missing price is
+ * never silently converted to zero.
+ */
+@Injectable()
+export class PriceOracleService {
+ private readonly cache = new Map();
+
+ constructor(private readonly config: ConfigService) {}
+
+ /** The resolved price-oracle configuration. */
+ private get settings(): PriceOracleConfig {
+ return (
+ this.config.get('priceOracle') ?? {
+ enabled: false,
+ url: '',
+ apiKey: '',
+ timeoutMs: 3_000,
+ maxAgeMs: 300_000,
+ staticPrices: {},
+ }
+ );
+ }
+
+ /**
+ * Returns a quote for `asset` denominated in `base`.
+ *
+ * @throws PriceUnavailableException when no usable quote exists.
+ */
+ async getQuote(asset: string, base: string): Promise {
+ const assetCode = assetCodeOf(asset);
+ const baseCode = base.toUpperCase();
+
+ if (assetCode === baseCode) {
+ return {
+ asset: assetCode,
+ base: baseCode,
+ price: 1,
+ asOf: new Date(),
+ source: 'static',
+ };
+ }
+
+ const cached = this.cache.get(`${assetCode}:${baseCode}`);
+ if (cached && cached.expiresAt > Date.now()) {
+ return cached.quote;
+ }
+
+ const quote = await this.fetchQuote(assetCode, baseCode);
+ const ttl = Math.max(1_000, Math.floor(this.settings.maxAgeMs / 2));
+ this.cache.set(`${assetCode}:${baseCode}`, { quote, expiresAt: Date.now() + ttl });
+ return quote;
+ }
+
+ /**
+ * Converts an amount denominated in `asset` into `base`.
+ *
+ * @throws PriceUnavailableException when the conversion cannot be performed.
+ */
+ async convert(amount: number, asset: string, base: string): Promise {
+ const quote = await this.getQuote(asset, base);
+ const converted = amount * quote.price;
+ if (!Number.isFinite(converted)) {
+ throw new PriceUnavailableException(
+ 'invalid',
+ assetCodeOf(asset),
+ base.toUpperCase(),
+ `Price conversion for asset ${assetCodeOf(asset)} produced a non-finite amount`,
+ );
+ }
+ return converted;
+ }
+
+ /** Clears the in-memory quote cache (used by tests and by manual refreshes). */
+ clearCache(): void {
+ this.cache.clear();
+ }
+
+ // ── internals ──────────────────────────────────────────────────────────
+
+ private async fetchQuote(assetCode: string, baseCode: string): Promise {
+ const settings = this.settings;
+
+ const staticPrice = settings.staticPrices[assetCode];
+ if (staticPrice !== undefined) {
+ return {
+ asset: assetCode,
+ base: baseCode,
+ price: staticPrice,
+ asOf: new Date(),
+ source: 'static',
+ };
+ }
+
+ if (!settings.enabled || !settings.url) {
+ throw new PriceUnavailableException(
+ 'disabled',
+ assetCode,
+ baseCode,
+ `No price source is configured for asset ${assetCode}; enable the price oracle or configure a static price before evaluating base-currency limits`,
+ );
+ }
+
+ let payload: unknown;
+ try {
+ payload = await this.requestQuote(settings, assetCode, baseCode);
+ } catch (error) {
+ throw new PriceUnavailableException(
+ 'unavailable',
+ assetCode,
+ baseCode,
+ `Price quote for asset ${assetCode} in ${baseCode} is unavailable: ${(error as Error).message}`,
+ );
+ }
+
+ const quote = this.parseQuote(payload, assetCode, baseCode);
+ const ageMs = Date.now() - quote.asOf.getTime();
+ if (ageMs > settings.maxAgeMs) {
+ throw new PriceUnavailableException(
+ 'stale',
+ assetCode,
+ baseCode,
+ `Price quote for asset ${assetCode} in ${baseCode} is stale (age ${Math.round(ageMs / 1000)}s exceeds the ${Math.round(settings.maxAgeMs / 1000)}s limit)`,
+ );
+ }
+ return quote;
+ }
+
+ private async requestQuote(
+ settings: PriceOracleConfig,
+ assetCode: string,
+ baseCode: string,
+ ): Promise {
+ const url = new URL(settings.url);
+ url.searchParams.set('base', baseCode);
+ url.searchParams.set('assets', assetCode);
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), settings.timeoutMs);
+ try {
+ const response = await fetch(url.toString(), {
+ signal: controller.signal,
+ headers: settings.apiKey ? { authorization: `Bearer ${settings.apiKey}` } : {},
+ });
+ if (!response.ok) {
+ throw new Error(`price endpoint responded with HTTP ${response.status}`);
+ }
+ return (await response.json()) as unknown;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ private parseQuote(payload: unknown, assetCode: string, baseCode: string): PriceQuote {
+ const quotes = (payload as { quotes?: Record } | null)?.quotes;
+ const entry = quotes?.[assetCode] ?? quotes?.[assetCode.toLowerCase()];
+ if (!entry || typeof entry !== 'object') {
+ throw new PriceUnavailableException(
+ 'invalid',
+ assetCode,
+ baseCode,
+ `Price quote for asset ${assetCode} in ${baseCode} is missing from the oracle response`,
+ );
+ }
+
+ const { price, asOf } = entry as { price?: unknown; asOf?: unknown };
+ if (typeof price !== 'number' || !Number.isFinite(price) || price <= 0) {
+ throw new PriceUnavailableException(
+ 'invalid',
+ assetCode,
+ baseCode,
+ `Price quote for asset ${assetCode} in ${baseCode} is invalid`,
+ );
+ }
+
+ const timestamp = typeof asOf === 'string' ? Date.parse(asOf) : Number.NaN;
+ if (!Number.isFinite(timestamp)) {
+ throw new PriceUnavailableException(
+ 'invalid',
+ assetCode,
+ baseCode,
+ `Price quote for asset ${assetCode} in ${baseCode} has an invalid timestamp`,
+ );
+ }
+
+ return { asset: assetCode, base: baseCode, price, asOf: new Date(timestamp), source: 'http' };
+ }
+}
diff --git a/src/modules/policies/transfer-payload.spec.ts b/src/modules/policies/transfer-payload.spec.ts
new file mode 100644
index 00000000..d131ac10
--- /dev/null
+++ b/src/modules/policies/transfer-payload.spec.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest';
+import { TransferPayloadError, parseTransferPayload } from './transfer-payload';
+
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+/** Canonicalises like the asset registry would. */
+const canonicalize = (asset: string): string => asset.toUpperCase();
+
+describe('parseTransferPayload', () => {
+ it('parses a single-asset payload', () => {
+ expect(parseTransferPayload({ asset: 'XLM', amount: '10.5' }, canonicalize)).toEqual([
+ { asset: 'XLM', amount: 10.5 },
+ ]);
+ });
+
+ it('parses a multi-asset payload', () => {
+ const legs = parseTransferPayload(
+ {
+ transfers: [
+ { asset: 'XLM', amount: '10' },
+ { asset: 'USDC', amount: '25.25' },
+ ],
+ },
+ canonicalize,
+ );
+
+ expect(legs).toEqual([
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 25.25 },
+ ]);
+ });
+
+ it('keeps the recipient of a leg when the payload declares one', () => {
+ const legs = parseTransferPayload(
+ {
+ transfers: [
+ {
+ asset: 'XLM',
+ amount: '10',
+ recipientAddress: 'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5',
+ },
+ ],
+ },
+ canonicalize,
+ );
+
+ expect(legs[0].recipientAddress).toBe(
+ 'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5',
+ );
+ });
+
+ it('rejects the same asset twice in one payload', () => {
+ expect(() =>
+ parseTransferPayload(
+ {
+ transfers: [
+ { asset: 'XLM', amount: '10' },
+ { asset: 'xlm', amount: '5' },
+ ],
+ },
+ canonicalize,
+ ),
+ ).toThrow(TransferPayloadError);
+ });
+
+ it('rejects a payload without an asset or a transfers array', () => {
+ expect(() => parseTransferPayload({}, canonicalize)).toThrow(TransferPayloadError);
+ });
+
+ it('rejects a non-positive amount', () => {
+ expect(() => parseTransferPayload({ asset: 'XLM', amount: '0' }, canonicalize)).toThrow(
+ TransferPayloadError,
+ );
+ expect(() => parseTransferPayload({ asset: 'XLM', amount: -5 }, canonicalize)).toThrow(
+ TransferPayloadError,
+ );
+ });
+
+ it('rejects a non-finite amount', () => {
+ expect(() => parseTransferPayload({ asset: 'XLM', amount: Number.NaN }, canonicalize)).toThrow(
+ TransferPayloadError,
+ );
+ });
+
+ it('rejects a non-numeric amount', () => {
+ expect(() => parseTransferPayload({ asset: 'XLM', amount: 'abc' }, canonicalize)).toThrow(
+ TransferPayloadError,
+ );
+ expect(() =>
+ parseTransferPayload({ transfers: [{ asset: 'XLM', amount: 'abc' }] }, canonicalize),
+ ).toThrow(TransferPayloadError);
+ });
+
+ it('rejects an infinite amount', () => {
+ expect(() =>
+ parseTransferPayload({ asset: 'XLM', amount: Number.POSITIVE_INFINITY }, canonicalize),
+ ).toThrow(TransferPayloadError);
+ });
+
+ it('rejects a payload whose transfers array is empty', () => {
+ expect(() => parseTransferPayload({ transfers: [] }, canonicalize)).toThrow(
+ TransferPayloadError,
+ );
+ });
+
+ it('accepts an issued asset identifier', () => {
+ const legs = parseTransferPayload({ asset: `USDC:${ISSUER}`, amount: '3' }, canonicalize);
+ expect(legs[0].asset).toBe(`USDC:${ISSUER}`);
+ });
+});
diff --git a/src/modules/policies/transfer-payload.ts b/src/modules/policies/transfer-payload.ts
new file mode 100644
index 00000000..1ec511f0
--- /dev/null
+++ b/src/modules/policies/transfer-payload.ts
@@ -0,0 +1,99 @@
+import { z } from 'zod';
+import { stellarAssetSchema } from '../../common/validators/stellar-asset.schema';
+
+/**
+ * One asset leg of a transfer payload. The amount is a decimal string with up
+ * to 7 places, matching Stellar's precision.
+ */
+export const assetTransferSchema = z.object({
+ asset: stellarAssetSchema,
+ amount: z
+ .string()
+ .regex(/^\d+(\.\d{1,7})?$/, 'Amount must be a positive decimal with up to 7 places')
+ .refine((value) => Number(value) > 0, 'Amount must be greater than zero'),
+});
+
+export type AssetTransferInput = z.infer;
+
+/** A transfer leg after parsing, with the amount as a number. */
+export interface ParsedTransfer {
+ /** Canonical asset identifier (`XLM` or `CODE:ISSUER`). */
+ asset: string;
+ /** Amount of the leg. */
+ amount: number;
+ /** Optional per-leg recipient, when the payload declares one. */
+ recipientAddress?: string;
+}
+
+/** The raw shape a payload may have before it is parsed. */
+export interface TransferPayloadInput {
+ asset?: string;
+ amount?: string | number;
+ transfers?: Array<{ asset: string; amount: string | number; recipientAddress?: string }>;
+}
+
+/** Raised when a transfer payload cannot be parsed. */
+export class TransferPayloadError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'TransferPayloadError';
+ }
+}
+
+/**
+ * Parses a single-asset or multi-asset transfer payload into its legs.
+ *
+ * - A payload with a `transfers` array is a multi-asset transfer: one leg per
+ * entry. Every asset may appear at most once, so a transfer can never hide
+ * two amounts for the same asset behind a single limit check.
+ * - A payload with a top-level `asset`/`amount` pair is a single-asset
+ * transfer and yields exactly one leg.
+ *
+ * @param canonicalize Resolves an asset string to its canonical identifier.
+ * @throws TransferPayloadError when the payload is malformed.
+ */
+export function parseTransferPayload(
+ input: TransferPayloadInput,
+ canonicalize: (asset: string) => string,
+): ParsedTransfer[] {
+ if (input.transfers && input.transfers.length > 0) {
+ const seen = new Set();
+ return input.transfers.map((leg) => {
+ const asset = canonicalize(leg.asset);
+ const key = asset.toUpperCase();
+ if (seen.has(key)) {
+ throw new TransferPayloadError(
+ `Duplicate asset '${asset}' in transfers; each asset may appear only once per payload`,
+ );
+ }
+ seen.add(key);
+ return {
+ asset,
+ amount: parseAmount(leg.amount),
+ ...(leg.recipientAddress ? { recipientAddress: leg.recipientAddress } : {}),
+ };
+ });
+ }
+
+ if (input.asset === undefined || input.amount === undefined) {
+ throw new TransferPayloadError(
+ 'A transfer payload requires either an asset/amount pair or a transfers array',
+ );
+ }
+
+ return [
+ {
+ asset: canonicalize(input.asset),
+ amount: parseAmount(input.amount),
+ },
+ ];
+}
+
+/** Parses a transfer amount into a finite positive number. */
+function parseAmount(value: string | number): number {
+ const amount = typeof value === 'number' ? value : Number(value);
+ if (!Number.isFinite(amount) || amount <= 0) {
+ throw new TransferPayloadError('Amount must be a finite number greater than zero');
+ }
+ return amount;
+}
diff --git a/src/modules/transactions/guards/spending-limit.guard.ts b/src/modules/transactions/guards/spending-limit.guard.ts
index 53aa795e..51659ce2 100644
--- a/src/modules/transactions/guards/spending-limit.guard.ts
+++ b/src/modules/transactions/guards/spending-limit.guard.ts
@@ -3,6 +3,7 @@ import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { AuthenticatedUser } from '../../../common/interfaces/authenticated-user.interface';
import { SpendingLimitService } from '../spending-limit.service';
+import { AssetRegistryService } from '../../policies/asset-registry.service';
import { TransactionIntent } from '../../policies/policy.types';
export const SPENDING_LIMIT_GUARD_KEY = 'astroid:spendingLimitGuard';
@@ -58,6 +59,7 @@ export class SpendingLimitGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly spendingLimitService: SpendingLimitService,
+ private readonly assetRegistry: AssetRegistryService,
) {}
async canActivate(context: ExecutionContext): Promise {
@@ -92,7 +94,7 @@ export class SpendingLimitGuard implements CanActivate {
const actorId = request.user?.id;
const amount = Number(body?.amount ?? 0);
- const asset = (body?.asset as string) ?? 'XLM';
+ const asset = this.assetRegistry.canonicalize((body?.asset as string) ?? 'XLM');
const recipientAddress = (body?.recipientAddress as string) ?? '';
const walletId = (body?.walletId as string) ?? undefined;
diff --git a/src/modules/transactions/spending-limit.service.ts b/src/modules/transactions/spending-limit.service.ts
index 2bc84ae0..6242c9f8 100644
--- a/src/modules/transactions/spending-limit.service.ts
+++ b/src/modules/transactions/spending-limit.service.ts
@@ -3,7 +3,7 @@ import { Prisma, TransactionStatus } from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { PolicyService } from '../policies/policy.service';
import { AuditService } from '../audit/audit.service';
-import { PolicyConfiguration, TransactionIntent } from '../policies/policy.types';
+import { PolicyConfiguration, SpendPeriods, TransactionIntent } from '../policies/policy.types';
import { PolicyViolationException } from '../../common/exceptions/domain.exception';
/**
@@ -30,6 +30,10 @@ export interface SpendAggregates {
* {@link PolicyViolationException} so the compliance trail is complete
* even when the transaction is blocked.
*
+ * Multi-asset transfers are supported: {@link aggregateSpendByAsset} returns
+ * the spend accumulated per asset so every leg of a transfer is compared
+ * against the spend of *its own* asset.
+ *
* This service is intentionally narrow in scope — it does not replace
* {@link PolicyService} or {@link PolicyEngine}; it only enriches the intent
* with atomic spend data and delegates evaluation to the policy layer.
@@ -60,35 +64,8 @@ export class SpendingLimitService {
* the aggregate — DRAFT/REJECTED/FAILED/CANCELLED/EXPIRED are excluded.
*/
async aggregateSpend(agentId: string, asset: string): Promise {
- const now = new Date();
-
- // UTC day boundary — midnight today
- const startOfDay = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
- );
-
- // UTC week boundary — most-recent Monday at midnight
- const dayOfWeek = now.getUTCDay(); // 0 = Sunday
- const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
- const startOfWeek = new Date(
- Date.UTC(
- now.getUTCFullYear(),
- now.getUTCMonth(),
- now.getUTCDate() - daysSinceMonday,
- ),
- );
-
- // UTC month boundary — 1st of the current month
- const startOfMonth = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
- );
-
- const COUNTED_STATUSES: TransactionStatus[] = [
- TransactionStatus.PENDING,
- TransactionStatus.SUBMITTED,
- TransactionStatus.CONFIRMED,
- TransactionStatus.COMPLETED,
- ];
+ const windows = this.windowBounds();
+ const counted = this.countedStatuses();
// Run all three aggregates in parallel within a single Prisma transaction
// to get a consistent snapshot.
@@ -98,9 +75,9 @@ export class SpendingLimitService {
where: {
agentId,
asset,
- status: { in: COUNTED_STATUSES },
+ status: { in: counted },
deletedAt: null,
- createdAt: { gte: startOfDay },
+ createdAt: { gte: windows.startOfDay },
},
}),
this.prisma.transaction.aggregate({
@@ -108,9 +85,9 @@ export class SpendingLimitService {
where: {
agentId,
asset,
- status: { in: COUNTED_STATUSES },
+ status: { in: counted },
deletedAt: null,
- createdAt: { gte: startOfWeek },
+ createdAt: { gte: windows.startOfWeek },
},
}),
this.prisma.transaction.aggregate({
@@ -118,9 +95,9 @@ export class SpendingLimitService {
where: {
agentId,
asset,
- status: { in: COUNTED_STATUSES },
+ status: { in: counted },
deletedAt: null,
- createdAt: { gte: startOfMonth },
+ createdAt: { gte: windows.startOfMonth },
},
}),
]);
@@ -132,6 +109,33 @@ export class SpendingLimitService {
};
}
+ /**
+ * Computes the spend aggregates for several assets at once. Used by
+ * multi-asset transfers, where every leg must be compared against the spend
+ * of its own asset rather than against the spend of the primary asset.
+ *
+ * The result is keyed by the canonical asset identifier so it lines up with
+ * the legs of the intent.
+ */
+ async aggregateSpendByAsset(
+ agentId: string,
+ assets: string[],
+ ): Promise> {
+ const unique = [...new Set(assets)];
+ if (unique.length === 0) {
+ return {};
+ }
+
+ const entries = await Promise.all(
+ unique.map(async (asset) => {
+ const aggregates = await this.aggregateSpend(agentId, asset);
+ return [asset, aggregates] as const;
+ }),
+ );
+
+ return Object.fromEntries(entries);
+ }
+
/**
* Returns true if the agent has at least one active spending-limit policy
* (a policy with `dailyLimit`, `weeklyLimit`, or `monthlyLimit` configured).
@@ -156,7 +160,14 @@ export class SpendingLimitService {
return (
config.dailyLimit !== undefined ||
config.weeklyLimit !== undefined ||
- config.monthlyLimit !== undefined
+ config.monthlyLimit !== undefined ||
+ (config.assetLimits !== undefined &&
+ Object.values(config.assetLimits).some(
+ (limits) =>
+ limits.dailyLimit !== undefined ||
+ limits.weeklyLimit !== undefined ||
+ limits.monthlyLimit !== undefined,
+ ))
);
});
}
@@ -223,6 +234,16 @@ export class SpendingLimitService {
};
}
+ // Multi-asset transfers need the spend of every asset in the payload, not
+ // just the spend of the primary asset.
+ if (intent.transfers && intent.transfers.length > 0 && !intent.spentByAsset) {
+ const spentByAsset = await this.aggregateSpendByAsset(
+ agentId,
+ intent.transfers.map((leg) => leg.asset),
+ );
+ enrichedIntent = { ...enrichedIntent, spentByAsset };
+ }
+
const result = await this.policyService.evaluateIntent(enrichedIntent, actorId);
if (!result.passed) {
@@ -247,6 +268,7 @@ export class SpendingLimitService {
violations: result.violations,
requiresApproval: result.requiresApproval,
aggregates,
+ ...(enrichedIntent.spentByAsset ? { spentByAsset: enrichedIntent.spentByAsset } : {}),
},
);
}
@@ -254,6 +276,48 @@ export class SpendingLimitService {
// ── private helpers ──────────────────────────────────────────────────────
+ /** UTC window boundaries used by the spend aggregates. */
+ private windowBounds(): {
+ startOfDay: Date;
+ startOfWeek: Date;
+ startOfMonth: Date;
+ } {
+ const now = new Date();
+
+ // UTC day boundary — midnight today
+ const startOfDay = new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
+ );
+
+ // UTC week boundary — most-recent Monday at midnight
+ const dayOfWeek = now.getUTCDay(); // 0 = Sunday
+ const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
+ const startOfWeek = new Date(
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate() - daysSinceMonday,
+ ),
+ );
+
+ // UTC month boundary — 1st of the current month
+ const startOfMonth = new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
+ );
+
+ return { startOfDay, startOfWeek, startOfMonth };
+ }
+
+ /** Transaction states that count as real, settled spend. */
+ private countedStatuses(): TransactionStatus[] {
+ return [
+ TransactionStatus.PENDING,
+ TransactionStatus.SUBMITTED,
+ TransactionStatus.CONFIRMED,
+ TransactionStatus.COMPLETED,
+ ];
+ }
+
/**
* Writes an audit log entry for a spending limit policy failure.
* Failures here are logged but never allowed to propagate — a broken audit
@@ -264,7 +328,13 @@ export class SpendingLimitService {
actorId: string | null,
agentId: string,
intent: TransactionIntent,
- violations: Array<{ policyId: string; policyName: string; code: string; message: string }>,
+ violations: Array<{
+ policyId: string;
+ policyName: string;
+ code: string;
+ message: string;
+ asset?: string;
+ }>,
aggregates: SpendAggregates,
): Promise {
try {
@@ -282,6 +352,12 @@ export class SpendingLimitService {
recipientAddress: intent.recipientAddress,
violations,
aggregates,
+ ...(intent.transfers
+ ? { transfers: intent.transfers.map((leg) => ({ asset: leg.asset, amount: leg.amount })) }
+ : {}),
+ ...(intent.spentByAsset
+ ? { spentByAsset: intent.spentByAsset as Record }
+ : {}),
evaluatedAt: new Date().toISOString(),
} as unknown as Prisma.InputJsonValue,
});
diff --git a/src/modules/transactions/tests/spending-limit.guard.spec.ts b/src/modules/transactions/tests/spending-limit.guard.spec.ts
index 56eb3630..f1fb3fe4 100644
--- a/src/modules/transactions/tests/spending-limit.guard.spec.ts
+++ b/src/modules/transactions/tests/spending-limit.guard.spec.ts
@@ -4,6 +4,7 @@ import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';
import { SpendingLimitGuard, SPENDING_LIMIT_GUARD_KEY } from '../guards/spending-limit.guard';
import { SpendingLimitService } from '../spending-limit.service';
+import { AssetRegistryService } from '../../policies/asset-registry.service';
import { PolicyViolationException } from '../../../common/exceptions/domain.exception';
// ---------------------------------------------------------------------------
@@ -11,6 +12,18 @@ import { PolicyViolationException } from '../../../common/exceptions/domain.exce
// ---------------------------------------------------------------------------
const VALID_STELLAR = 'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5';
+const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+const CANONICAL_USDC = `USDC:${USDC_ISSUER}`;
+
+/**
+ * Mirrors the real {@link AssetRegistryService}: a bare anchor ticker is
+ * expanded to its canonical `CODE:ISSUER` identifier so the guard checks the
+ * limit against the same identity the transaction pipeline uses.
+ */
+function canonicalize(asset: string): string {
+ if (asset === 'USDC') return CANONICAL_USDC;
+ return asset;
+}
function makeContext(overrides: {
body?: Record;
@@ -56,6 +69,10 @@ describe('SpendingLimitGuard', () => {
SpendingLimitGuard,
Reflector,
{ provide: SpendingLimitService, useValue: mockSpendingLimitService },
+ {
+ provide: AssetRegistryService,
+ useValue: { canonicalize },
+ },
],
}).compile();
@@ -138,12 +155,31 @@ describe('SpendingLimitGuard', () => {
expect(intent.organizationId).toBe('org-1');
expect(intent.agentId).toBe('agent-1');
expect(intent.amount).toBe(250);
- expect(intent.asset).toBe('USDC');
+ // The asset is canonicalized, so a bare ticker and the credit asset it
+ // refers to share one spending budget.
+ expect(intent.asset).toBe(CANONICAL_USDC);
expect(intent.recipientAddress).toBe(VALID_STELLAR);
expect(intent.walletId).toBe('wallet-1');
expect(actorId).toBe('user-1');
});
+ it('keeps an already canonical credit asset untouched', async () => {
+ const ctx = makeContext({
+ body: {
+ agentId: 'agent-1',
+ amount: '250',
+ asset: CANONICAL_USDC,
+ recipientAddress: VALID_STELLAR,
+ walletId: 'wallet-1',
+ },
+ });
+
+ await guard.canActivate(ctx);
+
+ const [intent] = mockSpendingLimitService.evaluateSpendingLimits.mock.calls[0] as [Record];
+ expect(intent.asset).toBe(CANONICAL_USDC);
+ });
+
it('uses XLM as default asset when none provided', async () => {
const ctx = makeContext({
body: { agentId: 'agent-1', amount: '50', recipientAddress: VALID_STELLAR },
diff --git a/src/modules/transactions/tests/spending-limit.service.spec.ts b/src/modules/transactions/tests/spending-limit.service.spec.ts
index df2ab431..375529a9 100644
--- a/src/modules/transactions/tests/spending-limit.service.spec.ts
+++ b/src/modules/transactions/tests/spending-limit.service.spec.ts
@@ -490,8 +490,7 @@ describe('SpendingLimitService', () => {
});
describe('intent enrichment', () => {
- it('passes enriched intent (with aggregates) to PolicyService.evaluateIntent', async () => {
- mockPrismaService.policy.findMany.mockResolvedValue([
+ it('passes enriched intent (with aggregates) to PolicyService.evaluateIntent', async () => { mockPrismaService.policy.findMany.mockResolvedValue([
{ configuration: { dailyLimit: 1000, weeklyLimit: 5000, monthlyLimit: 15000 } },
]);
mockPrismaService.$transaction.mockResolvedValue([
@@ -542,5 +541,107 @@ describe('SpendingLimitService', () => {
expect(passedIntent.recipientAddress).toBe(VALID_STELLAR);
});
});
+
+ describe('multi-asset transfers', () => {
+ beforeEach(() => {
+ mockPrismaService.policy.findMany.mockResolvedValue([
+ { configuration: { assetLimits: { USDC: { dailyLimit: 500 } } } },
+ ]);
+ mockPolicyService.evaluateIntent.mockResolvedValue({
+ passed: true,
+ requiresApproval: false,
+ violations: [],
+ evaluatedPolicyIds: ['policy-1'],
+ });
+ });
+
+ it('collects the spend of every asset in the payload', async () => {
+ mockPrismaService.$transaction.mockResolvedValue([
+ { _sum: { amount: makeDecimal(0) } },
+ { _sum: { amount: makeDecimal(0) } },
+ { _sum: { amount: makeDecimal(0) } },
+ ]);
+
+ const intent = makeIntent({
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 100 },
+ ],
+ });
+ await service.evaluateSpendingLimits(intent, 'user-1');
+
+ const passedIntent = mockPolicyService.evaluateIntent.mock.calls[0][0] as TransactionIntent;
+ expect(passedIntent.spentByAsset).toBeDefined();
+ expect(Object.keys(passedIntent.spentByAsset ?? {})).toEqual(['XLM', 'USDC']);
+ });
+
+ it('does not query the aggregates twice when they are already present', async () => {
+ const intent = makeIntent({
+ spentToday: 10,
+ spentThisWeek: 10,
+ spentThisMonth: 10,
+ spentByAsset: {
+ XLM: { spentToday: 10, spentThisWeek: 10, spentThisMonth: 10 },
+ USDC: { spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 },
+ },
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 100 },
+ ],
+ });
+ await service.evaluateSpendingLimits(intent, 'user-1');
+
+ const passedIntent = mockPolicyService.evaluateIntent.mock.calls[0][0] as TransactionIntent;
+ expect(passedIntent.spentByAsset?.USDC.spentToday).toBe(0);
+ expect(mockPrismaService.$transaction).not.toHaveBeenCalled();
+ });
+
+ it('reports the offending asset in the violation details', async () => {
+ mockPrismaService.$transaction.mockResolvedValue([
+ { _sum: { amount: makeDecimal(0) } },
+ { _sum: { amount: makeDecimal(0) } },
+ { _sum: { amount: makeDecimal(0) } },
+ ]);
+ mockPolicyService.evaluateIntent.mockResolvedValue({
+ passed: false,
+ requiresApproval: false,
+ violations: [
+ {
+ policyId: 'policy-1',
+ policyName: 'USDC cap',
+ code: 'DAILY_LIMIT_EXCEEDED',
+ message: 'Projected daily spend 600 USDC exceeds limit 500 USDC',
+ asset: 'USDC',
+ },
+ ],
+ evaluatedPolicyIds: ['policy-1'],
+ });
+
+ const intent = makeIntent({
+ transfers: [
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 100 },
+ ],
+ });
+
+ let caught: PolicyViolationException | undefined;
+ try {
+ await service.evaluateSpendingLimits(intent, 'user-1');
+ } catch (error) {
+ caught = error as PolicyViolationException;
+ }
+
+ expect(caught).toBeInstanceOf(PolicyViolationException);
+ const details = caught?.details as { violations: Array<{ asset?: string }> };
+ expect(details.violations[0].asset).toBe('USDC');
+
+ const auditCall = mockAuditService.record.mock.calls[0][0] as Record;
+ const newValue = auditCall.newValue as Record;
+ expect(newValue.transfers).toEqual([
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC', amount: 100 },
+ ]);
+ });
+ });
});
});
diff --git a/src/modules/transactions/tests/transaction.service.spec.ts b/src/modules/transactions/tests/transaction.service.spec.ts
index 1ffe51f1..d93e7a03 100644
--- a/src/modules/transactions/tests/transaction.service.spec.ts
+++ b/src/modules/transactions/tests/transaction.service.spec.ts
@@ -9,12 +9,14 @@ import { RiskService } from '../../risk/risk.service';
import { BudgetService } from '../../budgets/budget.service';
import { StellarService } from '../../stellar/stellar.service';
import { SpendingLimitService } from '../spending-limit.service';
+import { AssetRegistryService } from '../../policies/asset-registry.service';
import { EventBusService } from '../../../events/event-bus.service';
import { PrismaService } from '../../../database/prisma.service';
-import { AgentStatus, RiskBand, TransactionStatus, WalletStatus } from '@prisma/client';
+import { AgentStatus, Prisma, RiskBand, TransactionStatus, WalletStatus } from '@prisma/client';
import { Keypair } from '@stellar/stellar-sdk';
import { CreateTransactionInput } from '../transaction.dto';
import { DomainException } from '../../../common/exceptions/domain.exception';
+import { PolicyViolationException } from '../../../common/exceptions/domain.exception';
import { ErrorCode } from '../../../common/constants/error-codes';
import { parseIdempotencyKey } from '../transaction.dto';
@@ -89,6 +91,7 @@ describe('TransactionService', () => {
}),
evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
} as unknown as SpendingLimitService,
+ { canonicalize: (asset: string) => asset } as unknown as AssetRegistryService,
);
});
@@ -249,6 +252,10 @@ describe('TransactionService - create', () => {
evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
},
},
+ {
+ provide: AssetRegistryService,
+ useValue: { canonicalize: (asset: string) => asset },
+ },
],
}).compile();
@@ -339,10 +346,11 @@ describe('TransactionService - create', () => {
evaluatedPolicyIds: ['policy_1'],
}),
};
+ const blockedRepository = { create: vi.fn(), update: vi.fn() };
const blockedModule: TestingModule = await Test.createTestingModule({
providers: [
TransactionService,
- { provide: TransactionRepository, useValue: { create: vi.fn(), update: vi.fn() } },
+ { provide: TransactionRepository, useValue: blockedRepository },
{ provide: WalletService, useValue: { getOrThrow: vi.fn().mockResolvedValue(wallet) } },
{
provide: AgentService,
@@ -351,7 +359,16 @@ describe('TransactionService - create', () => {
{ provide: PolicyService, useValue: blockedPolicy },
{ provide: RiskService, useValue: { evaluate: vi.fn() } },
{ provide: BudgetService, useValue: { assertWithinBudget: vi.fn(), consume: vi.fn() } },
- { provide: StellarService, useValue: { submitPayment: vi.fn() } },
+ {
+ provide: StellarService,
+ useValue: {
+ submitPayment: vi.fn().mockResolvedValue({
+ hash: 'stellar_hash_1',
+ ledger: 100,
+ successful: true,
+ }),
+ },
+ },
{ provide: EventBusService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
{ provide: PrismaService, useValue: {} },
{
@@ -361,6 +378,10 @@ describe('TransactionService - create', () => {
evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
},
},
+ {
+ provide: AssetRegistryService,
+ useValue: { canonicalize: (asset: string) => asset },
+ },
],
}).compile();
const blockedService = blockedModule.get(TransactionService);
@@ -372,8 +393,470 @@ describe('TransactionService - create', () => {
expect(error).toBeInstanceOf(DomainException);
expect((error as DomainException).code).toBe(ErrorCode.POLICY_VIOLATION);
+ // The violation reasons travel with the error so the caller learns why the
+ // transaction was rejected.
+ expect((error as DomainException).details).toEqual([
+ { policyId: 'policy_1', reason: 'exceeds max amount' },
+ ]);
+ // Nothing is persisted and nothing reaches the network.
+ expect(blockedRepository.create).not.toHaveBeenCalled();
expect(blockedStellar.submitPayment).not.toHaveBeenCalled();
});
+
+ it('blocks a spending-limit violation before the transaction is persisted or submitted', async () => {
+ const repository = { create: vi.fn(), update: vi.fn() };
+ const stellar = { submitPayment: vi.fn() };
+ const blockedModule: TestingModule = await Test.createTestingModule({
+ providers: [
+ TransactionService,
+ { provide: TransactionRepository, useValue: repository },
+ { provide: WalletService, useValue: { getOrThrow: vi.fn().mockResolvedValue(wallet) } },
+ {
+ provide: AgentService,
+ useValue: { getOrThrow: vi.fn().mockResolvedValue({ id: 'agent_1', status: AgentStatus.ACTIVE }) },
+ },
+ {
+ provide: PolicyService,
+ useValue: {
+ checkVelocityLimit: vi.fn().mockResolvedValue(undefined),
+ evaluateIntent: vi.fn().mockResolvedValue({
+ passed: true,
+ requiresApproval: false,
+ violations: [],
+ evaluatedPolicyIds: [],
+ }),
+ },
+ },
+ { provide: RiskService, useValue: { evaluate: vi.fn() } },
+ { provide: BudgetService, useValue: { assertWithinBudget: vi.fn(), consume: vi.fn() } },
+ { provide: StellarService, useValue: stellar },
+ { provide: EventBusService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
+ { provide: PrismaService, useValue: {} },
+ {
+ provide: SpendingLimitService,
+ useValue: {
+ aggregateSpend: vi.fn().mockResolvedValue({ spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 }),
+ evaluateSpendingLimits: vi
+ .fn()
+ .mockRejectedValue(
+ new PolicyViolationException('Transaction blocked by spending limit policy', {
+ violations: [{ code: 'DAILY_LIMIT_EXCEEDED', asset: 'USDC' }],
+ }),
+ ),
+ },
+ },
+ {
+ provide: AssetRegistryService,
+ useValue: { canonicalize: (asset: string) => asset },
+ },
+ ],
+ }).compile();
+ const blockedService = blockedModule.get(TransactionService);
+
+ await expect(blockedService.create('org_1', 'user_1', input)).rejects.toBeInstanceOf(
+ PolicyViolationException,
+ );
+ expect(repository.create).not.toHaveBeenCalled();
+ expect(stellar.submitPayment).not.toHaveBeenCalled();
+ });
+
+ it('stores and submits the canonical asset identifier', async () => {
+ let stored: Record | undefined;
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ TransactionService,
+ {
+ provide: TransactionRepository,
+ useValue: {
+ create: vi.fn().mockImplementation((data: Record) => {
+ stored = { id: 'tx_1', ...data, status: TransactionStatus.DRAFT };
+ return Promise.resolve(stored);
+ }),
+ update: vi.fn().mockImplementation((id: string, data: Record) => {
+ stored = { ...stored, id, ...data };
+ return Promise.resolve(stored);
+ }),
+ findById: vi.fn().mockImplementation(() => Promise.resolve(stored)),
+ hasPaidRecipient: vi.fn().mockResolvedValue(false),
+ recentCountForWallet: vi.fn().mockResolvedValue(0),
+ },
+ },
+ { provide: WalletService, useValue: { getOrThrow: vi.fn().mockResolvedValue(wallet) } },
+ {
+ provide: AgentService,
+ useValue: { getOrThrow: vi.fn().mockResolvedValue({ id: 'agent_1', status: AgentStatus.ACTIVE }) },
+ },
+ {
+ provide: PolicyService,
+ useValue: {
+ checkVelocityLimit: vi.fn().mockResolvedValue(undefined),
+ evaluateIntent: vi.fn().mockResolvedValue({
+ passed: true,
+ requiresApproval: false,
+ violations: [],
+ evaluatedPolicyIds: [],
+ }),
+ },
+ },
+ {
+ provide: RiskService,
+ useValue: {
+ evaluate: vi.fn().mockResolvedValue({
+ score: 10,
+ band: RiskBand.LOW,
+ factors: [],
+ canAutoExecute: true,
+ }),
+ },
+ },
+ { provide: BudgetService, useValue: { assertWithinBudget: vi.fn(), consume: vi.fn() } },
+ {
+ provide: StellarService,
+ useValue: {
+ submitPayment: vi.fn().mockResolvedValue({
+ hash: 'stellar_hash_1',
+ ledger: 100,
+ successful: true,
+ }),
+ },
+ },
+ { provide: EventBusService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
+ { provide: PrismaService, useValue: {} },
+ {
+ provide: SpendingLimitService,
+ useValue: {
+ aggregateSpend: vi.fn().mockResolvedValue({ spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 }),
+ evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
+ },
+ },
+ {
+ provide: AssetRegistryService,
+ useValue: {
+ canonicalize: (asset: string) =>
+ asset === 'USDC' ? 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' : asset,
+ },
+ },
+ ],
+ }).compile();
+ const anchoredService = module.get(TransactionService);
+ const anchoredStellar = module.get(StellarService);
+ const anchoredRepository = module.get(TransactionRepository);
+
+ await anchoredService.create('org_1', 'user_1', { ...input, asset: 'USDC' });
+
+ expect(anchoredRepository.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ asset: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ }),
+ );
+ expect(anchoredStellar.submitPayment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ asset: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ }),
+ );
+ });
+
+ it('aggregates the spend and checks the velocity limit against the canonical asset', async () => {
+ const aggregateSpend = vi
+ .fn()
+ .mockResolvedValue({ spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 });
+ const checkVelocityLimit = vi.fn().mockResolvedValue(undefined);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ TransactionService,
+ {
+ provide: TransactionRepository,
+ useValue: {
+ create: vi.fn().mockImplementation((data: Record) =>
+ Promise.resolve({ id: 'tx_1', ...data, status: TransactionStatus.DRAFT }),
+ ),
+ update: vi.fn().mockImplementation((id: string, data: Record) =>
+ Promise.resolve({ id, ...data }),
+ ),
+ findById: vi.fn().mockImplementation(() =>
+ Promise.resolve({ id: 'tx_1', amount: new Prisma.Decimal('50') }),
+ ),
+ hasPaidRecipient: vi.fn().mockResolvedValue(false),
+ recentCountForWallet: vi.fn().mockResolvedValue(0),
+ },
+ },
+ { provide: WalletService, useValue: { getOrThrow: vi.fn().mockResolvedValue(wallet) } },
+ {
+ provide: AgentService,
+ useValue: { getOrThrow: vi.fn().mockResolvedValue({ id: 'agent_1', status: AgentStatus.ACTIVE }) },
+ },
+ {
+ provide: PolicyService,
+ useValue: {
+ checkVelocityLimit,
+ evaluateIntent: vi.fn().mockResolvedValue({
+ passed: true,
+ requiresApproval: false,
+ violations: [],
+ evaluatedPolicyIds: [],
+ }),
+ },
+ },
+ {
+ provide: RiskService,
+ useValue: {
+ evaluate: vi.fn().mockResolvedValue({
+ score: 10,
+ band: RiskBand.LOW,
+ factors: [],
+ canAutoExecute: true,
+ }),
+ },
+ },
+ { provide: BudgetService, useValue: { assertWithinBudget: vi.fn(), consume: vi.fn() } },
+ {
+ provide: StellarService,
+ useValue: {
+ submitPayment: vi.fn().mockResolvedValue({
+ hash: 'stellar_hash_1',
+ ledger: 100,
+ successful: true,
+ }),
+ },
+ },
+ { provide: EventBusService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
+ { provide: PrismaService, useValue: {} },
+ {
+ provide: SpendingLimitService,
+ useValue: {
+ aggregateSpend,
+ evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
+ },
+ },
+ {
+ provide: AssetRegistryService,
+ useValue: {
+ canonicalize: (asset: string) =>
+ asset === 'USDC' ? 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' : asset,
+ },
+ },
+ ],
+ }).compile();
+ const canonicalService = module.get(TransactionService);
+
+ await canonicalService.create('org_1', 'user_1', { ...input, asset: 'USDC' });
+
+ // The spend of the anchor asset and the velocity limit are checked against
+ // the canonical identifier, so a bare ticker and its credit asset share
+ // one budget.
+ expect(aggregateSpend).toHaveBeenCalledWith(
+ 'agent_1',
+ 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ );
+ expect(checkVelocityLimit).toHaveBeenCalledWith(
+ 'org_1',
+ 'agent_1',
+ 50,
+ 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ 'user_1',
+ );
+ });
+
+ it('checks the budget against the canonical asset of the transfer', async () => {
+ const assertWithinBudget = vi.fn().mockResolvedValue(undefined);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ TransactionService,
+ {
+ provide: TransactionRepository,
+ useValue: {
+ create: vi.fn().mockImplementation((data: Record) =>
+ Promise.resolve({ id: 'tx_1', ...data, status: TransactionStatus.DRAFT }),
+ ),
+ update: vi.fn().mockImplementation((id: string, data: Record) =>
+ Promise.resolve({ id, ...data }),
+ ),
+ findById: vi.fn().mockImplementation(() =>
+ Promise.resolve({ id: 'tx_1', amount: new Prisma.Decimal('50') }),
+ ),
+ hasPaidRecipient: vi.fn().mockResolvedValue(false),
+ recentCountForWallet: vi.fn().mockResolvedValue(0),
+ },
+ },
+ { provide: WalletService, useValue: { getOrThrow: vi.fn().mockResolvedValue(wallet) } },
+ {
+ provide: AgentService,
+ useValue: { getOrThrow: vi.fn().mockResolvedValue({ id: 'agent_1', status: AgentStatus.ACTIVE }) },
+ },
+ {
+ provide: PolicyService,
+ useValue: {
+ checkVelocityLimit: vi.fn().mockResolvedValue(undefined),
+ evaluateIntent: vi.fn().mockResolvedValue({
+ passed: true,
+ requiresApproval: false,
+ violations: [],
+ evaluatedPolicyIds: [],
+ }),
+ },
+ },
+ {
+ provide: RiskService,
+ useValue: {
+ evaluate: vi.fn().mockResolvedValue({
+ score: 10,
+ band: RiskBand.LOW,
+ factors: [],
+ canAutoExecute: true,
+ }),
+ },
+ },
+ { provide: BudgetService, useValue: { assertWithinBudget, consume: vi.fn() } },
+ {
+ provide: StellarService,
+ useValue: {
+ submitPayment: vi.fn().mockResolvedValue({
+ hash: 'stellar_hash_1',
+ ledger: 100,
+ successful: true,
+ }),
+ },
+ },
+ { provide: EventBusService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
+ { provide: PrismaService, useValue: {} },
+ {
+ provide: SpendingLimitService,
+ useValue: {
+ aggregateSpend: vi.fn().mockResolvedValue({ spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 }),
+ evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
+ },
+ },
+ {
+ provide: AssetRegistryService,
+ useValue: { canonicalize: (asset: string) => asset },
+ },
+ ],
+ }).compile();
+ const budgetService = module.get(TransactionService);
+
+ await budgetService.create('org_1', 'user_1', { ...input, budgetId: 'budget_1' });
+
+ expect(assertWithinBudget).toHaveBeenCalledWith('org_1', 'budget_1', 50, 'XLM');
+ });
+});
+
+describe('TransactionService - multi-asset simulation', () => {
+ let service: TransactionService;
+ let policyService: { evaluateIntent: ReturnType };
+ let riskService: { assess: ReturnType };
+ let repository: { hasPaidRecipient: ReturnType; recentCountForWallet: ReturnType; create: ReturnType };
+ let stellarService: { submitPayment: ReturnType };
+
+ const multiAssetInput = {
+ walletId: 'wallet_1',
+ recipientAddress: Keypair.random().publicKey(),
+ transfers: [
+ { asset: 'XLM', amount: '10' },
+ { asset: 'USDC', amount: '20' },
+ ],
+ metadata: {},
+ };
+
+ beforeEach(() => {
+ repository = {
+ hasPaidRecipient: vi.fn().mockResolvedValue(false),
+ recentCountForWallet: vi.fn().mockResolvedValue(0),
+ create: vi.fn(),
+ };
+ policyService = {
+ evaluateIntent: vi.fn().mockResolvedValue({
+ passed: true,
+ requiresApproval: false,
+ violations: [],
+ }),
+ };
+ riskService = {
+ assess: vi.fn().mockReturnValue({
+ score: 10,
+ band: RiskBand.LOW,
+ factors: [],
+ canAutoExecute: true,
+ }),
+ };
+ stellarService = { submitPayment: vi.fn() };
+
+ service = new TransactionService(
+ repository as unknown as TransactionRepository,
+ {
+ getOrThrow: vi.fn().mockResolvedValue({
+ id: 'wallet_1',
+ status: WalletStatus.ACTIVE,
+ stellarAddress: Keypair.random().publicKey(),
+ network: 'TESTNET',
+ createdAt: new Date(),
+ }),
+ } as unknown as WalletService,
+ { getOrThrow: vi.fn() } as unknown as AgentService,
+ policyService as unknown as PolicyService,
+ riskService as unknown as RiskService,
+ {} as BudgetService,
+ stellarService as unknown as StellarService,
+ { emit: vi.fn().mockResolvedValue(undefined) } as unknown as EventBusService,
+ {} as PrismaService,
+ {
+ aggregateSpend: vi.fn().mockResolvedValue({ spentToday: 0, spentThisWeek: 0, spentThisMonth: 0 }),
+ evaluateSpendingLimits: vi.fn().mockResolvedValue(undefined),
+ } as unknown as SpendingLimitService,
+ {
+ canonicalize: (asset: string) =>
+ asset === 'USDC' ? 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' : asset,
+ } as unknown as AssetRegistryService,
+ );
+ });
+
+ it('evaluates every leg of a multi-asset payload', async () => {
+ const result = await service.simulate('org_1', multiAssetInput as never);
+
+ expect(result.wouldPass).toBe(true);
+ expect(result.transfers).toEqual([
+ { asset: 'XLM', amount: 10 },
+ { asset: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', amount: 20 },
+ ]);
+
+ const intent = policyService.evaluateIntent.mock.calls[0][0] as {
+ asset: string;
+ transfers: Array<{ asset: string; amount: number }>;
+ };
+ expect(intent.transfers).toHaveLength(2);
+ expect(intent.asset).toBe('XLM');
+ });
+
+ it('scores every leg and inherits the riskiest one', async () => {
+ riskService.assess
+ .mockReturnValueOnce({ score: 10, band: RiskBand.LOW, factors: [], canAutoExecute: true })
+ .mockReturnValueOnce({ score: 70, band: RiskBand.HIGH, factors: [], canAutoExecute: false });
+
+ const result = await service.simulate('org_1', multiAssetInput as never);
+
+ expect(riskService.assess).toHaveBeenCalledTimes(2);
+ expect(result.risk.band).toBe(RiskBand.HIGH);
+ expect(result.requiresApproval).toBe(true);
+ });
+
+ it('never persists or submits anything during a multi-asset dry run', async () => {
+ await service.simulate('org_1', multiAssetInput as never);
+
+ expect(repository.create).not.toHaveBeenCalled();
+ expect(stellarService.submitPayment).not.toHaveBeenCalled();
+ });
+
+ it('rejects a malformed multi-asset payload', async () => {
+ await expect(
+ service.simulate('org_1', {
+ ...multiAssetInput,
+ transfers: [
+ { asset: 'XLM', amount: '10' },
+ { asset: 'XLM', amount: '20' },
+ ],
+ } as never),
+ ).rejects.toBeInstanceOf(DomainException);
+ expect(policyService.evaluateIntent).not.toHaveBeenCalled();
+ });
});
describe('parseIdempotencyKey', () => {
diff --git a/src/modules/transactions/transaction.dto.spec.ts b/src/modules/transactions/transaction.dto.spec.ts
index 852177bf..7983abf6 100644
--- a/src/modules/transactions/transaction.dto.spec.ts
+++ b/src/modules/transactions/transaction.dto.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { createTransactionSchema } from './transaction.dto';
+import { createTransactionSchema, simulateTransactionSchema } from './transaction.dto';
describe('createTransactionSchema memo validation', () => {
const baseInput = {
@@ -46,6 +46,27 @@ describe('createTransactionSchema memo validation', () => {
).toThrow();
});
+ it('rejects an amount with more than seven decimal places', () => {
+ expect(() =>
+ createTransactionSchema.parse({ ...baseInput, amount: '1.12345678' }),
+ ).toThrow();
+ expect(() =>
+ simulateTransactionSchema.parse({
+ ...baseInput,
+ transfers: [{ asset: 'XLM', amount: '1.12345678' }],
+ }),
+ ).toThrow();
+ });
+
+ it('rejects a non-numeric amount', () => {
+ expect(() => createTransactionSchema.parse({ ...baseInput, amount: 'abc' })).toThrow();
+ });
+
+ it('rejects a malformed asset identifier', () => {
+ expect(() => createTransactionSchema.parse({ ...baseInput, asset: 'USDC:bad' })).toThrow();
+ expect(() => createTransactionSchema.parse({ ...baseInput, asset: '' })).toThrow();
+ });
+
it('rejects non-JSON values nested in metadata', () => {
expect(() =>
createTransactionSchema.parse({
@@ -240,3 +261,107 @@ describe('createTransactionSchema memo validation', () => {
});
});
});
+
+describe('createTransactionSchema asset validation', () => {
+ const baseInput = {
+ walletId: '550e8400-e29b-41d4-a716-446655440000',
+ amount: '10.0000000',
+ recipientAddress: 'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5',
+ };
+
+ it('defaults to the native asset', () => {
+ expect(createTransactionSchema.parse(baseInput).asset).toBe('XLM');
+ });
+
+ it('accepts a Stellar Anchor ticker', () => {
+ expect(createTransactionSchema.parse({ ...baseInput, asset: 'USDC' }).asset).toBe('USDC');
+ });
+
+ it('accepts an issued asset in its canonical CODE:ISSUER form', () => {
+ const asset = 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+ expect(createTransactionSchema.parse({ ...baseInput, asset }).asset).toBe(asset);
+ });
+
+ it('rejects an asset code that is too long', () => {
+ expect(() =>
+ createTransactionSchema.parse({ ...baseInput, asset: 'A'.repeat(13) }),
+ ).toThrow();
+ });
+
+ it('rejects an issued asset with a malformed issuer', () => {
+ expect(() =>
+ createTransactionSchema.parse({ ...baseInput, asset: 'USDC:not-an-issuer' }),
+ ).toThrow();
+ });
+
+ it('explains that a transaction moves a single asset when a transfers array is sent', () => {
+ expect(() =>
+ createTransactionSchema.parse({
+ ...baseInput,
+ transfers: [{ asset: 'XLM', amount: '1.0000000' }],
+ }),
+ ).toThrow('A transaction moves a single asset');
+ });
+});
+
+describe('simulateTransactionSchema multi-asset payloads', () => {
+ const baseInput = {
+ walletId: '550e8400-e29b-41d4-a716-446655440000',
+ recipientAddress: 'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5',
+ };
+
+ it('accepts a single-asset payload', () => {
+ const parsed = simulateTransactionSchema.parse({
+ ...baseInput,
+ asset: 'XLM',
+ amount: '10.0000000',
+ });
+ expect(parsed.amount).toBe('10.0000000');
+ expect(parsed.transfers).toBeUndefined();
+ });
+
+ it('accepts a multi-asset payload', () => {
+ const parsed = simulateTransactionSchema.parse({
+ ...baseInput,
+ transfers: [
+ { asset: 'XLM', amount: '10.0000000' },
+ { asset: 'USDC', amount: '25.5000000' },
+ ],
+ });
+ expect(parsed.transfers).toHaveLength(2);
+ expect(parsed.transfers?.[1]).toEqual({ asset: 'USDC', amount: '25.5000000' });
+ });
+
+ it('rejects a payload that mixes a single transfer with a transfers array', () => {
+ expect(() =>
+ simulateTransactionSchema.parse({
+ ...baseInput,
+ asset: 'XLM',
+ amount: '10.0000000',
+ transfers: [{ asset: 'USDC', amount: '1.0000000' }],
+ }),
+ ).toThrow('not both');
+ });
+
+ it('rejects a payload without any transfer', () => {
+ expect(() => simulateTransactionSchema.parse(baseInput)).toThrow('amount is required');
+ });
+
+ it('rejects a transfers array with an invalid amount', () => {
+ expect(() =>
+ simulateTransactionSchema.parse({
+ ...baseInput,
+ transfers: [{ asset: 'USDC', amount: '0' }],
+ }),
+ ).toThrow();
+ });
+
+ it('rejects a transfers array with more than ten legs', () => {
+ expect(() =>
+ simulateTransactionSchema.parse({
+ ...baseInput,
+ transfers: Array.from({ length: 11 }, () => ({ asset: 'USDC', amount: '1.0000000' })),
+ }),
+ ).toThrow();
+ });
+});
diff --git a/src/modules/transactions/transaction.dto.ts b/src/modules/transactions/transaction.dto.ts
index 12555d8b..b083cc3b 100644
--- a/src/modules/transactions/transaction.dto.ts
+++ b/src/modules/transactions/transaction.dto.ts
@@ -2,7 +2,9 @@ import { z } from 'zod';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { stellarMemoTypeSchema } from '../../common/validators/stellar-memo.schema';
import { stellarAddressSchema } from '../../common/validators/stellar-address.schema';
+import { stellarAssetSchema } from '../../common/validators/stellar-asset.schema';
import { ValidationException } from '../../common/exceptions/domain.exception';
+import { assetTransferSchema } from '../policies/transfer-payload';
const idempotencyKeyPattern = /^[A-Za-z0-9._~-]{1,128}$/;
@@ -34,118 +36,186 @@ const amountString = z
.regex(/^\d+(\.\d{1,7})?$/, 'Amount must be a positive decimal with up to 7 places')
.refine((v) => Number(v) > 0, 'Amount must be greater than zero');
-export const createTransactionSchema = z
- .object({
- walletId: z.string().uuid(),
- agentId: z.string().uuid().optional(),
- budgetId: z.string().uuid().optional(),
- asset: z.string().min(1).max(24).default('XLM'),
- amount: amountString,
- recipientAddress: stellarAddressSchema,
- memo: z.string().max(128).optional(),
- memoType: stellarMemoTypeSchema.optional(),
- memoValue: z.string().optional(),
- purpose: z.string().max(280).optional(),
- metadata: z.record(jsonValueSchema).default({}),
- })
- .strict()
- .superRefine((data, ctx) => {
- const hasTyped = data.memoType !== undefined;
- const hasLegacy = data.memo !== undefined;
+/** Fields shared by the create and simulate transaction payloads. */
+const transactionPayloadFields = {
+ walletId: z.string().uuid(),
+ agentId: z.string().uuid().optional(),
+ budgetId: z.string().uuid().optional(),
+ recipientAddress: stellarAddressSchema,
+ memo: z.string().max(128).optional(),
+ memoType: stellarMemoTypeSchema.optional(),
+ memoValue: z.string().optional(),
+ purpose: z.string().max(280).optional(),
+ metadata: z.record(jsonValueSchema).default({}),
+};
+
+/**
+ * Validates the memo rules shared by the create and simulate payloads: a
+ * typed memo requires a value, the value must fit the memo type and legacy
+ * text memos are limited to 28 bytes.
+ */
+function validateMemo(
+ data: { memo?: string; memoType?: string; memoValue?: string },
+ ctx: z.RefinementCtx,
+): void {
+ const hasTyped = data.memoType !== undefined;
+ const hasLegacy = data.memo !== undefined;
- if (!hasTyped && !hasLegacy) return;
+ if (!hasTyped && !hasLegacy) return;
- if (hasTyped && !data.memoValue) {
+ if (hasTyped && !data.memoValue) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'memoValue is required when memoType is specified',
+ path: ['memoValue'],
+ });
+ return;
+ }
+
+ if (!hasTyped && hasLegacy) {
+ const byteLength = new TextEncoder().encode(data.memo!).byteLength;
+ if (byteLength === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'memoValue is required when memoType is specified',
- path: ['memoValue'],
+ message: 'Memo cannot be empty',
+ path: ['memo'],
+ });
+ } else if (byteLength > 28) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Memo exceeds maximum length of 28 bytes (got ${byteLength} bytes)`,
+ path: ['memo'],
});
- return;
- }
-
- if (!hasTyped && hasLegacy) {
- const byteLength = new TextEncoder().encode(data.memo!).byteLength;
- if (byteLength === 0) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Memo cannot be empty',
- path: ['memo'],
- });
- } else if (byteLength > 28) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `Memo exceeds maximum length of 28 bytes (got ${byteLength} bytes)`,
- path: ['memo'],
- });
- }
- return;
}
+ return;
+ }
- if (hasTyped && data.memoValue !== undefined) {
- switch (data.memoType) {
- case 'text': {
- const byteLength = new TextEncoder().encode(data.memoValue).byteLength;
- if (byteLength === 0) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Text memo cannot be empty',
- path: ['memoValue'],
- });
- } else if (byteLength > 28) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `Text memo exceeds maximum length of 28 bytes (got ${byteLength} bytes)`,
- path: ['memoValue'],
- });
- }
- break;
+ if (hasTyped && data.memoValue !== undefined) {
+ switch (data.memoType) {
+ case 'text': {
+ const byteLength = new TextEncoder().encode(data.memoValue).byteLength;
+ if (byteLength === 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Text memo cannot be empty',
+ path: ['memoValue'],
+ });
+ } else if (byteLength > 28) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Text memo exceeds maximum length of 28 bytes (got ${byteLength} bytes)`,
+ path: ['memoValue'],
+ });
}
- case 'id': {
- if (!/^\d+$/.test(data.memoValue)) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Memo ID must be a non-negative integer string',
- path: ['memoValue'],
- });
- } else {
- try {
- const big = BigInt(data.memoValue);
- if (big < BigInt(0) || big > BigInt('18446744073709551615')) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Memo ID must be in range 0 to 18446744073709551615',
- path: ['memoValue'],
- });
- }
- } catch {
+ break;
+ }
+ case 'id': {
+ if (!/^\d+$/.test(data.memoValue)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Memo ID must be a non-negative integer string',
+ path: ['memoValue'],
+ });
+ } else {
+ try {
+ const big = BigInt(data.memoValue);
+ if (big < BigInt(0) || big > BigInt('18446744073709551615')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'Memo ID is not a valid unsigned 64-bit integer',
+ message: 'Memo ID must be in range 0 to 18446744073709551615',
path: ['memoValue'],
});
}
- }
- break;
- }
- case 'hash':
- case 'return': {
- if (!/^[0-9a-fA-F]{64}$/.test(data.memoValue)) {
+ } catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: `${data.memoType === 'hash' ? 'Hash' : 'Return'} memo must be exactly 64 hex characters (32 bytes)`,
+ message: 'Memo ID is not a valid unsigned 64-bit integer',
path: ['memoValue'],
});
}
- break;
}
+ break;
+ }
+ case 'hash':
+ case 'return': {
+ if (!/^[0-9a-fA-F]{64}$/.test(data.memoValue)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Memo ${data.memoType} must be exactly 64 hex characters (32 bytes)`,
+ path: ['memoValue'],
+ });
+ }
+ break;
}
}
+ }
+}
+
+/**
+ * Payload of `POST /transactions`. A transaction moves a single asset, so
+ * exactly one `asset`/`amount` pair is accepted. Multi-asset payloads are
+ * evaluated by the policy engine through the simulation endpoints.
+ */
+export const createTransactionSchema = z
+ .object({
+ ...transactionPayloadFields,
+ asset: stellarAssetSchema.default('XLM'),
+ amount: amountString,
+ transfers: z.array(assetTransferSchema).max(10).optional(),
+ })
+ .strict()
+ .superRefine((data, ctx) => {
+ if (data.transfers) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ 'A transaction moves a single asset. Submit one request per asset, or use POST /transactions/simulate to evaluate a multi-asset transfer against the spending policies.',
+ path: ['transfers'],
+ });
+ return;
+ }
+ validateMemo(data, ctx);
});
export type CreateTransactionInput = z.infer;
-export const simulateTransactionSchema = createTransactionSchema;
+/**
+ * Payload of `POST /transactions/simulate`. Accepts either a single-asset
+ * `asset`/`amount` pair or a multi-asset `transfers` array; the two forms are
+ * mutually exclusive.
+ */
+export const simulateTransactionSchema = z
+ .object({
+ ...transactionPayloadFields,
+ asset: stellarAssetSchema.optional(),
+ amount: amountString.optional(),
+ transfers: z.array(assetTransferSchema).min(1).max(10).optional(),
+ })
+ .strict()
+ .superRefine((data, ctx) => {
+ const hasTransfers = data.transfers !== undefined;
+ const hasSingle = data.amount !== undefined || data.asset !== undefined;
+
+ if (hasTransfers && hasSingle) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Provide either an asset/amount pair or a transfers array, not both',
+ path: ['transfers'],
+ });
+ return;
+ }
+
+ if (!hasTransfers && data.amount === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'amount is required when transfers is not provided',
+ path: ['amount'],
+ });
+ }
+
+ validateMemo(data, ctx);
+ });
+
export type SimulateTransactionInput = z.infer;
// ── Swagger DTOs ──
@@ -160,7 +230,11 @@ export class CreateTransactionDto {
@ApiPropertyOptional({ description: 'Budget to charge this transaction against' })
budgetId?: string;
- @ApiPropertyOptional({ example: 'XLM' })
+ @ApiPropertyOptional({
+ example: 'XLM',
+ description:
+ 'Asset to transfer. Either a ticker (`XLM`, `USDC`) or the canonical `CODE:ISSUER` form of a credit asset.',
+ })
asset?: string;
@ApiProperty({ example: '125.5000000' })
@@ -192,3 +266,12 @@ export class CreateTransactionDto {
@ApiPropertyOptional({ type: Object })
metadata?: Record;
}
+
+/** Swagger model for one leg of a multi-asset transfer payload. */
+export class AssetTransferDto {
+ @ApiProperty({ example: 'XLM', description: 'Asset code or `CODE:ISSUER` identifier' })
+ asset!: string;
+
+ @ApiProperty({ example: '125.5000000' })
+ amount!: string;
+}
diff --git a/src/modules/transactions/transaction.service.ts b/src/modules/transactions/transaction.service.ts
index 07d7c41e..2f670920 100644
--- a/src/modules/transactions/transaction.service.ts
+++ b/src/modules/transactions/transaction.service.ts
@@ -12,10 +12,16 @@ import {
} from '@prisma/client';
import { TransactionRepository } from './transaction.repository';
import { SpendingLimitService } from './spending-limit.service';
-import { CreateTransactionInput } from './transaction.dto';
+import { CreateTransactionInput, SimulateTransactionInput } from './transaction.dto';
import { TransactionsValidator } from './transactions.validator';
import { WalletService, toNetworkName } from '../wallets/wallet.service';
import { PolicyService } from '../policies/policy.service';
+import { AssetRegistryService } from '../policies/asset-registry.service';
+import {
+ ParsedTransfer,
+ TransferPayloadError,
+ parseTransferPayload,
+} from '../policies/transfer-payload';
import { RiskService } from '../risk/risk.service';
import { BudgetService } from '../budgets/budget.service';
import { StellarService } from '../stellar/stellar.service';
@@ -27,6 +33,7 @@ import { ErrorCode } from '../../common/constants/error-codes';
import {
ConflictException,
NotFoundException,
+ ValidationException,
} from '../../common/exceptions/domain.exception';
import {
buildPaginationMeta,
@@ -43,6 +50,9 @@ const Decimal = Prisma.Decimal;
const IDEMPOTENCY_OPERATION = 'transactions.create';
const IDEMPOTENCY_RETENTION_MS = 24 * 60 * 60 * 1000;
+/** The payload shapes the transaction pipeline accepts. */
+type TransactionPayload = CreateTransactionInput | SimulateTransactionInput;
+
function normalizeAmount(amount: string): string {
const [whole, fraction = ''] = amount.split('.');
const normalizedWhole = whole.replace(/^0+(?=\d)/, '');
@@ -96,6 +106,7 @@ export class TransactionService {
private readonly eventBus: EventBusService,
private readonly prisma: PrismaService,
private readonly spendingLimits: SpendingLimitService,
+ private readonly assetRegistry: AssetRegistryService,
) {}
async create(
@@ -159,22 +170,26 @@ export class TransactionService {
onTransactionCreated?: (transaction: Transaction) => Promise,
) {
const amount = Number(input.amount);
+ // Resolve the asset to its canonical identifier (`XLM`, `CODE:ISSUER`) so
+ // issued and Stellar Anchor assets are stored, aggregated and submitted
+ // under one stable identity.
+ const asset = this.assetRegistry.canonicalize(input.asset);
const memoValue = input.memoType && input.memoValue ? input.memoValue : input.memo;
const { wallet } = await this.preflight(organizationId, input);
// 2.5. Velocity limit check for agent spending
if (input.agentId) {
- await this.policies.checkVelocityLimit(organizationId, input.agentId, amount, input.asset, actorId);
+ await this.policies.checkVelocityLimit(organizationId, input.agentId, amount, asset, actorId);
}
// 2.6. Spending limit evaluation — atomically fetches daily/weekly/monthly
// aggregates and evaluates periodic budget caps. On violation, writes
// an audit log entry and throws PolicyViolationException (HTTP 422).
// Returns the aggregates so we can reuse them in step 3 below.
- const baseIntent = this.toIntent(organizationId, input, amount);
+ const baseIntent = this.toIntent(organizationId, input, amount, asset);
let enrichedIntent = baseIntent;
if (input.agentId) {
- const aggregates = await this.spendingLimits.aggregateSpend(input.agentId, input.asset);
+ const aggregates = await this.spendingLimits.aggregateSpend(input.agentId, asset);
enrichedIntent = {
...baseIntent,
spentToday: aggregates.spentToday,
@@ -206,9 +221,10 @@ export class TransactionService {
{ actorId },
);
- // 5. Budget headroom (no mutation yet).
+ // 5. Budget headroom (no mutation yet). The budget is denominated in a
+ // single currency, so it is only valid for a transfer of that asset.
if (input.budgetId) {
- await this.budgets.assertWithinBudget(organizationId, input.budgetId, amount);
+ await this.budgets.assertWithinBudget(organizationId, input.budgetId, amount, asset);
}
const requiresApproval = policyResult.requiresApproval || !assessment.canAutoExecute;
@@ -222,7 +238,7 @@ export class TransactionService {
? { policy: { connect: { id: policyResult.matchedPolicyId } } }
: {}),
...(input.budgetId ? { budget: { connect: { id: input.budgetId } } } : {}),
- asset: input.asset,
+ asset,
amount: new Decimal(input.amount),
senderAddress: wallet.stellarAddress,
recipientAddress: input.recipientAddress,
@@ -324,15 +340,33 @@ export class TransactionService {
}
/** Dry-run: runs every governance check without persisting or moving funds. */
- async simulate(organizationId: string, input: CreateTransactionInput) {
- const amount = Number(input.amount);
+ async simulate(organizationId: string, input: SimulateTransactionInput) {
+ const legs = this.parseLegs(input);
const { wallet } = await this.preflight(organizationId, input);
- const intent = this.toIntent(organizationId, input, amount);
+ const intent = this.toMultiAssetIntent(organizationId, input, legs);
const policyResult = await this.policies.evaluateIntent(intent);
- const assessment = this.risk.assess(
- await this.toRiskFactors(organizationId, wallet, input, amount, policyResult.violations.length),
+
+ // Every leg is scored on its own; the batch inherits the riskiest leg.
+ const assessments = await Promise.all(
+ legs.map(async (leg) =>
+ this.risk.assess(
+ await this.toRiskFactors(
+ organizationId,
+ wallet,
+ input,
+ leg.amount,
+ policyResult.violations.length,
+ leg.asset,
+ ),
+ ),
+ ),
);
- const requiresApproval = policyResult.requiresApproval || !assessment.canAutoExecute;
+ const assessment = assessments.reduce((worst, current) =>
+ current.score > worst.score ? current : worst,
+ );
+
+ const requiresApproval =
+ policyResult.requiresApproval || assessments.some((item) => !item.canAutoExecute);
return {
wouldPass: policyResult.passed,
requiresApproval,
@@ -340,6 +374,7 @@ export class TransactionService {
passed: policyResult.passed,
violations: policyResult.violations,
},
+ transfers: legs.map((leg) => ({ asset: leg.asset, amount: leg.amount })),
risk: assessment,
};
}
@@ -391,7 +426,7 @@ export class TransactionService {
/** Resolves and validates the sender wallet + agent before any evaluation. */
private async preflight(
organizationId: string,
- input: CreateTransactionInput,
+ input: TransactionPayload,
): Promise<{ wallet: Wallet; agent?: Agent }> {
const wallet = await this.wallets.getOrThrow(organizationId, input.walletId);
this.assertWalletSpendable(wallet);
@@ -428,24 +463,73 @@ export class TransactionService {
organizationId: string,
input: CreateTransactionInput,
amount: number,
+ asset: string,
): TransactionIntent {
return {
organizationId,
agentId: input.agentId,
walletId: input.walletId,
- asset: input.asset,
+ asset,
amount,
recipientAddress: input.recipientAddress,
at: new Date(),
};
}
+ /**
+ * Parses the legs of a transfer payload. A single-asset payload yields one
+ * leg; a multi-asset payload yields one leg per entry. The asset of every
+ * leg is resolved to its canonical identifier.
+ */
+ private parseLegs(input: CreateTransactionInput | SimulateTransactionInput): ParsedTransfer[] {
+ const payload = input as {
+ asset?: string;
+ amount?: string | number;
+ transfers?: Array<{ asset: string; amount: string | number }>;
+ };
+ try {
+ return parseTransferPayload(payload, (asset) => this.assetRegistry.canonicalize(asset));
+ } catch (error) {
+ if (error instanceof TransferPayloadError) {
+ throw new ValidationException(error.message);
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * Builds the intent of a (possibly multi-asset) payload. The first leg is
+ * the primary transfer; every leg is evaluated against the policy limits.
+ */
+ private toMultiAssetIntent(
+ organizationId: string,
+ input: CreateTransactionInput | SimulateTransactionInput,
+ legs: ParsedTransfer[],
+ ): TransactionIntent {
+ const primary = legs[0];
+ return {
+ organizationId,
+ agentId: input.agentId,
+ walletId: input.walletId,
+ asset: primary.asset,
+ amount: primary.amount,
+ recipientAddress: input.recipientAddress,
+ at: new Date(),
+ transfers: legs.map((leg) => ({
+ asset: leg.asset,
+ amount: leg.amount,
+ ...(leg.recipientAddress ? { recipientAddress: leg.recipientAddress } : {}),
+ })),
+ };
+ }
+
private async toRiskFactors(
organizationId: string,
wallet: Wallet,
- input: CreateTransactionInput,
+ input: TransactionPayload,
amount: number,
policyViolations: number,
+ asset?: string,
): Promise {
const [knownRecipient, recentTransactionCount] = await Promise.all([
this.repository.hasPaidRecipient(organizationId, input.recipientAddress),
@@ -456,7 +540,7 @@ export class TransactionService {
);
return {
amount,
- asset: input.asset,
+ asset: asset ?? input.asset ?? 'XLM',
knownRecipient,
recentTransactionCount,
walletAgeDays,
diff --git a/src/queues/queues.constants.ts b/src/queues/queues.constants.ts
index 6cbd4d43..9bac8f11 100644
--- a/src/queues/queues.constants.ts
+++ b/src/queues/queues.constants.ts
@@ -25,6 +25,8 @@ export const Queues = {
DeadLetter: 'dead-letter',
/** Audit logs cleanup job. */
AuditCleanup: 'audit-cleanup',
+ /** Audit log archival: moves expired audit rows into the compressed archive. */
+ AuditArchive: 'audit-archive',
} as const;
export type QueueName = (typeof Queues)[keyof typeof Queues];
diff --git a/src/utils/asset.util.spec.ts b/src/utils/asset.util.spec.ts
new file mode 100644
index 00000000..866df0a4
--- /dev/null
+++ b/src/utils/asset.util.spec.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+import {
+ ASSET_CODE_MAX_LENGTH,
+ assetCodeOf,
+ isNativeAsset,
+ isValidAssetCode,
+ isValidIssuerAddress,
+ parseAssetIdentifier,
+} from './asset.util';
+
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+describe('parseAssetIdentifier', () => {
+ it('parses the native asset', () => {
+ const parsed = parseAssetIdentifier('XLM');
+ expect(parsed).toEqual({ code: 'XLM', canonical: 'XLM', native: true });
+ });
+
+ it('parses a bare ticker', () => {
+ const parsed = parseAssetIdentifier('usdc');
+ expect(parsed).toEqual({ code: 'USDC', canonical: 'USDC', native: false });
+ });
+
+ it('parses a credit asset with its issuer', () => {
+ const parsed = parseAssetIdentifier(`USDC:${ISSUER}`);
+ expect(parsed).toEqual({
+ code: 'USDC',
+ issuer: ISSUER,
+ canonical: `USDC:${ISSUER}`,
+ native: false,
+ });
+ });
+
+ it('rejects an empty identifier', () => {
+ expect(() => parseAssetIdentifier(' ')).toThrow(/must not be empty/);
+ });
+
+ it('rejects a code longer than the Stellar maximum', () => {
+ expect(() => parseAssetIdentifier('A'.repeat(ASSET_CODE_MAX_LENGTH + 1))).toThrow(
+ /1-12 alphanumeric characters/,
+ );
+ });
+
+ it('rejects a malformed issuer', () => {
+ expect(() => parseAssetIdentifier('USDC:not-an-issuer')).toThrow(
+ /issuer must be a Stellar account address/,
+ );
+ });
+
+ it('rejects the native asset with an issuer', () => {
+ expect(() => parseAssetIdentifier(`XLM:${ISSUER}`)).toThrow(/has no issuer/);
+ });
+});
+
+describe('assetCodeOf', () => {
+ it('returns the code without the issuer', () => {
+ expect(assetCodeOf(`USDC:${ISSUER}`)).toBe('USDC');
+ expect(assetCodeOf('XLM')).toBe('XLM');
+ });
+});
+
+describe('isNativeAsset', () => {
+ it('is true only for the native asset', () => {
+ expect(isNativeAsset('XLM')).toBe(true);
+ expect(isNativeAsset('USDC')).toBe(false);
+ expect(isNativeAsset(`USDC:${ISSUER}`)).toBe(false);
+ });
+});
+
+describe('isValidAssetCode / isValidIssuerAddress', () => {
+ it('accepts alphanumeric codes only', () => {
+ expect(isValidAssetCode('USDC')).toBe(true);
+ expect(isValidAssetCode('US-DC')).toBe(false);
+ expect(isValidAssetCode('')).toBe(false);
+ });
+
+ it('accepts Stellar account addresses only', () => {
+ expect(isValidIssuerAddress(ISSUER)).toBe(true);
+ expect(isValidIssuerAddress('GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4K')).toBe(
+ false,
+ );
+ expect(isValidIssuerAddress('not-an-address')).toBe(false);
+ });
+});
diff --git a/src/utils/asset.util.ts b/src/utils/asset.util.ts
new file mode 100644
index 00000000..1e05a2eb
--- /dev/null
+++ b/src/utils/asset.util.ts
@@ -0,0 +1,96 @@
+/**
+ * Stellar asset identity helpers.
+ *
+ * A Stellar asset is either the native asset (`XLM`) or a credit asset issued
+ * by an account. The network identifies a credit asset by the pair
+ * `CODE:ISSUER`, while operators usually refer to well-known Stellar Anchor
+ * assets by their ticker alone (`USDC`).
+ *
+ * This module is deliberately dependency-free and side-effect free so the
+ * policy engine, the transaction pipeline and the API boundary can all share
+ * exactly one notion of "which asset is this?".
+ */
+
+/** Ticker of the native (Lumens) asset. */
+export const NATIVE_ASSET_CODE = 'XLM';
+
+/** Maximum length of a Stellar asset code. */
+export const ASSET_CODE_MAX_LENGTH = 12;
+
+/** A parsed Stellar asset identifier. */
+export interface AssetIdentifier {
+ /** Upper-case asset code, e.g. `XLM` or `USDC`. */
+ code: string;
+ /** Issuing account for credit assets; undefined for the native asset. */
+ issuer?: string;
+ /**
+ * Canonical identifier used for persistence, aggregation and comparison:
+ * `XLM` for the native asset, `CODE:ISSUER` for a resolved credit asset and
+ * a bare `CODE` when the issuer could not be resolved.
+ */
+ canonical: string;
+ /** True when the asset is the native asset. */
+ native: boolean;
+}
+
+/** True when the value is a syntactically valid Stellar asset code. */
+export function isValidAssetCode(value: string): boolean {
+ return /^[A-Za-z0-9]{1,12}$/.test(value);
+}
+
+/** True when the value looks like a Stellar account address (`G…`). */
+export function isValidIssuerAddress(value: string): boolean {
+ return /^G[A-Z2-7]{55}$/.test(value);
+}
+
+/**
+ * Parses an asset identifier of the form `CODE` or `CODE:ISSUER`.
+ *
+ * @throws Error when the identifier is malformed. API boundaries validate the
+ * payload with the Zod schema first, so a throw here signals a programming
+ * error rather than user input.
+ */
+export function parseAssetIdentifier(raw: string): AssetIdentifier {
+ const value = raw.trim();
+ if (!value) {
+ throw new Error('Asset identifier must not be empty');
+ }
+
+ const separator = value.indexOf(':');
+ if (separator === -1) {
+ if (!isValidAssetCode(value)) {
+ throw new Error(
+ `Invalid asset '${raw}': a bare asset code must be 1-12 alphanumeric characters`,
+ );
+ }
+ const code = value.toUpperCase();
+ return { code, canonical: code, native: code === NATIVE_ASSET_CODE };
+ }
+
+ const code = value.slice(0, separator).trim().toUpperCase();
+ const issuer = value.slice(separator + 1).trim();
+ if (!isValidAssetCode(code)) {
+ throw new Error(`Invalid asset '${raw}': the asset code must be 1-12 alphanumeric characters`);
+ }
+ if (!isValidIssuerAddress(issuer)) {
+ throw new Error(`Invalid asset '${raw}': the issuer must be a Stellar account address (G…)`);
+ }
+ if (code === NATIVE_ASSET_CODE) {
+ throw new Error(`Invalid asset '${raw}': the native asset ${NATIVE_ASSET_CODE} has no issuer`);
+ }
+ return { code, issuer, canonical: `${code}:${issuer}`, native: false };
+}
+
+/**
+ * Returns the upper-case asset code of an identifier, ignoring any issuer.
+ * `USDC:GA5Z…` and `USDC` both yield `USDC`, which is the key used by
+ * asset-scoped policy configuration.
+ */
+export function assetCodeOf(raw: string): string {
+ return parseAssetIdentifier(raw).code;
+}
+
+/** True when the identifier refers to the native asset. */
+export function isNativeAsset(raw: string): boolean {
+ return parseAssetIdentifier(raw).native;
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index a1c8b6b7..faddc17b 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,2 +1,3 @@
export * from './crypto.util';
export * from './decimal.util';
+export * from './asset.util';