Skip to content

Commit c8555f6

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

3 files changed

Lines changed: 347 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: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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+
let token = "";
148+
if (!DRY_RUN) {
149+
token = await getPlatformToken();
150+
console.log("[BACKFILL] Platform token acquired");
151+
}
152+
153+
// Find all distinct (accountId, accountType, currencyId) combos from ledger
154+
const distinctAccounts: Array<{
155+
accountId: string;
156+
accountType: AccountType;
157+
currencyId: string;
158+
}> = await ledgerRepo
159+
.createQueryBuilder("ledger")
160+
.select("ledger.accountId", "accountId")
161+
.addSelect("ledger.accountType", "accountType")
162+
.addSelect("ledger.currencyId", "currencyId")
163+
.distinct(true)
164+
.getRawMany();
165+
166+
console.log(`[BACKFILL] Found ${distinctAccounts.length} unique accounts across all currencies`);
167+
168+
// Cache currency lookups
169+
const currencyCache = new Map<string, Currency | null>();
170+
// Cache ename lookups
171+
const enameCache = new Map<string, string | null>();
172+
173+
async function getCurrency(currencyId: string): Promise<Currency | null> {
174+
if (currencyCache.has(currencyId)) return currencyCache.get(currencyId)!;
175+
const currency = await currencyRepo.findOne({ where: { id: currencyId } });
176+
currencyCache.set(currencyId, currency);
177+
return currency;
178+
}
179+
180+
async function getAccountEname(accountId: string, accountType: AccountType): Promise<string | null> {
181+
const key = `${accountType}:${accountId}`;
182+
if (enameCache.has(key)) return enameCache.get(key)!;
183+
184+
let ename: string | null = null;
185+
if (accountType === AccountType.USER) {
186+
const user = await userRepo.findOne({ where: { id: accountId } });
187+
ename = user?.ename ? normalizeEname(user.ename) : null;
188+
} else {
189+
const group = await groupRepo.findOne({ where: { id: accountId } });
190+
ename = group?.ename ? normalizeEname(group.ename) : null;
191+
}
192+
193+
enameCache.set(key, ename);
194+
return ename;
195+
}
196+
197+
let totalBackfilled = 0;
198+
let totalSkipped = 0;
199+
let totalErrors = 0;
200+
201+
// Group by account holder ename for batched eVault writes
202+
const vaultGroups = new Map<string, BulkInput[]>();
203+
204+
for (let i = 0; i < distinctAccounts.length; i++) {
205+
const { accountId, accountType, currencyId } = distinctAccounts[i];
206+
207+
const accountEname = await getAccountEname(accountId, accountType);
208+
if (!accountEname) {
209+
console.warn(`[BACKFILL WARNING] ${accountType} ${accountId} has no ename — skipping`);
210+
totalSkipped++;
211+
continue;
212+
}
213+
214+
const currency = await getCurrency(currencyId);
215+
if (!currency) {
216+
console.warn(`[BACKFILL WARNING] Currency ${currencyId} not found — skipping`);
217+
totalSkipped++;
218+
continue;
219+
}
220+
221+
const currencyEname = currency.ename ? normalizeEname(currency.ename) : null;
222+
if (!currencyEname) {
223+
console.warn(`[BACKFILL WARNING] Currency ${currencyId} has no ename — skipping`);
224+
totalSkipped++;
225+
continue;
226+
}
227+
228+
// Get current balance from latest ledger entry
229+
const latestEntry = await ledgerRepo.findOne({
230+
where: { currencyId, accountId, accountType },
231+
order: { createdAt: "DESC" },
232+
});
233+
const balance = latestEntry ? Number(latestEntry.balance) : 0;
234+
235+
// Get first entry for createdAt
236+
const firstEntry = await ledgerRepo.findOne({
237+
where: { currencyId, accountId, accountType },
238+
order: { createdAt: "ASC" },
239+
});
240+
241+
const payload: Record<string, unknown> = {
242+
accountEname,
243+
accountType,
244+
currencyEname,
245+
currencyName: currency.name,
246+
balance,
247+
createdAt: firstEntry?.createdAt?.toISOString() ?? new Date().toISOString(),
248+
};
249+
250+
if (!vaultGroups.has(accountEname)) {
251+
vaultGroups.set(accountEname, []);
252+
}
253+
vaultGroups.get(accountEname)!.push({
254+
ontology: ACCOUNT_ONTOLOGY,
255+
payload,
256+
acl: ["*"],
257+
});
258+
259+
if ((i + 1) % 50 === 0) {
260+
console.log(`[BACKFILL] Processed ${i + 1}/${distinctAccounts.length} accounts`);
261+
}
262+
}
263+
264+
console.log(`[BACKFILL] Built ${vaultGroups.size} vault groups, writing...`);
265+
266+
// Write to eVaults
267+
for (const [vaultOwnerEname, inputs] of vaultGroups) {
268+
console.log(`[BACKFILL] Writing ${inputs.length} account(s) to vault ${vaultOwnerEname}...`);
269+
270+
if (DRY_RUN) {
271+
for (const input of inputs) {
272+
console.log(
273+
`[DRY RUN] Would create account metaenvelope → vault ${vaultOwnerEname} (currency: ${input.payload.currencyEname}, balance: ${input.payload.balance})`
274+
);
275+
}
276+
totalBackfilled += inputs.length;
277+
continue;
278+
}
279+
280+
try {
281+
const evaultUrl = await resolveEVaultUrl(vaultOwnerEname);
282+
console.log(`[BACKFILL] Resolved ${vaultOwnerEname}${evaultUrl}`);
283+
const result = await bulkCreateOnEVault(evaultUrl, vaultOwnerEname, token, inputs);
284+
totalBackfilled += result.successCount;
285+
totalErrors += result.errorCount;
286+
console.log(
287+
`[BACKFILL] Vault ${vaultOwnerEname}: +${result.successCount} created, ${result.errorCount} errors`
288+
);
289+
} catch (error) {
290+
const msg = error instanceof Error ? error.message : String(error);
291+
console.error(`[BACKFILL ERROR] Vault ${vaultOwnerEname}: ${msg}`);
292+
totalErrors += inputs.length;
293+
}
294+
}
295+
296+
console.log("[BACKFILL] ==========================================");
297+
console.log(`[BACKFILL] SUMMARY${DRY_RUN ? " (DRY RUN)" : ""}`);
298+
console.log(`[BACKFILL] Total accounts found : ${distinctAccounts.length}`);
299+
console.log(`[BACKFILL] Successfully backfilled : ${totalBackfilled}`);
300+
console.log(`[BACKFILL] Skipped (no ename) : ${totalSkipped}`);
301+
console.log(`[BACKFILL] Errors (evault/graphql) : ${totalErrors}`);
302+
console.log("[BACKFILL] ==========================================");
303+
304+
await AppDataSource.destroy();
305+
}
306+
307+
main()
308+
.then(() => { console.log("[BACKFILL] Done."); process.exit(0); })
309+
.catch((err) => { console.error("[BACKFILL] Fatal error:", err); process.exit(1); });
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
"accountEname": {
8+
"type": "string",
9+
"description": "Global eName of the account holder (user or group)"
10+
},
11+
"accountType": {
12+
"type": "string",
13+
"enum": ["user", "group"],
14+
"description": "Type of account holder"
15+
},
16+
"currencyEname": {
17+
"type": "string",
18+
"description": "Global eName of the currency"
19+
},
20+
"currencyName": {
21+
"type": "string",
22+
"description": "Display name of the currency"
23+
},
24+
"balance": {
25+
"type": "number",
26+
"description": "Current account balance"
27+
},
28+
"createdAt": {
29+
"type": "string",
30+
"format": "date-time",
31+
"description": "When the account was first active"
32+
}
33+
},
34+
"required": ["accountEname", "accountType", "currencyEname", "currencyName", "balance", "createdAt"],
35+
"additionalProperties": false
36+
}

0 commit comments

Comments
 (0)