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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Vault Reconciliation

`packages/orchestrator/src/reconciliation.ts` reconciles the off-chain vault
ledger (`vault-ledger.ts`) against the on-chain AgentVault contract, which is
treated as the source of truth.

## Scope

This worker reconciles **account-level** state only: a user's `balance` and
`total_spent` as reported by `getAccount()`, compared against the sum of
their local ledger entries (deposits, withdrawals, payments).

**Not covered:** task-level reconciliation against BudgetGuardian's
`getTask()`. BudgetGuardian is a separate contract that is not currently
wired into the live task pipeline (`server.ts` only calls
`agent-vault-client.ts` for `createTask`/`releasePayment`/`completeTask`), so
there's no live `vault_task_id` -> BudgetGuardian mapping to reconcile yet.
See the diff-model discussion on issue #105 for the two-pass design this
was scoped from.

## How it works

- **Dry-run (default):** `GET /reconciliation` computes a drift report and
changes nothing.
- **Repair:** `GET /reconciliation?repair=true` additionally appends a
corrective `adjustment` entry to the local ledger for any user with
balance drift, and writes an audit record for every field changed.
Comment thread
Times-stack marked this conversation as resolved.
- **Idempotent:** running repair twice in a row with no new drift makes no
further changes (the second run finds `local === chain` and does nothing).
- **Never writes on-chain.** The chain is read-only input; only the local
ledger and the audit log are ever modified.

## Drift classes

| Type | Meaning |
|---|---|
| `balance_mismatch` | Local derived balance (deposits - withdrawals - payments) differs from chain `balance` |
| `spent_mismatch` | Local summed `payment` entries differ from chain `total_spent` |

`budget_lock` ledger entries are informational only (they represent an
in-flight lock, not a settled movement) and are excluded from both totals,
mirroring how the chain's `balance`/`total_spent` only reflect settled
activity.

All comparisons are done in stroops (fixed-point), not floating-point USDC,
to avoid false-positive drift from rounding.

## Endpoints

- `GET /reconciliation` -- run a dry-run pass, return the full drift report.
- `GET /reconciliation?repair=true` -- run and apply repairs.
- `GET /reconciliation/audit` -- full append-only audit trail.
- `GET /reconciliation/audit?user_address=...` -- audit trail for one user.
- `GET /metrics` -- now includes a `reconciliation` block: `last_run`,
`last_mode`, `drift_count`, `repaired_count`.

## Data files

- `data/reconciliation-audit.json` -- append-only audit log. Never
overwritten; only ever appended to.
296 changes: 296 additions & 0 deletions packages/orchestrator/src/reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('fs', () => ({
default: {
mkdirSync: vi.fn(),
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => '[]'),
writeFileSync: vi.fn(),
},
}));

vi.mock('@clevercon/common', () => ({
writeJsonSafe: vi.fn(),
}));

vi.mock('./agent-vault-client.js', () => ({
getAccount: vi.fn(),
}));

vi.mock('./orchestrator-store.js', () => ({
all: vi.fn(),
}));

vi.mock('./vault-ledger.js', () => ({
getAllVaultTx: vi.fn(),
appendVaultTx: vi.fn(),
isLedgerAtRetentionCap: vi.fn(() => false),
}));

import { getAccount } from './agent-vault-client.js';
import * as orchestratorStore from './orchestrator-store.js';
import { getAllVaultTx, appendVaultTx } from './vault-ledger.js';
import { computeUserDrift, runReconciliation, getReconciliationSummary } from './reconciliation.js';

const USER = 'GABC123';

function ledgerEntry(
type: 'deposit' | 'withdrawal' | 'payment' | 'budget_lock' | 'adjustment',
amount: number,
extra: {
adjustment_target?: 'balance' | 'spent';
adjustment_direction?: 'increase' | 'decrease';
} = {},
) {
return {
id: 'x',
user_address: USER,
type,
amount_usdc: amount,
timestamp: new Date().toISOString(),
...extra,
};
}

function orchRecord() {
return {
user_address: USER,
orchestrator_name: 'test',
orchestrator_pubkey: 'pk',
orchestrator_secret: 'sk',
registered_on_chain: true,
created_at: new Date().toISOString(),
};
}

beforeEach(() => {
vi.clearAllMocks();
});

describe('computeUserDrift', () => {
it('reports no drift when local ledger matches chain', async () => {
vi.mocked(getAllVaultTx).mockReturnValue([
ledgerEntry('deposit', 100),
ledgerEntry('payment', 20),
]);
vi.mocked(getAccount).mockResolvedValue({
balance: 80,
available: 80,
locked: 0,
total_deposited: 100,
total_spent: 20,
active_tasks_count: 0,
});

const drift = await computeUserDrift(USER);

expect(drift.drift).toBe(false);
expect(drift.diffs).toHaveLength(0);
});

it('classifies balance_mismatch when local balance differs from chain', async () => {
vi.mocked(getAllVaultTx).mockReturnValue([ledgerEntry('deposit', 100)]);
vi.mocked(getAccount).mockResolvedValue({
balance: 90,
available: 90,
locked: 0,
total_deposited: 100,
total_spent: 0,
active_tasks_count: 0,
});

const drift = await computeUserDrift(USER);

expect(drift.drift).toBe(true);
const d = drift.diffs.find((x) => x.type === 'balance_mismatch');
expect(d).toBeDefined();
expect(d!.local_value).toBeCloseTo(100);
expect(d!.chain_value).toBeCloseTo(90);
expect(d!.delta_usdc).toBeCloseTo(-10);
});

it('classifies spent_mismatch when local spend differs from chain total_spent', async () => {
vi.mocked(getAllVaultTx).mockReturnValue([
ledgerEntry('deposit', 100),
ledgerEntry('payment', 20),
]);
vi.mocked(getAccount).mockResolvedValue({
balance: 80,
available: 80,
locked: 0,
total_deposited: 100,
total_spent: 25,
active_tasks_count: 0,
});

const drift = await computeUserDrift(USER);

expect(drift.drift).toBe(true);
const d = drift.diffs.find((x) => x.type === 'spent_mismatch');
expect(d).toBeDefined();
expect(d!.local_value).toBeCloseTo(20);
expect(d!.chain_value).toBeCloseTo(25);
});

it('applies adjustment entries to the correct total based on their target', async () => {
vi.mocked(getAllVaultTx).mockReturnValue([
ledgerEntry('deposit', 100),
ledgerEntry('payment', 20),
ledgerEntry('adjustment', 5, {
adjustment_target: 'spent',
adjustment_direction: 'increase',
}),
]);
vi.mocked(getAccount).mockResolvedValue({
balance: 80,
available: 80,
locked: 0,
total_deposited: 100,
total_spent: 25, // local spent = 20 + 5 adjustment = 25, matches chain
active_tasks_count: 0,
});

const drift = await computeUserDrift(USER);

expect(drift.drift).toBe(false);
});

it('treats budget_lock entries as informational (no balance/spend effect)', async () => {
vi.mocked(getAllVaultTx).mockReturnValue([
ledgerEntry('deposit', 100),
ledgerEntry('budget_lock', 30),
]);
vi.mocked(getAccount).mockResolvedValue({
balance: 100,
available: 70,
locked: 30,
total_deposited: 100,
total_spent: 0,
active_tasks_count: 1,
});

const drift = await computeUserDrift(USER);

expect(drift.drift).toBe(false);
});

it('treats a user with no on-chain account as zero balance/spend', async () => {
vi.mocked(getAllVaultTx).mockReturnValue([]);
vi.mocked(getAccount).mockResolvedValue(null);

const drift = await computeUserDrift(USER);

expect(drift.drift).toBe(false);
expect(drift.chain.balance_usdc).toBe(0);
});
});

describe('runReconciliation', () => {
it('dry-run mode reports drift but never calls appendVaultTx', async () => {
vi.mocked(orchestratorStore.all).mockReturnValue([orchRecord()]);
vi.mocked(getAllVaultTx).mockReturnValue([ledgerEntry('deposit', 100)]);
vi.mocked(getAccount).mockResolvedValue({
balance: 50,
available: 50,
locked: 0,
total_deposited: 100,
total_spent: 0,
active_tasks_count: 0,
});

const report = await runReconciliation();

expect(report.mode).toBe('dry-run');
expect(report.users_with_drift).toBe(1);
expect(report.repaired_count).toBe(0);
expect(appendVaultTx).not.toHaveBeenCalled();
});

it('repair mode corrects both balance_mismatch and spent_mismatch independently', async () => {
vi.mocked(orchestratorStore.all).mockReturnValue([orchRecord()]);
vi.mocked(getAllVaultTx).mockReturnValue([
ledgerEntry('deposit', 100),
ledgerEntry('payment', 20),
]);
vi.mocked(getAccount).mockResolvedValue({
balance: 50, // local computes 80 -> balance drift
available: 50,
locked: 0,
total_deposited: 100,
total_spent: 25, // local computes 20 -> spent drift
active_tasks_count: 0,
});

const report = await runReconciliation({ repair: true });

expect(report.mode).toBe('repair');
expect(report.repaired_count).toBe(2); // both diffs corrected

expect(appendVaultTx).toHaveBeenCalledWith(
expect.objectContaining({
user_address: USER,
type: 'adjustment',
adjustment_target: 'balance',
}),
);
expect(appendVaultTx).toHaveBeenCalledWith(
expect.objectContaining({
user_address: USER,
type: 'adjustment',
adjustment_target: 'spent',
}),
);

const summary = getReconciliationSummary();
expect(summary.last_mode).toBe('repair');
expect(summary.drift_count).toBe(1);
});

it('is idempotent: replaying the applied adjustments makes a second repair a no-op', async () => {
vi.mocked(orchestratorStore.all).mockReturnValue([orchRecord()]);
// First run: drift on both balance and spend.
vi.mocked(getAllVaultTx).mockReturnValueOnce([
ledgerEntry('deposit', 100),
ledgerEntry('payment', 20),
]);
vi.mocked(getAccount).mockResolvedValue({
balance: 50,
available: 50,
locked: 0,
total_deposited: 100,
total_spent: 25,
active_tasks_count: 0,
});

const first = await runReconciliation({ repair: true });
expect(first.repaired_count).toBe(2);

// Second run: ledger now includes the two corrective adjustment entries
// the first run appended -- local should now match chain exactly.
vi.mocked(getAllVaultTx).mockReturnValueOnce([
ledgerEntry('deposit', 100),
ledgerEntry('payment', 20),
ledgerEntry('adjustment', 30, {
adjustment_target: 'balance',
adjustment_direction: 'decrease',
}),
ledgerEntry('adjustment', 5, {
adjustment_target: 'spent',
adjustment_direction: 'increase',
}),
]);

vi.mocked(appendVaultTx).mockClear();
const second = await runReconciliation({ repair: true });

expect(second.users_with_drift).toBe(0);
expect(second.repaired_count).toBe(0);
expect(appendVaultTx).not.toHaveBeenCalled();
});

it('surfaces ledger_at_retention_cap on the report', async () => {
vi.mocked(orchestratorStore.all).mockReturnValue([]);
const report = await runReconciliation();
expect(report).toHaveProperty('ledger_at_retention_cap');
});
Comment thread
Times-stack marked this conversation as resolved.
});
Loading
Loading