feat: support pagination in GET transactions

remove getTransactions function and replace it with renderTransaction function (all tests updated)
This commit is contained in:
P. Reis 2025-03-24 23:02:04 -03:00
parent 1360484ae9
commit 83c96c88b7
6 changed files with 149 additions and 139 deletions

View file

@ -9,14 +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 { import { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
getLastRedeemedNutzap,
getMintsToProofs,
getTransactions,
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);
@ -462,72 +455,3 @@ Deno.test('getWallet function is working', async () => {
pubkey_p2pk: p2pk, pubkey_p2pk: p2pk,
}); });
}); });
Deno.test('getTransactions 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 relay = new NPostgres(orig.kysely);
const history1 = genEvent({
kind: 7376,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'in'],
['amount', '33'],
]),
),
created_at: Math.floor(Date.now() / 1000), // now
}, sk);
await relay.event(history1);
const history2 = genEvent({
kind: 7376,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'out'],
['amount', '29'],
]),
),
created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second
}, sk);
await relay.event(history2);
const history3 = genEvent({
kind: 7376,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'ouch'],
['amount', 'yolo'],
]),
),
created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second
}, sk);
await relay.event(history3);
const transactions = await getTransactions(relay, pubkey, signer, {});
assertEquals(transactions, [
{
direction: 'in',
amount: 33,
created_at: history1.created_at,
},
{
direction: 'out',
amount: 29,
created_at: history2.created_at,
},
]);
});

View file

@ -286,55 +286,6 @@ async function getWallet(
return walletEntity; return walletEntity;
} }
type Transactions = {
amount: number;
created_at: number;
direction: 'in' | 'out';
}[];
/** Returns a history of transactions. */
async function getTransactions(
store: NStore,
pubkey: string,
signer: SetRequired<NostrSigner, 'nip44'>,
pagination: { limit?: number; until?: number; since?: number },
opts?: { signal?: AbortSignal },
): Promise<Transactions> {
const { since, until, limit } = pagination;
const transactions: Transactions = [];
const events = await store.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], {
signal: opts?.signal,
});
for (const event of events) {
const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse(
await signer.nip44.decrypt(pubkey, event.content),
);
if (!success) {
continue;
}
const direction = contentTags.find(([name]) => name === 'direction')?.[1];
if (direction !== 'out' && direction !== 'in') {
continue;
}
const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10);
if (isNaN(amount)) {
continue;
}
transactions.push({
created_at: event.created_at,
direction,
amount,
});
}
return transactions;
}
/** 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) {
@ -348,4 +299,4 @@ function isNostrId(value: unknown): boolean {
return n.id().safeParse(value).success; return n.id().safeParse(value).success;
} }
export { getLastRedeemedNutzap, getMintsToProofs, getTransactions, getWallet, organizeProofs, validateAndParseWallet }; export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet };

View file

@ -1,9 +1,3 @@
export { export { getLastRedeemedNutzap, getMintsToProofs, getWallet, organizeProofs, validateAndParseWallet } from './cashu.ts';
getLastRedeemedNutzap,
getMintsToProofs,
getTransactions,
getWallet,
organizeProofs,
validateAndParseWallet,
} from './cashu.ts';
export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts'; export { proofSchema, tokenEventSchema, type Wallet, walletSchema } from './schemas.ts';
export { renderTransaction, type Transaction } from './views.ts';

View file

@ -0,0 +1,85 @@
import { NSecSigner } from '@nostrify/nostrify';
import { NPostgres } from '@nostrify/db';
import { genEvent } from '@nostrify/nostrify/test';
import { generateSecretKey } from 'nostr-tools';
import { assertEquals } from '@std/assert';
import { DittoPolyPg, TestDB } from '@ditto/db';
import { DittoConf } from '@ditto/conf';
import { renderTransaction, type Transaction } from './views.ts';
Deno.test('renderTransaction 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 relay = new NPostgres(orig.kysely);
const history1 = genEvent({
kind: 7376,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'in'],
['amount', '33'],
]),
),
created_at: Math.floor(Date.now() / 1000), // now
}, sk);
await relay.event(history1);
const history2 = genEvent({
kind: 7376,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'out'],
['amount', '29'],
]),
),
created_at: Math.floor(Date.now() / 1000) - 1, // now - 1 second
}, sk);
await relay.event(history2);
const history3 = genEvent({
kind: 7376,
content: await signer.nip44.encrypt(
pubkey,
JSON.stringify([
['direction', 'ouch'],
['amount', 'yolo'],
]),
),
created_at: Math.floor(Date.now() / 1000) - 2, // now - 2 second
}, sk);
await relay.event(history3);
const events = await relay.query([{ kinds: [7376], authors: [pubkey], since: history2.created_at }]);
const transactions = await Promise.all(
events.map((event) => {
return renderTransaction(event, pubkey, signer);
}),
);
assertEquals(transactions, [
{
direction: 'in',
amount: 33,
created_at: history1.created_at,
},
{
direction: 'out',
amount: 29,
created_at: history2.created_at,
},
]);
});

44
packages/cashu/views.ts Normal file
View file

@ -0,0 +1,44 @@
import { type NostrEvent, type NostrSigner, NSchema as n } from '@nostrify/nostrify';
import type { SetRequired } from 'type-fest';
import { z } from 'zod';
type Transaction = {
amount: number;
created_at: number;
direction: 'in' | 'out';
};
/** Renders one history of transaction. */
async function renderTransaction(
event: NostrEvent,
viewerPubkey: string,
signer: SetRequired<NostrSigner, 'nip44'>,
): Promise<Transaction | undefined> {
if (event.kind !== 7376) return;
const { data: contentTags, success } = n.json().pipe(z.coerce.string().array().min(2).array()).safeParse(
await signer.nip44.decrypt(viewerPubkey, event.content),
);
if (!success) {
return;
}
const direction = contentTags.find(([name]) => name === 'direction')?.[1];
if (direction !== 'out' && direction !== 'in') {
return;
}
const amount = parseInt(contentTags.find(([name]) => name === 'amount')?.[1] ?? '', 10);
if (isNaN(amount)) {
return;
}
return {
created_at: event.created_at,
direction,
amount,
};
}
export { renderTransaction, type Transaction };

View file

@ -1,14 +1,14 @@
import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts'; import { CashuMint, CashuWallet, MintQuoteState, Proof } from '@cashu/cashu-ts';
import { import {
getTransactions,
getWallet, getWallet,
organizeProofs, organizeProofs,
renderTransaction,
tokenEventSchema, tokenEventSchema,
validateAndParseWallet, validateAndParseWallet,
type Wallet, type Wallet,
} from '@ditto/cashu'; } from '@ditto/cashu';
import { userMiddleware } from '@ditto/mastoapi/middleware'; import { userMiddleware } from '@ditto/mastoapi/middleware';
import { paginationSchema } from '@ditto/mastoapi/pagination'; import { paginated, paginationSchema } from '@ditto/mastoapi/pagination';
import { DittoRoute } from '@ditto/mastoapi/router'; import { DittoRoute } from '@ditto/mastoapi/router';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
@ -277,9 +277,21 @@ route.get('/transactions', userMiddleware({ enc: 'nip44' }), async (c) => {
const pubkey = await user.signer.getPublicKey(); const pubkey = await user.signer.getPublicKey();
const transactions = await getTransactions(relay, pubkey, user.signer, { since, until, limit }, { signal }); const events = await relay.query([{ kinds: [7376], authors: [pubkey], since, until, limit }], {
signal,
});
return c.json(transactions, 200); const transactions = await Promise.all(
events.map((event) => {
return renderTransaction(event, pubkey, user.signer);
}),
);
if (!transactions.length) {
return c.json([], 200);
}
return paginated(c, events, transactions);
}); });
/** Get mints set by the CASHU_MINTS environment variable. */ /** Get mints set by the CASHU_MINTS environment variable. */