Skip to content

Commit 6761de8

Browse files
committed
feat: ontology for accounts
1 parent d2efd8b commit 6761de8

3 files changed

Lines changed: 350 additions & 1 deletion

File tree

platforms/ecurrency/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"typeorm": "typeorm-ts-node-commonjs",
1111
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts",
1212
"migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts",
13-
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts"
13+
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts",
14+
"backfill:accounts": "ts-node src/scripts/backfill-accounts.ts"
1415
},
1516
"dependencies": {
1617
"axios": "^1.6.7",
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import "reflect-metadata";
2+
import path from "node:path";
3+
import { config } from "dotenv";
4+
5+
config({ path: path.resolve(__dirname, "../../../../.env") });
6+
7+
import axios from "axios";
8+
import { AppDataSource } from "../database/data-source";
9+
import { Ledger, AccountType } from "../database/entities/Ledger";
10+
import { Currency } from "../database/entities/Currency";
11+
import { User } from "../database/entities/User";
12+
import { Group } from "../database/entities/Group";
13+
14+
// ─── Constants ────────────────────────────────────────────────────────────────
15+
16+
const ACCOUNT_ONTOLOGY = "6fda64db-fd14-4fa2-bd38-77d2e5e6136d";
17+
const BATCH_SIZE = 10;
18+
const DRY_RUN = process.argv.includes("--dry-run");
19+
const REGISTRY_URL = "https://registry.w3ds.metastate.foundation";
20+
21+
// ─── Helpers ──────────────────────────────────────────────────────────────────
22+
23+
function normalizeEname(value: string): string {
24+
return value.startsWith("@") ? value : `@${value}`;
25+
}
26+
27+
let _platformToken: string | null = null;
28+
29+
async function getPlatformToken(forceRefresh = false): Promise<string> {
30+
if (_platformToken && !forceRefresh) return _platformToken;
31+
const response = await axios.post(
32+
`${REGISTRY_URL}/platforms/certification`,
33+
{ platform: "ecurrency" },
34+
{ timeout: 5_000 }
35+
);
36+
_platformToken = response.data.token as string;
37+
return _platformToken;
38+
}
39+
40+
async function resolveEVaultUrl(eName: string): Promise<string> {
41+
const normalized = eName.startsWith("@") ? eName : `@${eName}`;
42+
const response = await axios.get(
43+
`${REGISTRY_URL}/resolve?w3id=${encodeURIComponent(normalized)}`,
44+
{ timeout: 5_000 }
45+
);
46+
const url = response.data?.evaultUrl || response.data?.uri;
47+
if (!url) throw new Error(`Registry returned no eVault URL for ${normalized}`);
48+
return url as string;
49+
}
50+
51+
// ─── eVault write ─────────────────────────────────────────────────────────────
52+
53+
interface BulkInput {
54+
ontology: string;
55+
payload: Record<string, unknown>;
56+
acl: string[];
57+
}
58+
59+
const BULK_CREATE_MUTATION = `
60+
mutation BulkCreate($inputs: [BulkMetaEnvelopeInput!]!) {
61+
bulkCreateMetaEnvelopes(inputs: $inputs, skipWebhooks: true) {
62+
successCount
63+
errorCount
64+
results { id success error }
65+
}
66+
}
67+
`;
68+
69+
async function bulkCreateOnEVault(
70+
evaultUrl: string,
71+
vaultOwnerEname: string,
72+
token: string,
73+
inputs: BulkInput[]
74+
): Promise<{ successCount: number; errorCount: number }> {
75+
const graphqlUrl = new URL("/graphql", evaultUrl).toString();
76+
let totalSuccess = 0;
77+
let totalErrors = 0;
78+
79+
for (let i = 0; i < inputs.length; i += BATCH_SIZE) {
80+
const batch = inputs.slice(i, i + BATCH_SIZE);
81+
let response: any;
82+
83+
try {
84+
response = await axios.post(
85+
graphqlUrl,
86+
{ query: BULK_CREATE_MUTATION, variables: { inputs: batch } },
87+
{
88+
headers: {
89+
"Content-Type": "application/json",
90+
Authorization: `Bearer ${token}`,
91+
"X-ENAME": vaultOwnerEname,
92+
},
93+
timeout: 5_000,
94+
}
95+
);
96+
} catch (err: any) {
97+
if (err?.response?.status === 401) {
98+
const freshToken = await getPlatformToken(true);
99+
response = await axios.post(
100+
graphqlUrl,
101+
{ query: BULK_CREATE_MUTATION, variables: { inputs: batch } },
102+
{
103+
headers: {
104+
"Content-Type": "application/json",
105+
Authorization: `Bearer ${freshToken}`,
106+
"X-ENAME": vaultOwnerEname,
107+
},
108+
timeout: 5_000,
109+
}
110+
);
111+
} else {
112+
throw err;
113+
}
114+
}
115+
116+
if (response.data.errors) {
117+
throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
118+
}
119+
120+
const result = response.data.data.bulkCreateMetaEnvelopes;
121+
totalSuccess += result.successCount as number;
122+
totalErrors += result.errorCount as number;
123+
124+
for (const r of result.results as Array<{ id: string; success: boolean; error?: string }>) {
125+
if (!r.success) {
126+
console.error(`[BACKFILL ERROR] Envelope ${r.id}: ${r.error}`);
127+
}
128+
}
129+
}
130+
131+
return { successCount: totalSuccess, errorCount: totalErrors };
132+
}
133+
134+
// ─── Main ─────────────────────────────────────────────────────────────────────
135+
136+
async function main() {
137+
console.log(`[BACKFILL] Starting eCurrency account backfill${DRY_RUN ? " (DRY RUN)" : ""}`);
138+
console.log(`[BACKFILL] Ontology: ${ACCOUNT_ONTOLOGY}`);
139+
140+
await AppDataSource.initialize();
141+
142+
const ledgerRepo = AppDataSource.getRepository(Ledger);
143+
const currencyRepo = AppDataSource.getRepository(Currency);
144+
const userRepo = AppDataSource.getRepository(User);
145+
const groupRepo = AppDataSource.getRepository(Group);
146+
147+
const token = await getPlatformToken();
148+
console.log("[BACKFILL] Platform token acquired");
149+
150+
// Find all distinct (accountId, accountType, currencyId) combos from ledger
151+
const distinctAccounts: Array<{
152+
accountId: string;
153+
accountType: AccountType;
154+
currencyId: string;
155+
}> = await ledgerRepo
156+
.createQueryBuilder("ledger")
157+
.select("ledger.accountId", "accountId")
158+
.addSelect("ledger.accountType", "accountType")
159+
.addSelect("ledger.currencyId", "currencyId")
160+
.distinct(true)
161+
.getRawMany();
162+
163+
console.log(`[BACKFILL] Found ${distinctAccounts.length} unique accounts across all currencies`);
164+
165+
// Cache currency lookups
166+
const currencyCache = new Map<string, Currency | null>();
167+
// Cache ename lookups
168+
const enameCache = new Map<string, string | null>();
169+
170+
async function getCurrency(currencyId: string): Promise<Currency | null> {
171+
if (currencyCache.has(currencyId)) return currencyCache.get(currencyId)!;
172+
const currency = await currencyRepo.findOne({ where: { id: currencyId } });
173+
currencyCache.set(currencyId, currency);
174+
return currency;
175+
}
176+
177+
async function getAccountEname(accountId: string, accountType: AccountType): Promise<string | null> {
178+
const key = `${accountType}:${accountId}`;
179+
if (enameCache.has(key)) return enameCache.get(key)!;
180+
181+
let ename: string | null = null;
182+
if (accountType === AccountType.USER) {
183+
const user = await userRepo.findOne({ where: { id: accountId } });
184+
ename = user?.ename ? normalizeEname(user.ename) : null;
185+
} else {
186+
const group = await groupRepo.findOne({ where: { id: accountId } });
187+
ename = group?.ename ? normalizeEname(group.ename) : null;
188+
}
189+
190+
enameCache.set(key, ename);
191+
return ename;
192+
}
193+
194+
let totalBackfilled = 0;
195+
let totalSkipped = 0;
196+
let totalErrors = 0;
197+
198+
// Group by account holder ename for batched eVault writes
199+
const vaultGroups = new Map<string, BulkInput[]>();
200+
201+
for (let i = 0; i < distinctAccounts.length; i++) {
202+
const { accountId, accountType, currencyId } = distinctAccounts[i];
203+
204+
const accountEname = await getAccountEname(accountId, accountType);
205+
if (!accountEname) {
206+
console.warn(`[BACKFILL WARNING] ${accountType} ${accountId} has no ename — skipping`);
207+
totalSkipped++;
208+
continue;
209+
}
210+
211+
const currency = await getCurrency(currencyId);
212+
if (!currency) {
213+
console.warn(`[BACKFILL WARNING] Currency ${currencyId} not found — skipping`);
214+
totalSkipped++;
215+
continue;
216+
}
217+
218+
const currencyEname = currency.ename ? normalizeEname(currency.ename) : null;
219+
if (!currencyEname) {
220+
console.warn(`[BACKFILL WARNING] Currency ${currencyId} has no ename — skipping`);
221+
totalSkipped++;
222+
continue;
223+
}
224+
225+
// Get current balance from latest ledger entry
226+
const latestEntry = await ledgerRepo.findOne({
227+
where: { currencyId, accountId, accountType },
228+
order: { createdAt: "DESC" },
229+
});
230+
const balance = latestEntry ? Number(latestEntry.balance) : 0;
231+
232+
// Get first entry for createdAt
233+
const firstEntry = await ledgerRepo.findOne({
234+
where: { currencyId, accountId, accountType },
235+
order: { createdAt: "ASC" },
236+
});
237+
238+
const payload: Record<string, unknown> = {
239+
accountId,
240+
accountEname,
241+
accountType,
242+
currencyEname,
243+
currencyName: currency.name,
244+
balance,
245+
createdAt: firstEntry?.createdAt?.toISOString() ?? new Date().toISOString(),
246+
};
247+
248+
if (!vaultGroups.has(accountEname)) {
249+
vaultGroups.set(accountEname, []);
250+
}
251+
vaultGroups.get(accountEname)!.push({
252+
ontology: ACCOUNT_ONTOLOGY,
253+
payload,
254+
acl: ["*"],
255+
});
256+
257+
if ((i + 1) % 50 === 0) {
258+
console.log(`[BACKFILL] Processed ${i + 1}/${distinctAccounts.length} accounts`);
259+
}
260+
}
261+
262+
console.log(`[BACKFILL] Built ${vaultGroups.size} vault groups, writing...`);
263+
264+
// Write to eVaults
265+
for (const [vaultOwnerEname, inputs] of vaultGroups) {
266+
console.log(`[BACKFILL] Writing ${inputs.length} account(s) to vault ${vaultOwnerEname}...`);
267+
268+
try {
269+
const evaultUrl = await resolveEVaultUrl(vaultOwnerEname);
270+
console.log(`[BACKFILL] Resolved ${vaultOwnerEname}${evaultUrl}`);
271+
272+
if (DRY_RUN) {
273+
for (const input of inputs) {
274+
console.log(
275+
`[DRY RUN] Would create account metaenvelope → vault ${vaultOwnerEname} (accountId: ${input.payload.accountId}, currency: ${input.payload.currencyEname}, balance: ${input.payload.balance})`
276+
);
277+
}
278+
totalBackfilled += inputs.length;
279+
continue;
280+
}
281+
282+
const result = await bulkCreateOnEVault(evaultUrl, vaultOwnerEname, token, inputs);
283+
totalBackfilled += result.successCount;
284+
totalErrors += result.errorCount;
285+
console.log(
286+
`[BACKFILL] Vault ${vaultOwnerEname}: +${result.successCount} created, ${result.errorCount} errors`
287+
);
288+
} catch (error) {
289+
const msg = error instanceof Error ? error.message : String(error);
290+
console.error(`[BACKFILL ERROR] Vault ${vaultOwnerEname}: ${msg}`);
291+
totalErrors += inputs.length;
292+
}
293+
}
294+
295+
console.log("[BACKFILL] ==========================================");
296+
console.log(`[BACKFILL] SUMMARY${DRY_RUN ? " (DRY RUN)" : ""}`);
297+
console.log(`[BACKFILL] Total accounts found : ${distinctAccounts.length}`);
298+
console.log(`[BACKFILL] Successfully backfilled : ${totalBackfilled}`);
299+
console.log(`[BACKFILL] Skipped (no ename) : ${totalSkipped}`);
300+
console.log(`[BACKFILL] Errors (evault/graphql) : ${totalErrors}`);
301+
console.log("[BACKFILL] ==========================================");
302+
303+
await AppDataSource.destroy();
304+
}
305+
306+
main()
307+
.then(() => { console.log("[BACKFILL] Done."); process.exit(0); })
308+
.catch((err) => { console.error("[BACKFILL] Fatal error:", err); process.exit(1); });
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"schemaId": "6fda64db-fd14-4fa2-bd38-77d2e5e6136d",
4+
"title": "Account",
5+
"type": "object",
6+
"properties": {
7+
"accountId": {
8+
"type": "string",
9+
"description": "Account identifier matching accountId in ledger MetaEnvelopes"
10+
},
11+
"accountEname": {
12+
"type": "string",
13+
"description": "Global eName of the account holder (user or group)"
14+
},
15+
"accountType": {
16+
"type": "string",
17+
"enum": ["user", "group"],
18+
"description": "Type of account holder"
19+
},
20+
"currencyEname": {
21+
"type": "string",
22+
"description": "Global eName of the currency"
23+
},
24+
"currencyName": {
25+
"type": "string",
26+
"description": "Display name of the currency"
27+
},
28+
"balance": {
29+
"type": "number",
30+
"description": "Current account balance"
31+
},
32+
"createdAt": {
33+
"type": "string",
34+
"format": "date-time",
35+
"description": "When the account was first active"
36+
}
37+
},
38+
"required": ["accountId", "accountEname", "accountType", "currencyEname", "currencyName", "balance", "createdAt"],
39+
"additionalProperties": false
40+
}

0 commit comments

Comments
 (0)