diff --git a/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md b/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md
new file mode 100644
index 000000000..ec24392c6
--- /dev/null
+++ b/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md
@@ -0,0 +1,285 @@
+---
+sidebar_position: 6
+---
+
+# eCurrency: Accounts and Ledger MetaEnvelopes
+
+This guide covers how eCurrency stores account and transaction data as MetaEnvelopes on user eVaults. If you are building a feature that reads balances, displays transaction history, or initiates transfers, this is the reference you need.
+
+## Ontology IDs
+
+| Type | Ontology ID | Description |
+|------|-------------|-------------|
+| Ledger | `550e8400-e29b-41d4-a716-446655440006` | Individual transaction entries (debits/credits) |
+| Currency | `550e8400-e29b-41d4-a716-446655440008` | Currency definitions |
+| Account | `6fda64db-fd14-4fa2-bd38-77d2e5e6136d` | Account snapshots (holder + currency + balance) |
+
+## Data Model Overview
+
+```mermaid
+graph TD
+ subgraph eVault["User's eVault"]
+ Account["Account MetaEnvelope
ontology: 6fda64db..."]
+ Ledger1["Ledger MetaEnvelope
ontology: 550e8400...06
(credit)"]
+ Ledger2["Ledger MetaEnvelope
ontology: 550e8400...06
(debit)"]
+ end
+
+ subgraph GroupVault["Group's eVault"]
+ Currency["Currency MetaEnvelope
ontology: 550e8400...08"]
+ TreasuryAccount["Account MetaEnvelope
(group treasury)"]
+ TreasuryLedger["Ledger MetaEnvelopes
(mints / burns)"]
+ end
+
+ Account -- "currencyEname links to" --> Currency
+ Ledger1 -- "currencyId links to" --> Currency
+ Ledger2 -- "currencyId links to" --> Currency
+ Account -- "accountId matches" --> Ledger1
+ Account -- "accountId matches" --> Ledger2
+```
+
+## Account MetaEnvelope
+
+An account represents a user's (or group's) holdings in a specific currency. One account MetaEnvelope exists per holder-currency pair.
+
+### Payload Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `accountId` | `string` | The holder's ID. Matches `accountId` on ledger MetaEnvelopes |
+| `accountEname` | `string` | Global eName of the holder (prefixed with `@`) |
+| `accountType` | `"user"` or `"group"` | Whether the holder is a user or a group treasury |
+| `currencyEname` | `string` | Global eName of the currency (prefixed with `@`) |
+| `currencyName` | `string` | Display name of the currency |
+| `balance` | `number` | Current balance at time of creation |
+| `createdAt` | `string` (ISO 8601) | When the first transaction on this account occurred |
+
+### Example
+
+```json
+{
+ "accountId": "f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7",
+ "accountEname": "@35a31f0d-dd76-5780-b383-29f219fcae99",
+ "accountType": "user",
+ "currencyEname": "@d8d3fbb7-70d1-46c6-b8ba-ae1ee701060c",
+ "currencyName": "MetaCoin",
+ "balance": 741,
+ "createdAt": "2026-01-15T10:30:00.000Z"
+}
+```
+
+### Where It Lives
+
+Account MetaEnvelopes are stored on the **account holder's** eVault. A user who holds 3 different currencies will have 3 account MetaEnvelopes on their eVault.
+
+## Ledger MetaEnvelope
+
+Each ledger entry represents a single debit or credit. Transfers produce two ledger entries: one debit on the sender and one credit on the receiver.
+
+### Payload Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `currencyId` | `string` | ID of the currency (links to currency MetaEnvelope) |
+| `accountId` | `string` | The account this entry belongs to |
+| `accountType` | `"user"` or `"group"` | Type of account holder |
+| `amount` | `number` | Signed amount. Positive for credits, negative for debits |
+| `type` | `"credit"` or `"debit"` | Entry type |
+| `description` | `string` | Human-readable description of the transaction |
+| `senderAccountId` | `string` | Account ID of the sender (for transfers) |
+| `senderAccountType` | `"user"` or `"group"` | Sender's account type |
+| `receiverAccountId` | `string` | Account ID of the receiver (for transfers) |
+| `receiverAccountType` | `"user"` or `"group"` | Receiver's account type |
+| `balance` | `number` | Running balance after this entry |
+| `hash` | `string` | SHA-256 hash of this entry (integrity chain) |
+| `prevHash` | `string` | Hash of the previous entry in the chain |
+| `createdAt` | `string` (ISO 8601) | When the entry was created |
+
+### Example: Transfer
+
+When Alice sends 50 MetaCoin to Bob, two ledger MetaEnvelopes are created:
+
+**Debit on Alice's eVault:**
+
+```json
+{
+ "currencyId": "d8d3fbb7-70d1-46c6-b8ba-ae1ee701060c",
+ "accountId": "a1b2c3d4-...",
+ "accountType": "user",
+ "amount": -50,
+ "type": "debit",
+ "description": "Transfer to user:e5f6g7h8-...",
+ "senderAccountId": "a1b2c3d4-...",
+ "senderAccountType": "user",
+ "receiverAccountId": "e5f6g7h8-...",
+ "receiverAccountType": "user",
+ "balance": 691,
+ "hash": "a3f7...",
+ "prevHash": "9c1d...",
+ "createdAt": "2026-03-28T14:00:00.000Z"
+}
+```
+
+**Credit on Bob's eVault:**
+
+```json
+{
+ "currencyId": "d8d3fbb7-70d1-46c6-b8ba-ae1ee701060c",
+ "accountId": "e5f6g7h8-...",
+ "accountType": "user",
+ "amount": 50,
+ "type": "credit",
+ "description": "Transfer from user:a1b2c3d4-...",
+ "senderAccountId": "a1b2c3d4-...",
+ "senderAccountType": "user",
+ "receiverAccountId": "e5f6g7h8-...",
+ "receiverAccountType": "user",
+ "balance": 150,
+ "hash": "b4e8...",
+ "prevHash": "d2f0...",
+ "createdAt": "2026-03-28T14:00:00.000Z"
+}
+```
+
+### Where It Lives
+
+Ledger MetaEnvelopes are stored on the eVault of the account holder for that entry. In a transfer, the debit lives on the sender's eVault and the credit lives on the receiver's eVault.
+
+## Currency MetaEnvelope
+
+Currencies are defined per group and stored on the eVaults of group admins.
+
+### Payload Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `name` | `string` | Currency display name |
+| `description` | `string` | Currency description |
+| `ename` | `string` | Global eName of the currency |
+| `groupId` | `string` | ID of the group that owns this currency |
+| `allowNegative` | `boolean` | Whether accounts can go below zero |
+| `maxNegativeBalance` | `number` | Floor for negative balances (if allowed) |
+| `allowNegativeGroupOnly` | `boolean` | If true, only group members can overdraft |
+| `createdBy` | `string` | ID of the admin who created it |
+| `createdAt` | `string` (ISO 8601) | Creation timestamp |
+
+## Querying an eVault
+
+### Get All Accounts for a User
+
+```graphql
+query GetAccounts {
+ metaEnvelopes(
+ filter: { ontologyId: "6fda64db-fd14-4fa2-bd38-77d2e5e6136d" }
+ first: 100
+ ) {
+ edges {
+ node {
+ id
+ parsed
+ }
+ }
+ }
+}
+```
+
+This returns all account MetaEnvelopes on the user's eVault. Each one represents a currency the user holds.
+
+### Get Transaction History for a User
+
+```graphql
+query GetLedgerEntries {
+ metaEnvelopes(
+ filter: { ontologyId: "550e8400-e29b-41d4-a716-446655440006" }
+ first: 50
+ ) {
+ edges {
+ node {
+ id
+ parsed
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+}
+```
+
+### Filter by Currency
+
+To get ledger entries for a specific currency, fetch all ledger MetaEnvelopes and filter client-side by `parsed.currencyId`.
+
+## Transaction Flow
+
+```mermaid
+sequenceDiagram
+ participant Sender as Sender's Platform
+ participant API as eCurrency API
+ participant SenderVault as Sender's eVault
+ participant ReceiverVault as Receiver's eVault
+
+ Sender->>API: POST /transfer
+ Note over API: Validate balance,
check negative rules
+
+ API->>API: Create debit ledger entry
(sender account, -amount)
+ API->>API: Create credit ledger entry
(receiver account, +amount)
+ API->>API: Compute hash chain
+
+ API-->>SenderVault: Sync debit ledger MetaEnvelope
+ API-->>ReceiverVault: Sync credit ledger MetaEnvelope
+
+ Note over SenderVault: Debit entry stored
with updated balance
+ Note over ReceiverVault: Credit entry stored
with updated balance
+```
+
+## Linking Accounts to Ledger Entries
+
+The `accountId` field is the primary key that ties everything together:
+
+```mermaid
+graph LR
+ Account["Account MetaEnvelope
accountId: abc-123
balance: 741"]
+ L1["Ledger Entry
accountId: abc-123
amount: +100"]
+ L2["Ledger Entry
accountId: abc-123
amount: -50"]
+ L3["Ledger Entry
accountId: abc-123
amount: +691"]
+
+ Account --- L1
+ Account --- L2
+ Account --- L3
+```
+
+To reconstruct the full picture for a given user and currency:
+
+1. Query the eVault for account MetaEnvelopes (ontology `6fda64db-fd14-4fa2-bd38-77d2e5e6136d`)
+2. Pick the account matching the desired `currencyEname`
+3. Use `accountId` from that account to filter ledger MetaEnvelopes (ontology `550e8400-e29b-41d4-a716-446655440006`) where `parsed.accountId` matches
+
+## Mint and Burn
+
+Minting and burning operate on the **group treasury account** (where `accountType = "group"`).
+
+- **Mint**: A credit entry is added to the group's account, increasing total supply
+- **Burn**: A debit entry is added to the group's account, decreasing total supply
+
+These entries have no `senderAccountId`/`receiverAccountId` since they are not transfers between two parties.
+
+## Negative Balances
+
+Currencies can be configured to allow negative balances:
+
+| Setting | Behavior |
+|---------|----------|
+| `allowNegative = false` | Balance cannot go below 0 |
+| `allowNegative = true` | Balance can go negative |
+| `maxNegativeBalance = -500` | Balance cannot go below -500 |
+| `allowNegativeGroupOnly = true` | Only group members can overdraft; non-members are capped at 0 |
+
+## Hash Chain Integrity
+
+Ledger entries form a hash chain per currency. Each entry's `hash` is computed from:
+
+- All fields of the entry (id, currencyId, accountId, amount, type, etc.)
+- The `prevHash` (hash of the previous entry in that currency's chain)
+
+This means tampering with any historical entry breaks the chain for all subsequent entries.
diff --git a/platforms/ecurrency/api/package.json b/platforms/ecurrency/api/package.json
index cd4ee5912..b05d53605 100644
--- a/platforms/ecurrency/api/package.json
+++ b/platforms/ecurrency/api/package.json
@@ -10,7 +10,8 @@
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts",
"migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts",
- "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts"
+ "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts",
+ "backfill:accounts": "ts-node src/scripts/backfill-accounts.ts"
},
"dependencies": {
"axios": "^1.6.7",
diff --git a/platforms/ecurrency/api/src/scripts/backfill-accounts.ts b/platforms/ecurrency/api/src/scripts/backfill-accounts.ts
new file mode 100644
index 000000000..ea6158b26
--- /dev/null
+++ b/platforms/ecurrency/api/src/scripts/backfill-accounts.ts
@@ -0,0 +1,407 @@
+import "reflect-metadata";
+import path from "node:path";
+import { config } from "dotenv";
+
+config({ path: path.resolve(__dirname, "../../../../.env") });
+
+import axios from "axios";
+import { AppDataSource } from "../database/data-source";
+import { Ledger, AccountType } from "../database/entities/Ledger";
+import { Currency } from "../database/entities/Currency";
+import { User } from "../database/entities/User";
+import { Group } from "../database/entities/Group";
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const ACCOUNT_ONTOLOGY = "6fda64db-fd14-4fa2-bd38-77d2e5e6136d";
+const BATCH_SIZE = 10;
+const DRY_RUN = process.argv.includes("--dry-run");
+const REGISTRY_URL = "https://registry.w3ds.metastate.foundation";
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function normalizeEname(value: string): string {
+ return value.startsWith("@") ? value : `@${value}`;
+}
+
+let _platformToken: string | null = null;
+
+async function getPlatformToken(forceRefresh = false): Promise {
+ if (_platformToken && !forceRefresh) return _platformToken;
+ const response = await axios.post(
+ `${REGISTRY_URL}/platforms/certification`,
+ { platform: "ecurrency" },
+ { timeout: 5_000 }
+ );
+ _platformToken = response.data.token as string;
+ return _platformToken;
+}
+
+async function resolveEVaultUrl(eName: string): Promise {
+ const normalized = eName.startsWith("@") ? eName : `@${eName}`;
+ const response = await axios.get(
+ `${REGISTRY_URL}/resolve?w3id=${encodeURIComponent(normalized)}`,
+ { timeout: 5_000 }
+ );
+ const url = response.data?.evaultUrl || response.data?.uri;
+ if (!url) throw new Error(`Registry returned no eVault URL for ${normalized}`);
+ return url as string;
+}
+
+// ─── eVault write ─────────────────────────────────────────────────────────────
+
+interface BulkInput {
+ ontology: string;
+ payload: Record;
+ acl: string[];
+}
+
+const LEDGER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440006";
+
+const FETCH_EXISTING_QUERY = `
+ query FetchExisting($first: Int!, $after: String, $filter: MetaEnvelopeFilterInput) {
+ metaEnvelopes(first: $first, after: $after, filter: $filter) {
+ edges {
+ node { id ontology parsed }
+ }
+ pageInfo { hasNextPage endCursor }
+ totalCount
+ }
+ }
+`;
+
+async function fetchExistingEnvelopes(
+ evaultUrl: string,
+ vaultOwnerEname: string,
+ token: string,
+ ontology: string
+): Promise> {
+ const graphqlUrl = new URL("/graphql", evaultUrl).toString();
+ const all: Array<{ id: string; parsed: any }> = [];
+ let cursor: string | null = null;
+ let hasNextPage = true;
+
+ while (hasNextPage) {
+ const response: any = await axios.post(
+ graphqlUrl,
+ {
+ query: FETCH_EXISTING_QUERY,
+ variables: {
+ first: 100,
+ after: cursor,
+ filter: { ontologyId: ontology },
+ },
+ },
+ {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ "X-ENAME": vaultOwnerEname,
+ },
+ timeout: 5_000,
+ }
+ );
+
+ if (response.data.errors) {
+ throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
+ }
+
+ const data: any = response.data.data.metaEnvelopes;
+ for (const edge of data.edges) {
+ all.push({ id: edge.node.id, parsed: edge.node.parsed });
+ }
+ hasNextPage = data.pageInfo.hasNextPage;
+ cursor = data.pageInfo.endCursor;
+ }
+
+ return all;
+}
+
+const BULK_CREATE_MUTATION = `
+ mutation BulkCreate($inputs: [BulkMetaEnvelopeInput!]!) {
+ bulkCreateMetaEnvelopes(inputs: $inputs, skipWebhooks: true) {
+ successCount
+ errorCount
+ results { id success error }
+ }
+ }
+`;
+
+async function bulkCreateOnEVault(
+ evaultUrl: string,
+ vaultOwnerEname: string,
+ token: string,
+ inputs: BulkInput[]
+): Promise<{ successCount: number; errorCount: number }> {
+ const graphqlUrl = new URL("/graphql", evaultUrl).toString();
+ let totalSuccess = 0;
+ let totalErrors = 0;
+
+ for (let i = 0; i < inputs.length; i += BATCH_SIZE) {
+ const batch = inputs.slice(i, i + BATCH_SIZE);
+ let response: any;
+
+ try {
+ response = await axios.post(
+ graphqlUrl,
+ { query: BULK_CREATE_MUTATION, variables: { inputs: batch } },
+ {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ "X-ENAME": vaultOwnerEname,
+ },
+ timeout: 5_000,
+ }
+ );
+ } catch (err: any) {
+ if (err?.response?.status === 401) {
+ const freshToken = await getPlatformToken(true);
+ response = await axios.post(
+ graphqlUrl,
+ { query: BULK_CREATE_MUTATION, variables: { inputs: batch } },
+ {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${freshToken}`,
+ "X-ENAME": vaultOwnerEname,
+ },
+ timeout: 5_000,
+ }
+ );
+ } else {
+ throw err;
+ }
+ }
+
+ if (response.data.errors) {
+ throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
+ }
+
+ const result = response.data.data.bulkCreateMetaEnvelopes;
+ totalSuccess += result.successCount as number;
+ totalErrors += result.errorCount as number;
+
+ for (const r of result.results as Array<{ id: string; success: boolean; error?: string }>) {
+ if (!r.success) {
+ console.error(`[BACKFILL ERROR] Envelope ${r.id}: ${r.error}`);
+ }
+ }
+ }
+
+ return { successCount: totalSuccess, errorCount: totalErrors };
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────────
+
+async function main() {
+ console.log(`[BACKFILL] Starting eCurrency account backfill${DRY_RUN ? " (DRY RUN)" : ""}`);
+ console.log(`[BACKFILL] Ontology: ${ACCOUNT_ONTOLOGY}`);
+
+ await AppDataSource.initialize();
+
+ const ledgerRepo = AppDataSource.getRepository(Ledger);
+ const currencyRepo = AppDataSource.getRepository(Currency);
+ const userRepo = AppDataSource.getRepository(User);
+ const groupRepo = AppDataSource.getRepository(Group);
+
+ const token = await getPlatformToken();
+ console.log("[BACKFILL] Platform token acquired");
+
+ // Find all distinct (accountId, accountType, currencyId) combos from ledger
+ const distinctAccounts: Array<{
+ accountId: string;
+ accountType: AccountType;
+ currencyId: string;
+ }> = await ledgerRepo
+ .createQueryBuilder("ledger")
+ .select("ledger.accountId", "accountId")
+ .addSelect("ledger.accountType", "accountType")
+ .addSelect("ledger.currencyId", "currencyId")
+ .distinct(true)
+ .getRawMany();
+
+ console.log(`[BACKFILL] Found ${distinctAccounts.length} unique accounts across all currencies`);
+
+ // Cache currency lookups
+ const currencyCache = new Map();
+ // Cache ename lookups
+ const enameCache = new Map();
+
+ async function getCurrency(currencyId: string): Promise {
+ if (currencyCache.has(currencyId)) return currencyCache.get(currencyId)!;
+ const currency = await currencyRepo.findOne({ where: { id: currencyId } });
+ currencyCache.set(currencyId, currency);
+ return currency;
+ }
+
+ async function getAccountEname(accountId: string, accountType: AccountType): Promise {
+ const key = `${accountType}:${accountId}`;
+ if (enameCache.has(key)) return enameCache.get(key)!;
+
+ let ename: string | null = null;
+ if (accountType === AccountType.USER) {
+ const user = await userRepo.findOne({ where: { id: accountId } });
+ ename = user?.ename ? normalizeEname(user.ename) : null;
+ } else {
+ const group = await groupRepo.findOne({ where: { id: accountId } });
+ ename = group?.ename ? normalizeEname(group.ename) : null;
+ }
+
+ enameCache.set(key, ename);
+ return ename;
+ }
+
+ let totalBackfilled = 0;
+ let totalSkipped = 0;
+ let totalErrors = 0;
+
+ // Group by account holder ename for batched eVault writes
+ const vaultGroups = new Map();
+
+ for (let i = 0; i < distinctAccounts.length; i++) {
+ const { accountId, accountType, currencyId } = distinctAccounts[i];
+
+ const accountEname = await getAccountEname(accountId, accountType);
+ if (!accountEname) {
+ console.warn(`[BACKFILL WARNING] ${accountType} ${accountId} has no ename — skipping`);
+ totalSkipped++;
+ continue;
+ }
+
+ const currency = await getCurrency(currencyId);
+ if (!currency) {
+ console.warn(`[BACKFILL WARNING] Currency ${currencyId} not found — skipping`);
+ totalSkipped++;
+ continue;
+ }
+
+ const currencyEname = currency.ename ? normalizeEname(currency.ename) : null;
+ if (!currencyEname) {
+ console.warn(`[BACKFILL WARNING] Currency ${currencyId} has no ename — skipping`);
+ totalSkipped++;
+ continue;
+ }
+
+ // Get current balance from latest ledger entry
+ const latestEntry = await ledgerRepo.findOne({
+ where: { currencyId, accountId, accountType },
+ order: { createdAt: "DESC" },
+ });
+ const balance = latestEntry ? Number(latestEntry.balance) : 0;
+
+ // Get first entry for createdAt
+ const firstEntry = await ledgerRepo.findOne({
+ where: { currencyId, accountId, accountType },
+ order: { createdAt: "ASC" },
+ });
+
+ const payload: Record = {
+ accountId,
+ accountEname,
+ accountType,
+ currencyEname,
+ currencyName: currency.name,
+ balance,
+ createdAt: firstEntry?.createdAt?.toISOString() ?? new Date().toISOString(),
+ };
+
+ if (!vaultGroups.has(accountEname)) {
+ vaultGroups.set(accountEname, []);
+ }
+ vaultGroups.get(accountEname)!.push({
+ ontology: ACCOUNT_ONTOLOGY,
+ payload,
+ acl: ["*"],
+ });
+
+ if ((i + 1) % 50 === 0) {
+ console.log(`[BACKFILL] Processed ${i + 1}/${distinctAccounts.length} accounts`);
+ }
+ }
+
+ console.log(`[BACKFILL] Built ${vaultGroups.size} vault groups, writing...`);
+
+ // Write to eVaults
+ for (const [vaultOwnerEname, inputs] of vaultGroups) {
+ console.log(`[BACKFILL] Writing ${inputs.length} account(s) to vault ${vaultOwnerEname}...`);
+
+ try {
+ const evaultUrl = await resolveEVaultUrl(vaultOwnerEname);
+ console.log(`[BACKFILL] Resolved ${vaultOwnerEname} → ${evaultUrl}`);
+
+ // Pull existing ledger and account envelopes from this vault
+ const existingLedgers = await fetchExistingEnvelopes(evaultUrl, vaultOwnerEname, token, LEDGER_ONTOLOGY);
+ const existingAccounts = await fetchExistingEnvelopes(evaultUrl, vaultOwnerEname, token, ACCOUNT_ONTOLOGY);
+ console.log(`[BACKFILL] Existing on vault: ${existingLedgers.length} ledger(s), ${existingAccounts.length} account(s)`);
+
+ if (existingLedgers.length > 0) {
+ const currencyIds = new Set(existingLedgers.map((l: any) => l.parsed?.currencyId));
+ console.log(`[BACKFILL] Ledger currencies on vault: ${[...currencyIds].join(", ")}`);
+ }
+ if (existingAccounts.length > 0) {
+ for (const acc of existingAccounts) {
+ console.log(`[BACKFILL] Existing account: currency=${acc.parsed?.currencyEname}, balance=${acc.parsed?.balance}`);
+ }
+ }
+
+ const filteredInputs: BulkInput[] = [];
+
+ for (const input of inputs) {
+ const alreadyExists = existingAccounts.some(
+ (a: any) => a.parsed?.accountId === input.payload.accountId && a.parsed?.currencyEname === input.payload.currencyEname
+ );
+ const ledgerCount = existingLedgers.filter(
+ (l: any) => l.parsed?.accountId === input.payload.accountId
+ ).length;
+
+ if (alreadyExists) {
+ console.log(`[BACKFILL] SKIP (already exists) accountId=${input.payload.accountId} currency=${input.payload.currencyEname}`);
+ totalSkipped++;
+ continue;
+ }
+
+ console.log(
+ `[BACKFILL] ${DRY_RUN ? "WOULD CREATE" : "CREATING"} accountId=${input.payload.accountId} currency=${input.payload.currencyEname} balance=${input.payload.balance} (${ledgerCount} ledger entries on vault)`
+ );
+
+ if (!DRY_RUN) {
+ filteredInputs.push(input);
+ } else {
+ totalBackfilled++;
+ }
+ }
+
+ if (!DRY_RUN) {
+ if (filteredInputs.length > 0) {
+ const result = await bulkCreateOnEVault(evaultUrl, vaultOwnerEname, token, filteredInputs);
+ totalBackfilled += result.successCount;
+ totalErrors += result.errorCount;
+ console.log(
+ `[BACKFILL] Vault ${vaultOwnerEname}: +${result.successCount} created, ${result.errorCount} errors`
+ );
+ }
+ }
+ } catch (error: any) {
+ const msg = error instanceof Error ? error.message : String(error);
+ const responseData = error?.response?.data ? JSON.stringify(error.response.data) : "no response body";
+ console.error(`[BACKFILL ERROR] Vault ${vaultOwnerEname}: ${msg}`);
+ console.error(`[BACKFILL ERROR] Response: ${responseData}`);
+ totalErrors += inputs.length;
+ }
+ }
+
+ console.log("[BACKFILL] ==========================================");
+ console.log(`[BACKFILL] SUMMARY${DRY_RUN ? " (DRY RUN)" : ""}`);
+ console.log(`[BACKFILL] Total accounts found : ${distinctAccounts.length}`);
+ console.log(`[BACKFILL] Successfully backfilled : ${totalBackfilled}`);
+ console.log(`[BACKFILL] Skipped (no ename) : ${totalSkipped}`);
+ console.log(`[BACKFILL] Errors (evault/graphql) : ${totalErrors}`);
+ console.log("[BACKFILL] ==========================================");
+
+ await AppDataSource.destroy();
+}
+
+main()
+ .then(() => { console.log("[BACKFILL] Done."); process.exit(0); })
+ .catch((err) => { console.error("[BACKFILL] Fatal error:", err); process.exit(1); });
diff --git a/services/ontology/schemas/account.json b/services/ontology/schemas/account.json
new file mode 100644
index 000000000..33ddc17dc
--- /dev/null
+++ b/services/ontology/schemas/account.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "schemaId": "6fda64db-fd14-4fa2-bd38-77d2e5e6136d",
+ "title": "Account",
+ "type": "object",
+ "properties": {
+ "accountId": {
+ "type": "string",
+ "description": "Account identifier matching accountId in ledger MetaEnvelopes"
+ },
+ "accountEname": {
+ "type": "string",
+ "description": "Global eName of the account holder (user or group)"
+ },
+ "accountType": {
+ "type": "string",
+ "enum": ["user", "group"],
+ "description": "Type of account holder"
+ },
+ "currencyEname": {
+ "type": "string",
+ "description": "Global eName of the currency"
+ },
+ "currencyName": {
+ "type": "string",
+ "description": "Display name of the currency"
+ },
+ "balance": {
+ "type": "number",
+ "description": "Current account balance"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the account was first active"
+ }
+ },
+ "required": ["accountId", "accountEname", "accountType", "currencyEname", "currencyName", "balance", "createdAt"],
+ "additionalProperties": false
+}