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 +}