mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
refactor: create getWallet function, with tests
This commit is contained in:
parent
07d0d4c4e5
commit
355c53fd64
7 changed files with 196 additions and 56 deletions
|
|
@ -9,7 +9,7 @@ import { assertEquals } from '@std/assert';
|
||||||
import { DittoPolyPg, TestDB } from '@ditto/db';
|
import { DittoPolyPg, TestDB } from '@ditto/db';
|
||||||
import { DittoConf } from '@ditto/conf';
|
import { DittoConf } from '@ditto/conf';
|
||||||
|
|
||||||
import { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
||||||
|
|
||||||
Deno.test('validateAndParseWallet function returns valid data', async () => {
|
Deno.test('validateAndParseWallet function returns valid data', async () => {
|
||||||
const conf = new DittoConf(Deno.env);
|
const conf = new DittoConf(Deno.env);
|
||||||
|
|
@ -46,6 +46,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => {
|
||||||
tags: [
|
tags: [
|
||||||
['pubkey', p2pk],
|
['pubkey', p2pk],
|
||||||
['mint', 'https://mint.soul.com'],
|
['mint', 'https://mint.soul.com'],
|
||||||
|
['relay', conf.relay],
|
||||||
],
|
],
|
||||||
}, sk);
|
}, sk);
|
||||||
await store.event(nutzapInfo);
|
await store.event(nutzapInfo);
|
||||||
|
|
@ -59,6 +60,7 @@ Deno.test('validateAndParseWallet function returns valid data', async () => {
|
||||||
privkey,
|
privkey,
|
||||||
p2pk,
|
p2pk,
|
||||||
mints: ['https://mint.soul.com'],
|
mints: ['https://mint.soul.com'],
|
||||||
|
relays: [conf.relay],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -343,3 +345,113 @@ Deno.test('getMintsToProofs function is working', async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('getWallet function is working', async () => {
|
||||||
|
const conf = new DittoConf(Deno.env);
|
||||||
|
const orig = new DittoPolyPg(conf.databaseUrl);
|
||||||
|
|
||||||
|
await using db = new TestDB(orig);
|
||||||
|
await db.migrate();
|
||||||
|
await db.clear();
|
||||||
|
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = new NSecSigner(sk);
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
|
const privkey = bytesToString('hex', sk);
|
||||||
|
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||||
|
|
||||||
|
const relay = new NPostgres(orig.kysely);
|
||||||
|
|
||||||
|
const proofs = genEvent({
|
||||||
|
kind: 7375,
|
||||||
|
content: await signer.nip44.encrypt(
|
||||||
|
pubkey,
|
||||||
|
JSON.stringify({
|
||||||
|
mint: 'https://cuiaba.mint.com',
|
||||||
|
proofs: [
|
||||||
|
{
|
||||||
|
'id': '004f7adf2a04356c',
|
||||||
|
'amount': 2,
|
||||||
|
'secret': '700312ccba84cb15d6a008c1d01b0dbf00025d3f2cb01f030a756553aca52de3',
|
||||||
|
'C': '02f0ff21fdd19a547d66d9ca09df5573ad88d28e4951825130708ba53cbed19561',
|
||||||
|
'dleq': {
|
||||||
|
'e': '9c44a58cb429be619c474b97216009bd96ff1b7dd145b35828a14f180c03a86f',
|
||||||
|
's': 'a11b8f616dfee5157a2c7c36da0ee181fe71b28729bee56b789e472c027ceb3b',
|
||||||
|
'r': 'c51b9ade8cfd3939b78d509c9723f86b43b432680f55a6791e3e252b53d4b465',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '004f7adf2a04356c',
|
||||||
|
'amount': 4,
|
||||||
|
'secret': '5936f22d486734c03bd50b89aaa34be8e99f20d199bcebc09da8716890e95fb3',
|
||||||
|
'C': '039b55f92c02243e31b04e964f2ad0bcd2ed3229e334f4c7a81037392b8411d6e7',
|
||||||
|
'dleq': {
|
||||||
|
'e': '7b7be700f2515f1978ca27bc1045d50b9d146bb30d1fe0c0f48827c086412b9e',
|
||||||
|
's': 'cf44b08c7e64fd2bd9199667327b10a29b7c699b10cb7437be518203b25fe3fa',
|
||||||
|
'r': 'ec0cf54ce2d17fae5db1c6e5e5fd5f34d7c7df18798b8d92bcb7cb005ec2f93b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '004f7adf2a04356c',
|
||||||
|
'amount': 16,
|
||||||
|
'secret': '89e2315c058f3a010972dc6d546b1a2e81142614d715c28d169c6afdba5326bd',
|
||||||
|
'C': '02bc1c3756e77563fe6c7769fc9d9bc578ea0b84bf4bf045cf31c7e2d3f3ad0818',
|
||||||
|
'dleq': {
|
||||||
|
'e': '8dfa000c9e2a43d35d2a0b1c7f36a96904aed35457ca308c6e7d10f334f84e72',
|
||||||
|
's': '9270a914b1a53e32682b1277f34c5cfa931a6fab701a5dbee5855b68ddf621ab',
|
||||||
|
'r': 'ae71e572839a3273b0141ea2f626915592b4b3f5f91b37bbeacce0d3396332c9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': '004f7adf2a04356c',
|
||||||
|
'amount': 16,
|
||||||
|
'secret': '06f2209f313d92505ae5c72087263f711b7a97b1b29a71886870e672a1b180ac',
|
||||||
|
'C': '02fa2ad933b62449e2765255d39593c48293f10b287cf7036b23570c8f01c27fae',
|
||||||
|
'dleq': {
|
||||||
|
'e': 'e696d61f6259ae97f8fe13a5af55d47f526eea62a7998bf888626fd1ae35e720',
|
||||||
|
's': 'b9f1ef2a8aec0e73c1a4aaff67e28b3ca3bc4628a532113e0733643c697ed7ce',
|
||||||
|
'r': 'b66ed62852811d14e9bf822baebfda92ba47c5c4babc4f2499d9ce81fbbbd3f2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
del: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
created_at: Math.floor(Date.now() / 1000), // now
|
||||||
|
}, sk);
|
||||||
|
|
||||||
|
await relay.event(proofs);
|
||||||
|
|
||||||
|
await relay.event(genEvent({
|
||||||
|
kind: 10019,
|
||||||
|
tags: [
|
||||||
|
['pubkey', p2pk],
|
||||||
|
['mint', 'https://mint.soul.com'],
|
||||||
|
['mint', 'https://cuiaba.mint.com'],
|
||||||
|
['relay', conf.relay],
|
||||||
|
],
|
||||||
|
}, sk));
|
||||||
|
|
||||||
|
const wallet = genEvent({
|
||||||
|
kind: 17375,
|
||||||
|
content: await signer.nip44.encrypt(
|
||||||
|
pubkey,
|
||||||
|
JSON.stringify([
|
||||||
|
['privkey', privkey],
|
||||||
|
['mint', 'https://mint.soul.com'],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}, sk);
|
||||||
|
|
||||||
|
await relay.event(wallet);
|
||||||
|
|
||||||
|
const walletEntity = await getWallet(relay, pubkey, signer);
|
||||||
|
|
||||||
|
assertEquals(walletEntity, {
|
||||||
|
balance: 38,
|
||||||
|
mints: ['https://mint.soul.com', 'https://cuiaba.mint.com'],
|
||||||
|
relays: [conf.relay],
|
||||||
|
pubkey_p2pk: p2pk,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { logi } from '@soapbox/logi';
|
||||||
import type { SetRequired } from 'type-fest';
|
import type { SetRequired } from 'type-fest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { proofSchema, tokenEventSchema } from './schemas.ts';
|
import { proofSchema, tokenEventSchema, type Wallet } from './schemas.ts';
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
wallet: NostrEvent;
|
wallet: NostrEvent;
|
||||||
|
|
@ -14,6 +14,7 @@ type Data = {
|
||||||
privkey: string;
|
privkey: string;
|
||||||
p2pk: string;
|
p2pk: string;
|
||||||
mints: string[];
|
mints: string[];
|
||||||
|
relays: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CustomError =
|
type CustomError =
|
||||||
|
|
@ -96,7 +97,9 @@ async function validateAndParseWallet(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: { wallet, nutzapInfo, privkey, p2pk, mints }, error: null };
|
const relays = [...new Set(nutzapInfo.tags.filter(([name]) => name === 'relay').map(([_, value]) => value))];
|
||||||
|
|
||||||
|
return { data: { wallet, nutzapInfo, privkey, p2pk, mints, relays }, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrganizedProofs = {
|
type OrganizedProofs = {
|
||||||
|
|
@ -235,6 +238,54 @@ async function getMintsToProofs(
|
||||||
return mintsToProofs;
|
return mintsToProofs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a wallet entity with the latest balance. */
|
||||||
|
async function getWallet(
|
||||||
|
store: NStore,
|
||||||
|
pubkey: string,
|
||||||
|
signer: SetRequired<NostrSigner, 'nip44'>,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<Wallet | undefined> {
|
||||||
|
const { data, error } = await validateAndParseWallet(store, signer, pubkey, { signal: opts?.signal });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logi({ level: 'error', ns: 'ditto.cashu.get_wallet', error: errorJson(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { p2pk, mints, relays } = data;
|
||||||
|
|
||||||
|
let balance = 0;
|
||||||
|
|
||||||
|
const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal: opts?.signal });
|
||||||
|
for (const token of tokens) {
|
||||||
|
try {
|
||||||
|
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
||||||
|
await signer.nip44.decrypt(pubkey, token.content),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mints.includes(decryptedContent.mint)) {
|
||||||
|
mints.push(decryptedContent.mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
balance += decryptedContent.proofs.reduce((accumulator, current) => {
|
||||||
|
return accumulator + current.amount;
|
||||||
|
}, 0);
|
||||||
|
} catch (e) {
|
||||||
|
logi({ level: 'error', ns: 'dtto.cashu.get_wallet', error: errorJson(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint
|
||||||
|
const walletEntity: Wallet = {
|
||||||
|
pubkey_p2pk: p2pk,
|
||||||
|
mints,
|
||||||
|
relays,
|
||||||
|
balance,
|
||||||
|
};
|
||||||
|
|
||||||
|
return walletEntity;
|
||||||
|
}
|
||||||
|
|
||||||
/** Serialize an error into JSON for JSON logging. */
|
/** Serialize an error into JSON for JSON logging. */
|
||||||
export function errorJson(error: unknown): Error | null {
|
export function errorJson(error: unknown): Error | null {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|
@ -248,4 +299,4 @@ function isNostrId(value: unknown): boolean {
|
||||||
return n.id().safeParse(value).success;
|
return n.id().safeParse(value).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet };
|
export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet };
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export { getLastRedeemedNutzap, getMintsToProofs, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
|
||||||
export { proofSchema, tokenEventSchema } from './schemas.ts';
|
export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const proofSchema: z.ZodType<{
|
export const proofSchema: z.ZodType<{
|
||||||
|
|
@ -26,3 +27,18 @@ export const tokenEventSchema: z.ZodType<{
|
||||||
proofs: proofSchema.array(),
|
proofs: proofSchema.array(),
|
||||||
del: z.string().array().optional(),
|
del: z.string().array().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Ditto Cashu wallet */
|
||||||
|
export const walletSchema = z.object({
|
||||||
|
pubkey_p2pk: n.id(),
|
||||||
|
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
||||||
|
return [...new Set(val)];
|
||||||
|
}),
|
||||||
|
relays: z.array(z.string()).nonempty().transform((val) => {
|
||||||
|
return [...new Set(val)];
|
||||||
|
}),
|
||||||
|
/** Unit in sats */
|
||||||
|
balance: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Wallet = z.infer<typeof walletSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { proofSchema } from '@ditto/cashu';
|
import { proofSchema, walletSchema } from '@ditto/cashu';
|
||||||
import { DittoConf } from '@ditto/conf';
|
import { DittoConf } from '@ditto/conf';
|
||||||
import { type User } from '@ditto/mastoapi/middleware';
|
import { type User } from '@ditto/mastoapi/middleware';
|
||||||
import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router';
|
import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router';
|
||||||
|
|
@ -11,7 +11,6 @@ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import cashuRoute from '@/controllers/api/cashu.ts';
|
import cashuRoute from '@/controllers/api/cashu.ts';
|
||||||
import { createTestDB } from '@/test.ts';
|
import { createTestDB } from '@/test.ts';
|
||||||
import { walletSchema } from '@/schema.ts';
|
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
import { Proof } from '@cashu/cashu-ts';
|
import { Proof } from '@cashu/cashu-ts';
|
||||||
|
|
||||||
|
|
@ -171,6 +170,7 @@ Deno.test('GET /wallet must be successful', async () => {
|
||||||
tags: [
|
tags: [
|
||||||
['pubkey', p2pk],
|
['pubkey', p2pk],
|
||||||
['mint', 'https://mint.soul.com'],
|
['mint', 'https://mint.soul.com'],
|
||||||
|
['relay', 'ws://localhost:4036/relay'],
|
||||||
],
|
],
|
||||||
}, sk));
|
}, sk));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts';
|
import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts';
|
||||||
import { organizeProofs, tokenEventSchema, validateAndParseWallet } from '@ditto/cashu';
|
import { getWallet, organizeProofs, tokenEventSchema, validateAndParseWallet, type Wallet } from '@ditto/cashu';
|
||||||
import { userMiddleware } from '@ditto/mastoapi/middleware';
|
import { userMiddleware } from '@ditto/mastoapi/middleware';
|
||||||
import { DittoRoute } from '@ditto/mastoapi/router';
|
import { DittoRoute } from '@ditto/mastoapi/router';
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||||
|
|
@ -10,15 +10,12 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { createEvent, parseBody } from '@/utils/api.ts';
|
import { createEvent, parseBody } from '@/utils/api.ts';
|
||||||
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
||||||
import { walletSchema } from '@/schema.ts';
|
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
import { errorJson } from '@/utils/log.ts';
|
import { errorJson } from '@/utils/log.ts';
|
||||||
import { getAmount } from '@/utils/bolt11.ts';
|
import { getAmount } from '@/utils/bolt11.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
|
||||||
type Wallet = z.infer<typeof walletSchema>;
|
|
||||||
|
|
||||||
const route = new DittoRoute();
|
const route = new DittoRoute();
|
||||||
|
|
||||||
interface Nutzap {
|
interface Nutzap {
|
||||||
|
|
@ -230,49 +227,27 @@ route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
||||||
|
|
||||||
/** Gets a wallet, if it exists. */
|
/** Gets a wallet, if it exists. */
|
||||||
route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => {
|
route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => {
|
||||||
const { conf, relay, user, signal, requestId } = c.var;
|
const { relay, user, signal } = c.var;
|
||||||
|
|
||||||
const pubkey = await user.signer.getPublicKey();
|
const pubkey = await user.signer.getPublicKey();
|
||||||
|
|
||||||
const { data, error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal });
|
const { error } = await validateAndParseWallet(relay, user.signer, pubkey, { signal });
|
||||||
if (error) {
|
if (error) {
|
||||||
return c.json({ error: error.message }, 404);
|
return c.json({ error: error.message }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { p2pk, mints } = data;
|
const walletEntity = await getWallet(relay, pubkey, user.signer);
|
||||||
|
|
||||||
let balance = 0;
|
if (!walletEntity) {
|
||||||
|
return c.json({ 'error': 'Wallet not found' }, 404);
|
||||||
const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal });
|
|
||||||
for (const token of tokens) {
|
|
||||||
try {
|
|
||||||
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
|
||||||
await user.signer.nip44.decrypt(pubkey, token.content),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mints.includes(decryptedContent.mint)) {
|
|
||||||
mints.push(decryptedContent.mint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
balance += decryptedContent.proofs.reduce((accumulator, current) => {
|
|
||||||
return accumulator + current.amount;
|
|
||||||
}, 0);
|
|
||||||
} catch (e) {
|
|
||||||
logi({ level: 'error', ns: 'ditto.api.cashu.wallet', requestId, error: errorJson(e) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint
|
|
||||||
const walletEntity: Wallet = {
|
|
||||||
pubkey_p2pk: p2pk,
|
|
||||||
mints,
|
|
||||||
relays: [conf.relay],
|
|
||||||
balance,
|
|
||||||
};
|
|
||||||
|
|
||||||
return c.json(walletEntity, 200);
|
return c.json(walletEntity, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT wallet
|
||||||
|
// what errors to return?
|
||||||
|
|
||||||
/** Get mints set by the CASHU_MINTS environment variable. */
|
/** Get mints set by the CASHU_MINTS environment variable. */
|
||||||
route.get('/mints', (c) => {
|
route.get('/mints', (c) => {
|
||||||
const { conf } = c.var;
|
const { conf } = c.var;
|
||||||
|
|
|
||||||
|
|
@ -59,19 +59,6 @@ const sizesSchema = z.string().refine((value) =>
|
||||||
value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v))
|
value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v))
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Ditto Cashu wallet */
|
|
||||||
const walletSchema = z.object({
|
|
||||||
pubkey_p2pk: n.id(),
|
|
||||||
mints: z.array(z.string().url()).nonempty().transform((val) => {
|
|
||||||
return [...new Set(val)];
|
|
||||||
}),
|
|
||||||
relays: z.array(z.string()).nonempty().transform((val) => {
|
|
||||||
return [...new Set(val)];
|
|
||||||
}),
|
|
||||||
/** Unit in sats */
|
|
||||||
balance: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
booleanParamSchema,
|
booleanParamSchema,
|
||||||
fileSchema,
|
fileSchema,
|
||||||
|
|
@ -82,5 +69,4 @@ export {
|
||||||
percentageSchema,
|
percentageSchema,
|
||||||
safeUrlSchema,
|
safeUrlSchema,
|
||||||
sizesSchema,
|
sizesSchema,
|
||||||
walletSchema,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue