From 4cb5540ccb7430d693153607d82dda8b904f757c Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 25 Jun 2026 15:07:26 -0400 Subject: [PATCH 1/6] feat: enhance API with new account and operator endpoints, implement withdraw functionality, and add pagination for credit transactions --- backend/README.md | 11 +- backend/src/antseed-funding-vault.ts | 77 +++++++++++++- backend/src/kv-credit-store.ts | 23 ++++ backend/src/withdraw-auth.ts | 87 +++++++++++++++ backend/src/worker.ts | 151 +++++++++++++++++++++++++-- backend/test/kv-credit-store.test.ts | 28 +++++ backend/test/withdraw-auth.test.ts | 66 ++++++++++++ backend/test/worker.test.ts | 110 ++++++++++++++++++- 8 files changed, 537 insertions(+), 16 deletions(-) create mode 100644 backend/src/withdraw-auth.ts create mode 100644 backend/test/withdraw-auth.test.ts diff --git a/backend/README.md b/backend/README.md index cf13ed8..9f99e51 100644 --- a/backend/README.md +++ b/backend/README.md @@ -16,16 +16,21 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base - `GET /config/status` - `GET /v1/accounts/:account/credit` - `GET /v1/accounts/:account/outstanding` -- `POST /v1/accounts/:account/withdraw` -- `GET /v1/requests/:requestId` +- `GET /v1/accounts/:account/transactions` — paginated `gdCredits` history (`status`, `limit`, `cursor`) +- `GET /v1/accounts/:account/withdrawable?buyer=0x...` — on-chain `withdrawablePrincipal` +- `POST /v1/accounts/:account/withdraw` — buyer EIP-712 sig → `withdrawPrincipal` on Base +- `POST /v1/accounts/:account/stream-credits` — manual daily stream accrual - `POST /v1/celo/events/record` -- `POST /v1/celo/streams/update` - `POST /v1/channels/close` +- `POST /v1/channels/withdraw` `POST /v1/celo/events/record` accepts either: - `{ "txHash": "0x..." }` - `{ "account": "0x...", "fromBlock": "0x...", "toBlock": "latest" }` +`POST /v1/accounts/:account/withdraw` body: +- `{ "buyerAddress": "0x...", "amountMicroUsd": "1000000", "recipient": "0x...", "timestamp": 1700000000, "buyerSig": "0x..." }` + ## Setup ```bash diff --git a/backend/src/antseed-funding-vault.ts b/backend/src/antseed-funding-vault.ts index a57ffeb..ae2461e 100644 --- a/backend/src/antseed-funding-vault.ts +++ b/backend/src/antseed-funding-vault.ts @@ -5,6 +5,9 @@ const FUNDING_VAULT_ABI = [ "function depositFor(address buyer, uint256 principal, uint256 bonus)", "function depositForWithId(address buyer, uint256 principal, uint256 bonus, string id)", "function requestClose(bytes32 channelId)", + "function withdrawChannel(bytes32 channelId)", + "function withdrawPrincipal(address buyer, uint256 amount, address recipient, uint256 timestamp, bytes buyerSig)", + "function withdrawablePrincipal(address buyer) view returns (uint256)", "function usedDepositIds(bytes32 id) view returns (bool)" ] as const; @@ -19,6 +22,8 @@ export type AntSeedFundingResult = { export class AntSeedFundingVaultClient { readonly enabled: boolean; private contract?: ethers.Contract; + private provider?: ethers.JsonRpcProvider; + private chainIdPromise?: Promise; private toBytes32Id(id: string): string { return ethers.id(id); @@ -31,12 +36,26 @@ export class AntSeedFundingVaultClient { cfg.ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY ); if (this.enabled) { - const provider = new ethers.JsonRpcProvider(cfg.ANTSEED_FUNDING_RPC_URL); - const signer = new ethers.Wallet(cfg.ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY!, provider); + this.provider = new ethers.JsonRpcProvider(cfg.ANTSEED_FUNDING_RPC_URL); + const signer = new ethers.Wallet(cfg.ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY!, this.provider); this.contract = new ethers.Contract(cfg.ANTSEED_FUNDING_VAULT_ADDRESS!, FUNDING_VAULT_ABI, signer); } } + get vaultAddress(): string | undefined { + return this.cfg.ANTSEED_FUNDING_VAULT_ADDRESS; + } + + async getChainId(): Promise { + if (!this.provider) { + throw new Error("bridge not configured"); + } + if (!this.chainIdPromise) { + this.chainIdPromise = this.provider.getNetwork().then((network) => Number(network.chainId)); + } + return this.chainIdPromise; + } + async depositForBuyer(buyer: string, principalMicroUsd: bigint, bonusMicroUsd: bigint): Promise { const total = principalMicroUsd + bonusMicroUsd; if (!this.contract) return { enabled: false, buyer, amountMicroUsd: total.toString() }; @@ -75,4 +94,58 @@ export class AntSeedFundingVaultClient { const receipt = await tx.wait(); return { enabled: true, txHash: receipt?.hash }; } + + async withdrawChannel(channelId: string): Promise<{ enabled: boolean; txHash?: string }> { + if (!this.contract) return { enabled: false }; + const tx = await this.contract.withdrawChannel(channelId); + const receipt = await tx.wait(); + return { enabled: true, txHash: receipt?.hash }; + } + + async getWithdrawablePrincipal(buyer: string): Promise<{ enabled: boolean; buyer: string; withdrawableMicroUsd: string }> { + const normalizedBuyer = buyer.toLowerCase(); + if (!this.contract) { + return { enabled: false, buyer: normalizedBuyer, withdrawableMicroUsd: "0" }; + } + const amount = await this.contract.withdrawablePrincipal(normalizedBuyer); + return { + enabled: true, + buyer: normalizedBuyer, + withdrawableMicroUsd: amount.toString() + }; + } + + async withdrawPrincipal( + buyer: string, + amountMicroUsd: bigint, + recipient: string, + timestamp: bigint, + buyerSig: string + ): Promise<{ enabled: boolean; buyer: string; amountMicroUsd: string; recipient: string; txHash?: string }> { + const normalizedBuyer = buyer.toLowerCase(); + const normalizedRecipient = recipient.toLowerCase(); + if (!this.contract) { + return { + enabled: false, + buyer: normalizedBuyer, + amountMicroUsd: amountMicroUsd.toString(), + recipient: normalizedRecipient + }; + } + const tx = await this.contract.withdrawPrincipal( + normalizedBuyer, + amountMicroUsd, + normalizedRecipient, + timestamp, + buyerSig + ); + const receipt = await tx.wait(); + return { + enabled: true, + buyer: normalizedBuyer, + amountMicroUsd: amountMicroUsd.toString(), + recipient: normalizedRecipient, + txHash: receipt?.hash + }; + } } diff --git a/backend/src/kv-credit-store.ts b/backend/src/kv-credit-store.ts index 59e4743..a98e9ae 100644 --- a/backend/src/kv-credit-store.ts +++ b/backend/src/kv-credit-store.ts @@ -120,6 +120,29 @@ export class KVCreditStore { return entries.filter((item): item is GdCreditEntry => Boolean(item)); } + async listGdCredits( + account: string, + options: { status?: GdCreditEntry["fundingStatus"]; limit?: number; cursor?: string } = {} + ): Promise<{ transactions: GdCreditEntry[]; nextCursor?: string }> { + const limit = Math.min(Math.max(options.limit ?? 20, 1), 100); + let entries = await this.getGdCredits(account); + if (options.status) { + entries = entries.filter((entry) => entry.fundingStatus === options.status); + } + entries.sort((a, b) => { + const byCreatedAt = b.createdAt.localeCompare(a.createdAt); + return byCreatedAt !== 0 ? byCreatedAt : b.id.localeCompare(a.id); + }); + if (options.cursor) { + const cursorIndex = entries.findIndex((entry) => entry.id === options.cursor); + if (cursorIndex >= 0) { + entries = entries.slice(cursorIndex + 1); + } + } + const page = entries.slice(0, limit); + const nextCursor = entries.length > limit ? page[page.length - 1]?.id : undefined; + return { transactions: page, nextCursor }; + } async getUser(account: string): Promise { const normalized = normalizeAccount(account); diff --git a/backend/src/withdraw-auth.ts b/backend/src/withdraw-auth.ts new file mode 100644 index 0000000..97cba2a --- /dev/null +++ b/backend/src/withdraw-auth.ts @@ -0,0 +1,87 @@ +import { verifyTypedData } from "ethers"; +import { Eip712SigningPayload } from "./operator-auth.js"; + +export const WITHDRAW_SIGNATURE_MAX_AGE_SECONDS = 300; + +export function withdrawPrincipalDomain(chainId: number, verifyingContract: string) { + return { + name: "AntseedBuyerOperator", + version: "1", + chainId, + verifyingContract + }; +} + +export const WITHDRAW_PRINCIPAL_TYPES = { + WithdrawPrincipal: [ + { name: "buyer", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + { name: "timestamp", type: "uint256" } + ] +}; + +export function assertWithdrawTimestampFresh(timestamp: number, nowSeconds = Math.floor(Date.now() / 1000)): void { + if (timestamp > nowSeconds) { + throw new Error("withdraw signature timestamp is in the future"); + } + if (nowSeconds - timestamp > WITHDRAW_SIGNATURE_MAX_AGE_SECONDS) { + throw new Error("withdraw signature expired"); + } +} + +export function recoverWithdrawPrincipalSigner( + chainId: number, + verifyingContract: string, + buyer: string, + amountMicroUsd: bigint, + recipient: string, + timestamp: bigint, + buyerSig: string +): string { + return verifyTypedData( + withdrawPrincipalDomain(chainId, verifyingContract), + WITHDRAW_PRINCIPAL_TYPES, + { + buyer, + amount: amountMicroUsd, + recipient, + timestamp + }, + buyerSig + ); +} + +export function buildWithdrawPrincipalPayload( + chainId: number, + verifyingContract: string, + buyer: string, + amountMicroUsd: bigint, + recipient: string, + timestamp: number +): Eip712SigningPayload { + return { + primaryType: "WithdrawPrincipal", + domain: { + name: "AntseedBuyerOperator", + version: "1", + chainId, + verifyingContract: verifyingContract.toLowerCase() + }, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ], + ...WITHDRAW_PRINCIPAL_TYPES + }, + message: { + buyer: buyer.toLowerCase(), + amount: amountMicroUsd.toString(), + recipient: recipient.toLowerCase(), + timestamp + } + }; +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 8e5b634..b0f88d3 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -2,8 +2,7 @@ * TODO * 1. keep list of user deposit/stream events when calling store.recordGdCredit, so it can be returned in the API response and used by frontend to correlate with on-chain events and show correct status in UI (eg. if funding failed, frontend can show "failed to credit" status on the specific deposit/stream event instead of just showing "0 G$ available" with no explanation) * 2. end point for user requesting credits for their active streams (if they want to trigger funding outside of the cron or deposit events) - * 3. implement withdraw endpoint, user can withdraw their principal from the vault if they want to stop using antseed. any unused deposited bonus will be withdrawn back to the vault. - * 4. implement max bonus cap per rootaccount to prevent abuse (eg. if someone creates 1000 accounts and deposits 1 GD in each to get 0.2 USD bonus on each deposit, we should have a cap like max 100 USD bonus per root account or something like that) + * 3. implement max bonus cap per rootaccount to prevent abuse (eg. if someone creates 1000 accounts and deposits 1 GD in each to get 0.2 USD bonus on each deposit, we should have a cap like max 100 USD bonus per root account or something like that) */ import { z } from "zod"; import { AntSeedFundingVaultClient } from "./antseed-funding-vault.js"; @@ -11,6 +10,7 @@ import { fetchCeloVaultEvents, fetchCeloVaultEventsForAccount, fetchCurrentGdMic import { Env, configFromEnv } from "./env.js"; import { KVCreditStore } from "./kv-credit-store.js"; import { GdCreditEntry } from "./types.js"; +import { assertWithdrawTimestampFresh, recoverWithdrawPrincipalSigner } from "./withdraw-auth.js"; const CeloEventsRecordSchema = z.object({ txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(), @@ -20,18 +20,20 @@ const CeloEventsRecordSchema = z.object({ }).refine((value) => Boolean(value.txHash || (value.account && value.fromBlock)), { message: "provide txHash or account+fromBlock" }); -const StreamUpdateSchema = z.object({ - account: z.string().min(1), - rootAccount: z.string().min(1).optional(), - flowRateWeiPerSecond: z.string().regex(/^\d+$/), - monthlyGdAmountWei: z.string().regex(/^\d+$/).optional(), - txHash: z.string().optional(), - logIndex: z.number().int().nonnegative().optional() -}); const CloseChannelSchema = z.object({ channelId: z.string().regex(/^0x[0-9a-fA-F]{64}$/) }); + +const WithdrawSchema = z.object({ + buyerAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), + amountMicroUsd: z.string().regex(/^\d+$/), + recipient: z.string().regex(/^0x[0-9a-fA-F]{40}$/), + timestamp: z.number().int().nonnegative(), + buyerSig: z.string().regex(/^0x[0-9a-fA-F]+$/) +}); + +const addressParam = z.string().regex(/^0x[0-9a-fA-F]{40}$/); const SuperfluidStreamsResponseSchema = z.object({ data: z.object({ streams: z.array(z.object({ @@ -210,6 +212,115 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis }); } + const transactionsMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/transactions$/); + if (request.method === "GET" && transactionsMatch) { + const account = decodeURIComponent(transactionsMatch[1]); + const statusParam = url.searchParams.get("status"); + const statusParsed = statusParam + ? z.enum(["pending", "funded", "failed"]).safeParse(statusParam) + : { success: true as const, data: undefined }; + if (!statusParsed.success) { + return json({ error: "status must be pending, funded, or failed" }, 400); + } + const limitParam = url.searchParams.get("limit"); + const limit = limitParam ? Number.parseInt(limitParam, 10) : undefined; + if (limitParam && (!Number.isFinite(limit) || limit! <= 0)) { + return json({ error: "limit must be a positive integer" }, 400); + } + const cursor = url.searchParams.get("cursor") ?? undefined; + const page = await store.listGdCredits(account, { + status: statusParsed.data, + limit, + cursor + }); + return json({ account: account.toLowerCase(), ...page }); + } + + const withdrawableMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/withdrawable$/); + if (request.method === "GET" && withdrawableMatch) { + const account = decodeURIComponent(withdrawableMatch[1]).toLowerCase(); + const buyer = url.searchParams.get("buyer"); + const buyerParsed = buyer ? addressParam.safeParse(buyer) : { success: false as const }; + if (!buyerParsed.success) { + return json({ error: "buyer query param required" }, 400); + } + const result = await antseedFundingVault.getWithdrawablePrincipal(buyerParsed.data); + return json({ account, ...result }); + } + + const withdrawMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/withdraw$/); + if (request.method === "POST" && withdrawMatch) { + const account = decodeURIComponent(withdrawMatch[1]).toLowerCase(); + const body = await parseJson(request); + const parsed = WithdrawSchema.safeParse(body); + if (!parsed.success) return json({ error: parsed.error.flatten() }, 400); + + const buyerAddress = parsed.data.buyerAddress.toLowerCase(); + const recipient = parsed.data.recipient.toLowerCase(); + const amountMicroUsd = BigInt(parsed.data.amountMicroUsd); + const timestamp = BigInt(parsed.data.timestamp); + + if (amountMicroUsd <= 0n) { + return json({ error: "amountMicroUsd must be greater than zero" }, 400); + } + + try { + assertWithdrawTimestampFresh(parsed.data.timestamp); + } catch (error) { + const message = error instanceof Error ? error.message : "invalid withdraw timestamp"; + return json({ error: message }, 400); + } + + if (!antseedFundingVault.enabled || !antseedFundingVault.vaultAddress) { + return json({ error: "base buyer operator bridge is not configured", account, buyerAddress }, 503); + } + + let signer: string; + try { + const chainId = await antseedFundingVault.getChainId(); + signer = recoverWithdrawPrincipalSigner( + chainId, + antseedFundingVault.vaultAddress, + buyerAddress, + amountMicroUsd, + recipient, + timestamp, + parsed.data.buyerSig + ).toLowerCase(); + } catch { + return json({ error: "invalid buyer signature" }, 400); + } + + if (signer !== buyerAddress) { + return json({ error: "buyer signature does not match buyerAddress" }, 400); + } + + const withdrawable = await antseedFundingVault.getWithdrawablePrincipal(buyerAddress); + if (!withdrawable.enabled) { + return json({ error: "base buyer operator bridge is not configured", account, buyerAddress }, 503); + } + if (amountMicroUsd > BigInt(withdrawable.withdrawableMicroUsd)) { + return json({ + error: "amount exceeds withdrawable principal", + withdrawableMicroUsd: withdrawable.withdrawableMicroUsd + }, 400); + } + + try { + const bridge = await antseedFundingVault.withdrawPrincipal( + buyerAddress, + amountMicroUsd, + recipient, + timestamp, + parsed.data.buyerSig + ); + return json({ account, buyerAddress, recipient, amountMicroUsd: amountMicroUsd.toString(), bridge }); + } catch (error) { + const message = error instanceof Error ? error.message : "withdraw failed"; + return json({ error: message, account, buyerAddress }, 502); + } + } + const streamCreditsMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/stream-credits$/); if (request.method === "POST" && streamCreditsMatch) { const account = decodeURIComponent(streamCreditsMatch[1]).toLowerCase(); @@ -265,6 +376,14 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis return json({ channelId: parsed.data.channelId, bridge }); } + if (request.method === "POST" && url.pathname === "/v1/channels/withdraw") { + const body = await parseJson(request); + const parsed = CloseChannelSchema.safeParse(body); + if (!parsed.success) return json({ error: parsed.error.flatten() }, 400); + const bridge = await antseedFundingVault.withdrawChannel(parsed.data.channelId); + return json({ channelId: parsed.data.channelId, bridge }); + } + return json({ error: "not found" }, 404); } @@ -307,6 +426,18 @@ async function fundCredit( BigInt(entry.bonusMicroUsd), entry.id ); + if (!bridge.enabled) { + const updated = await store.markFundingResult(entry, { funded: false, error: "bridge not configured" }); + return { + ...updated, + bridge: { + enabled: false, + buyer, + amountMicroUsd: entry.totalCreditMicroUsd, + error: "bridge not configured" + } + }; + } const updated = await store.markFundingResult(entry, { funded: true, txHash: bridge.txHash }); return { ...updated, bridge }; } catch (error) { diff --git a/backend/test/kv-credit-store.test.ts b/backend/test/kv-credit-store.test.ts index 30b1894..dab726d 100644 --- a/backend/test/kv-credit-store.test.ts +++ b/backend/test/kv-credit-store.test.ts @@ -273,3 +273,31 @@ test("markFundingResult updates lastStreamCreditAt for stream sources", async () // lastStreamCreditAt should be updated for stream sources assert.notEqual(after.lastStreamCreditAt, before.createdAt); }); + +test("listGdCredits paginates and filters by status", async () => { + const store = new KVCreditStore(new MemoryKV() as never); + const base = { + account: "0xabc", + rootAccount: "0xabc", + isVerified: true, + gdPrice: 1_000_000n, + maxBonusCapMicroUsd: 100_000_000n, + gdAmountWei: 1_000_000_000_000_000_000n + }; + + await store.recordGdCredit({ ...base, id: "a", source: "deposit" }); + const funded = await store.recordGdCredit({ ...base, id: "b", source: "deposit" }); + await store.markFundingResult(funded, { funded: true, txHash: "0xfunded" }); + await store.recordGdCredit({ ...base, id: "c", source: "deposit" }); + + const fundedOnly = await store.listGdCredits("0xabc", { status: "funded" }); + assert.equal(fundedOnly.transactions.length, 1); + assert.equal(fundedOnly.transactions[0].id, "b"); + + const firstPage = await store.listGdCredits("0xabc", { limit: 2 }); + assert.equal(firstPage.transactions.length, 2); + assert.equal(firstPage.nextCursor, firstPage.transactions[1]?.id); + + const secondPage = await store.listGdCredits("0xabc", { limit: 2, cursor: firstPage.nextCursor }); + assert.equal(secondPage.transactions.length, 1); +}); diff --git a/backend/test/withdraw-auth.test.ts b/backend/test/withdraw-auth.test.ts new file mode 100644 index 0000000..24f8c06 --- /dev/null +++ b/backend/test/withdraw-auth.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Wallet, verifyTypedData } from "ethers"; +import { + assertWithdrawTimestampFresh, + recoverWithdrawPrincipalSigner, + withdrawPrincipalDomain, + WITHDRAW_PRINCIPAL_TYPES, + WITHDRAW_SIGNATURE_MAX_AGE_SECONDS +} from "../src/withdraw-auth.js"; + +test("assertWithdrawTimestampFresh rejects expired signatures", () => { + const now = 1_700_000_000; + assert.throws( + () => assertWithdrawTimestampFresh(now - WITHDRAW_SIGNATURE_MAX_AGE_SECONDS - 1, now), + /expired/ + ); +}); + +test("assertWithdrawTimestampFresh rejects future timestamps", () => { + const now = 1_700_000_000; + assert.throws( + () => assertWithdrawTimestampFresh(now + 1, now), + /future/ + ); +}); + +test("recoverWithdrawPrincipalSigner matches ethers typed-data signing", async () => { + const wallet = Wallet.createRandom(); + const chainId = 8453; + const verifyingContract = "0x00000000000000000000000000000000000000aa"; + const amount = 3_000_000n; + const recipient = "0x0000000000000000000000000000000000000bbb"; + const timestamp = 1_700_000_000n; + const buyerSig = await wallet.signTypedData( + withdrawPrincipalDomain(chainId, verifyingContract), + WITHDRAW_PRINCIPAL_TYPES, + { + buyer: wallet.address, + amount, + recipient, + timestamp + } + ); + + const recovered = recoverWithdrawPrincipalSigner( + chainId, + verifyingContract, + wallet.address, + amount, + recipient, + timestamp, + buyerSig + ); + + assert.equal(recovered.toLowerCase(), wallet.address.toLowerCase()); + assert.equal( + verifyTypedData( + withdrawPrincipalDomain(chainId, verifyingContract), + WITHDRAW_PRINCIPAL_TYPES, + { buyer: wallet.address, amount, recipient, timestamp }, + buyerSig + ).toLowerCase(), + wallet.address.toLowerCase() + ); +}); diff --git a/backend/test/worker.test.ts b/backend/test/worker.test.ts index 1da3b0f..cd62527 100644 --- a/backend/test/worker.test.ts +++ b/backend/test/worker.test.ts @@ -119,7 +119,7 @@ test("/v1/celo/events/record processes deposit logs and records credits", async const body = await res.json() as { events: Array<{ id: string; source: string; fundingStatus: string; principalMicroUsd: string; buyerAddress?: string }> }; assert.equal(body.events.length, 1); assert.equal(body.events[0].source, "deposit"); - assert.equal(body.events[0].fundingStatus, "pending"); + assert.equal(body.events[0].fundingStatus, "failed"); assert.equal(body.events[0].buyerAddress, buyer.toLowerCase()); // Verify credit was recorded @@ -208,3 +208,111 @@ test("OPTIONS returns CORS preflight", async () => { assert.equal(res.status, 204); assert.equal(res.headers.get("access-control-allow-origin"), "*"); }); + +test("GET /v1/accounts/:account/transactions returns paginated gd credits", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const buyer = "0x0000000000000000000000000000000000000aaa"; + const txHash = `0x${"3".repeat(64)}`; + const celoVault = "0x0000000000000000000000000000000000000def"; + const originalFetch = globalThis.fetch; + + try { + const testEnv = env({ CELO_RPC_URL: "https://celo.rpc.local", CELO_VAULT_ADDRESS: celoVault }); + const depositLog = encodeVaultEventLog( + "GdDeposited", + [account, buyer, 1_000_000_000_000_000_000n, "0x"], + celoVault, + txHash, + 0 + ); + + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + if (body.method === "eth_call") { + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: "0x0000000000000000000000000000000000000000000000000000000000000000" + }); + } + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { logs: [depositLog] } + }); + }) as typeof fetch; + + await worker.fetch(new Request("https://worker.test/v1/celo/events/record", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ txHash }) + }), testEnv, {} as ExecutionContext); + + const res = await worker.fetch( + new Request(`https://worker.test/v1/accounts/${account}/transactions?limit=10`), + testEnv, + {} as ExecutionContext + ); + assert.equal(res.status, 200); + const body = await res.json() as { account: string; transactions: Array<{ source: string; fundingStatus: string }> }; + assert.equal(body.account, account); + assert.equal(body.transactions.length, 1); + assert.equal(body.transactions[0].source, "deposit"); + assert.equal(body.transactions[0].fundingStatus, "failed"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GET /v1/accounts/:account/withdrawable requires buyer query param", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const res = await worker.fetch( + new Request(`https://worker.test/v1/accounts/${account}/withdrawable`), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 400); +}); + +test("GET /v1/accounts/:account/withdrawable returns disabled bridge state", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const buyer = "0x0000000000000000000000000000000000000aaa"; + const res = await worker.fetch( + new Request(`https://worker.test/v1/accounts/${account}/withdrawable?buyer=${buyer}`), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 200); + const body = await res.json() as { enabled: boolean; withdrawableMicroUsd: string }; + assert.equal(body.enabled, false); + assert.equal(body.withdrawableMicroUsd, "0"); +}); + +test("POST /v1/accounts/:account/withdraw rejects when bridge disabled", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const res = await worker.fetch(new Request(`https://worker.test/v1/accounts/${account}/withdraw`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + buyerAddress: "0x0000000000000000000000000000000000000aaa", + amountMicroUsd: "1000000", + recipient: "0x0000000000000000000000000000000000000bbb", + timestamp: Math.floor(Date.now() / 1000), + buyerSig: "0x11" + }) + }), env(), {} as ExecutionContext); + assert.equal(res.status, 503); +}); + +test("POST /v1/channels/withdraw returns bridge disabled when not configured", async () => { + const channelId = `0x${"4".repeat(64)}`; + const res = await worker.fetch(new Request("https://worker.test/v1/channels/withdraw", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ channelId }) + }), env(), {} as ExecutionContext); + assert.equal(res.status, 200); + const body = await res.json() as { channelId: string; bridge: { enabled: boolean } }; + assert.equal(body.channelId, channelId); + assert.equal(body.bridge.enabled, false); +}); From da8e01e90b0f3807c490039a127c4b295ee39023 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 25 Jun 2026 15:07:54 -0400 Subject: [PATCH 2/6] feat: add operator consent and status endpoints, enhance README, and introduce .gitignore for wrangler --- backend/.gitignore | 1 + backend/README.md | 17 ++- backend/src/antseed-funding-vault.ts | 169 ++++++++++++++++++++++++++ backend/src/celo-events.ts | 14 ++- backend/src/kv-credit-store.ts | 2 +- backend/src/operator-auth.ts | 75 ++++++++++++ backend/src/worker.ts | 174 +++++++++++++++++++++++++-- backend/test/celo-events.test.ts | 8 +- backend/test/kv-credit-store.test.ts | 5 +- backend/test/operator-auth.test.ts | 38 ++++++ backend/test/withdraw-auth.test.ts | 31 +++++ backend/test/worker.test.ts | 56 ++++++++- backend/wrangler.toml | 8 +- 13 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/src/operator-auth.ts create mode 100644 backend/test/operator-auth.test.ts diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..7310e73 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.wrangler \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 9f99e51..d463462 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,10 +14,15 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base - `GET /health` - `GET /config/status` +- `GET /v1/accounts/:account/status` — dashboard: profile, operator consent, withdrawable, outstanding - `GET /v1/accounts/:account/credit` - `GET /v1/accounts/:account/outstanding` - `GET /v1/accounts/:account/transactions` — paginated `gdCredits` history (`status`, `limit`, `cursor`) -- `GET /v1/accounts/:account/withdrawable?buyer=0x...` — on-chain `withdrawablePrincipal` +- `GET /v1/accounts/:account/operator` — buyer operator consent status (`?buyer=` optional, defaults to account) +- `GET /v1/accounts/:account/operator/consent-payload` — EIP-712 typed data for operator consent (includes nonce) +- `POST /v1/accounts/:account/operator/accept` — submit buyer signature → `acceptBuyerOperator` on Base +- `GET /v1/accounts/:account/withdrawable` — on-chain `withdrawablePrincipal` (`?buyer=` optional) +- `GET /v1/accounts/:account/withdraw/payload` — EIP-712 typed data for withdraw (`amountMicroUsd`, `recipient`, optional `?buyer=`) - `POST /v1/accounts/:account/withdraw` — buyer EIP-712 sig → `withdrawPrincipal` on Base - `POST /v1/accounts/:account/stream-credits` — manual daily stream accrual - `POST /v1/celo/events/record` @@ -28,9 +33,19 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base - `{ "txHash": "0x..." }` - `{ "account": "0x...", "fromBlock": "0x...", "toBlock": "latest" }` +`POST /v1/accounts/:account/operator/accept` body: +- `{ "buyerSig": "0x...", "buyerAddress": "0x..." }` — `buyerAddress` optional (defaults to account) + `POST /v1/accounts/:account/withdraw` body: - `{ "buyerAddress": "0x...", "amountMicroUsd": "1000000", "recipient": "0x...", "timestamp": 1700000000, "buyerSig": "0x..." }` +## Widget flow (no Base chain details in UI) + +1. `GET /v1/accounts/:account/status` — check `operator.operatorAccepted` +2. If false: `GET .../operator/consent-payload` → wallet signs `typedData` → `POST .../operator/accept` +3. Celo deposit → `POST /v1/celo/events/record` +4. Withdraw: `GET .../withdraw/payload?amountMicroUsd=...&recipient=0x...` → sign → `POST .../withdraw` + ## Setup ```bash diff --git a/backend/src/antseed-funding-vault.ts b/backend/src/antseed-funding-vault.ts index ae2461e..8161add 100644 --- a/backend/src/antseed-funding-vault.ts +++ b/backend/src/antseed-funding-vault.ts @@ -1,9 +1,13 @@ import { ethers } from "ethers"; import { RuntimeConfig } from "./env.js"; +import { buildSetOperatorPayload } from "./operator-auth.js"; +import type { Eip712SigningPayload } from "./operator-auth.js"; const FUNDING_VAULT_ABI = [ + "function registry() view returns (address)", "function depositFor(address buyer, uint256 principal, uint256 bonus)", "function depositForWithId(address buyer, uint256 principal, uint256 bonus, string id)", + "function acceptBuyerOperator(address buyer, uint256 nonce, bytes buyerSig)", "function requestClose(bytes32 channelId)", "function withdrawChannel(bytes32 channelId)", "function withdrawPrincipal(address buyer, uint256 amount, address recipient, uint256 timestamp, bytes buyerSig)", @@ -11,6 +15,18 @@ const FUNDING_VAULT_ABI = [ "function usedDepositIds(bytes32 id) view returns (bool)" ] as const; +const REGISTRY_ABI = [ + "function deposits() view returns (address)" +] as const; + +const DEPOSITS_ABI = [ + "function getOperator(address buyer) view returns (address)", + "function domainSeparator() view returns (bytes32)", + "function eip712Domain() view returns (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)", + "function operatorNonces(address buyer) view returns (uint256)", + "function nonces(address buyer) view returns (uint256)" +] as const; + export type AntSeedFundingResult = { enabled: boolean; buyer: string; @@ -19,11 +35,23 @@ export type AntSeedFundingResult = { error?: string; }; +export type BuyerOperatorStatus = { + enabled: boolean; + account: string; + buyerAddress: string; + operatorAddress?: string; + currentOperator: string; + operatorAccepted: boolean; + consentNonce: string; +}; + export class AntSeedFundingVaultClient { readonly enabled: boolean; private contract?: ethers.Contract; private provider?: ethers.JsonRpcProvider; private chainIdPromise?: Promise; + private depositsAddressPromise?: Promise; + private depositsDomainPromise?: Promise<{ name: string; version: string }>; private toBytes32Id(id: string): string { return ethers.id(id); @@ -56,6 +84,147 @@ export class AntSeedFundingVaultClient { return this.chainIdPromise; } + private async getDepositsContract(): Promise { + if (!this.provider) { + throw new Error("bridge not configured"); + } + const depositsAddress = await this.getDepositsAddress(); + return new ethers.Contract(depositsAddress, DEPOSITS_ABI, this.provider); + } + + async getDepositsAddress(): Promise { + if (!this.contract) { + throw new Error("bridge not configured"); + } + if (!this.depositsAddressPromise) { + this.depositsAddressPromise = (async () => { + const registryAddress = await this.contract!.registry(); + const registry = new ethers.Contract(registryAddress, REGISTRY_ABI, this.provider!); + const depositsAddress = await registry.deposits(); + return String(depositsAddress).toLowerCase(); + })(); + } + return this.depositsAddressPromise; + } + + private async getDepositsEip712Domain(): Promise<{ name: string; version: string }> { + if (!this.depositsDomainPromise) { + this.depositsDomainPromise = (async () => { + const deposits = await this.getDepositsContract(); + try { + const domain = await deposits.eip712Domain(); + return { + name: String(domain.name), + version: String(domain.version) + }; + } catch { + return { name: "AntseedDeposits", version: "1" }; + } + })(); + } + return this.depositsDomainPromise; + } + + async getOperatorNonce(buyer: string): Promise { + const deposits = await this.getDepositsContract(); + const normalizedBuyer = buyer.toLowerCase(); + try { + const nonce = await deposits.operatorNonces(normalizedBuyer); + return BigInt(nonce.toString()); + } catch { + const nonce = await deposits.nonces(normalizedBuyer); + return BigInt(nonce.toString()); + } + } + + async getBuyerOperatorStatus(account: string, buyerAddress?: string): Promise { + const buyer = (buyerAddress ?? account).toLowerCase(); + const accountNormalized = account.toLowerCase(); + if (!this.contract || !this.vaultAddress) { + return { + enabled: false, + account: accountNormalized, + buyerAddress: buyer, + currentOperator: ethers.ZeroAddress, + operatorAccepted: false, + consentNonce: "0" + }; + } + + const deposits = await this.getDepositsContract(); + const currentOperator = String(await deposits.getOperator(buyer)).toLowerCase(); + const operatorAddress = this.vaultAddress.toLowerCase(); + const consentNonce = await this.getOperatorNonce(buyer); + + return { + enabled: true, + account: accountNormalized, + buyerAddress: buyer, + operatorAddress, + currentOperator, + operatorAccepted: currentOperator === operatorAddress, + consentNonce: consentNonce.toString() + }; + } + + async getDepositsSigningDomain(): Promise<{ name: string; version: string }> { + return this.getDepositsEip712Domain(); + } + + async buildOperatorConsentPayload(account: string, buyerAddress?: string): Promise<{ + enabled: boolean; + account: string; + buyerAddress: string; + typedData?: Eip712SigningPayload; + }> { + const status = await this.getBuyerOperatorStatus(account, buyerAddress); + if (!status.enabled || !status.operatorAddress) { + return { + enabled: false, + account: status.account, + buyerAddress: status.buyerAddress + }; + } + + const [chainId, depositsAddress, domain] = await Promise.all([ + this.getChainId(), + this.getDepositsAddress(), + this.getDepositsEip712Domain() + ]); + + return { + enabled: true, + account: status.account, + buyerAddress: status.buyerAddress, + typedData: buildSetOperatorPayload( + chainId, + depositsAddress, + status.operatorAddress, + BigInt(status.consentNonce), + domain + ) + }; + } + + async acceptBuyerOperator( + buyer: string, + nonce: bigint, + buyerSig: string + ): Promise<{ enabled: boolean; buyer: string; nonce: string; txHash?: string }> { + const normalizedBuyer = buyer.toLowerCase(); + if (!this.contract) { + return { enabled: false, buyer: normalizedBuyer, nonce: nonce.toString() }; + } + const tx = await this.contract.acceptBuyerOperator(normalizedBuyer, nonce, buyerSig); + const receipt = await tx.wait(); + return { + enabled: true, + buyer: normalizedBuyer, + nonce: nonce.toString(), + txHash: receipt?.hash + }; + } + async depositForBuyer(buyer: string, principalMicroUsd: bigint, bonusMicroUsd: bigint): Promise { const total = principalMicroUsd + bonusMicroUsd; if (!this.contract) return { enabled: false, buyer, amountMicroUsd: total.toString() }; diff --git a/backend/src/celo-events.ts b/backend/src/celo-events.ts index 9c6a9eb..b44218e 100644 --- a/backend/src/celo-events.ts +++ b/backend/src/celo-events.ts @@ -12,9 +12,12 @@ const GOODID_ABI = new Interface([ ]); const RESERVE_ORACLE_ABI = new Interface([ - "function currentPrice(bytes32 exchangeId) view returns (uint256)" + "function currentPriceDAI() view returns (uint256)" ]); +const RESERVE_PRICE_DECIMALS = 1_000_000_000_000_000_000n; +const MICRO_USD_PER_USD = 1_000_000n; + export type ParsedCeloVaultEvent = | { kind: "deposit"; @@ -77,16 +80,15 @@ export async function fetchCeloVaultEventsForAccount( export async function fetchCurrentGdMicroUsdPerToken(cfg: RuntimeConfig): Promise { if (!cfg.CELO_RPC_URL || !cfg.CELO_RESERVE_PRICE_ORACLE_ADDRESS) return cfg.GD_MICRO_USD_PER_TOKEN; - const data = RESERVE_ORACLE_ABI.encodeFunctionData("currentPrice", []); + const data = RESERVE_ORACLE_ABI.encodeFunctionData("currentPriceDAI", []); const result = await rpc(cfg.CELO_RPC_URL, "eth_call", [{ to: cfg.CELO_RESERVE_PRICE_ORACLE_ADDRESS, data }, "latest"]); if (!result || result === "0x") return cfg.GD_MICRO_USD_PER_TOKEN; - const [price] = RESERVE_ORACLE_ABI.decodeFunctionResult("currentPrice", result); - const reservePrice = BigInt(price.toString()); + const [reservePriceDai] = RESERVE_ORACLE_ABI.decodeFunctionResult("currentPriceDAI", result); + const reservePrice = BigInt(reservePriceDai.toString()); if (reservePrice > 0n) { - return reservePrice; + return (reservePrice * MICRO_USD_PER_USD) / RESERVE_PRICE_DECIMALS; } - return cfg.GD_MICRO_USD_PER_TOKEN; } diff --git a/backend/src/kv-credit-store.ts b/backend/src/kv-credit-store.ts index a98e9ae..33103a0 100644 --- a/backend/src/kv-credit-store.ts +++ b/backend/src/kv-credit-store.ts @@ -84,7 +84,7 @@ export class KVCreditStore { totalOutstandingFundingMicroUsd: addDecimalStrings(current.totalOutstandingFundingMicroUsd, entry.totalCreditMicroUsd), })); - return entry; + return (await this.getJson(`${GD_CREDIT_PREFIX}${entryId}`))!; } async markFundingResult(entry: GdCreditEntry, result: { funded: boolean; id?: string; txHash?: string; error?: string }): Promise { diff --git a/backend/src/operator-auth.ts b/backend/src/operator-auth.ts new file mode 100644 index 0000000..edac54a --- /dev/null +++ b/backend/src/operator-auth.ts @@ -0,0 +1,75 @@ +import { verifyTypedData } from "ethers"; + +export type Eip712SigningPayload = { + primaryType: string; + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: string; + }; + types: Record>; + message: Record; +}; + +export const SET_OPERATOR_TYPES = { + SetOperator: [ + { name: "operator", type: "address" }, + { name: "nonce", type: "uint256" } + ] +}; + +export function buildSetOperatorPayload( + chainId: number, + depositsAddress: string, + operatorAddress: string, + nonce: bigint, + domain: { name: string; version: string } +): Eip712SigningPayload { + return { + primaryType: "SetOperator", + domain: { + name: domain.name, + version: domain.version, + chainId, + verifyingContract: depositsAddress.toLowerCase() + }, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ], + ...SET_OPERATOR_TYPES + }, + message: { + operator: operatorAddress.toLowerCase(), + nonce: nonce.toString() + } + }; +} + +export function recoverSetOperatorSigner( + chainId: number, + depositsAddress: string, + operatorAddress: string, + nonce: bigint, + buyerSig: string, + domain: { name: string; version: string } +): string { + return verifyTypedData( + { + name: domain.name, + version: domain.version, + chainId, + verifyingContract: depositsAddress + }, + SET_OPERATOR_TYPES, + { + operator: operatorAddress, + nonce + }, + buyerSig + ); +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts index b0f88d3..f81cbce 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -10,7 +10,8 @@ import { fetchCeloVaultEvents, fetchCeloVaultEventsForAccount, fetchCurrentGdMic import { Env, configFromEnv } from "./env.js"; import { KVCreditStore } from "./kv-credit-store.js"; import { GdCreditEntry } from "./types.js"; -import { assertWithdrawTimestampFresh, recoverWithdrawPrincipalSigner } from "./withdraw-auth.js"; +import { assertWithdrawTimestampFresh, buildWithdrawPrincipalPayload, recoverWithdrawPrincipalSigner } from "./withdraw-auth.js"; +import { recoverSetOperatorSigner } from "./operator-auth.js"; const CeloEventsRecordSchema = z.object({ txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(), @@ -33,6 +34,11 @@ const WithdrawSchema = z.object({ buyerSig: z.string().regex(/^0x[0-9a-fA-F]+$/) }); +const OperatorAcceptSchema = z.object({ + buyerAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(), + buyerSig: z.string().regex(/^0x[0-9a-fA-F]+$/) +}); + const addressParam = z.string().regex(/^0x[0-9a-fA-F]{40}$/); const SuperfluidStreamsResponseSchema = z.object({ data: z.object({ @@ -139,6 +145,108 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis return json({ account: profile.account, profile, gdCredits }); } + const statusMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/status$/); + if (request.method === "GET" && statusMatch) { + const account = decodeURIComponent(statusMatch[1]).toLowerCase(); + const buyerQuery = url.searchParams.get("buyer"); + const buyerAddress = buyerQuery && addressParam.safeParse(buyerQuery).success + ? buyerQuery.toLowerCase() + : account; + const [profile, gdCredits, operator, withdrawable] = await Promise.all([ + store.getUser(account), + store.getGdCredits(account), + antseedFundingVault.getBuyerOperatorStatus(account, buyerAddress), + antseedFundingVault.getWithdrawablePrincipal(buyerAddress) + ]); + const outstandingFundingCredits = gdCredits.filter((entry) => entry.fundingStatus === "failed" || entry.fundingStatus === "pending"); + return json({ + account, + buyerAddress, + profile, + operator, + withdrawableMicroUsd: withdrawable.withdrawableMicroUsd, + outstandingFundingMicroUsd: profile.totalOutstandingFundingMicroUsd, + outstandingFundingCount: outstandingFundingCredits.length + }); + } + + const operatorConsentPayloadMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/operator\/consent-payload$/); + if (request.method === "GET" && operatorConsentPayloadMatch) { + const account = decodeURIComponent(operatorConsentPayloadMatch[1]).toLowerCase(); + const buyerQuery = url.searchParams.get("buyer"); + const buyerAddress = buyerQuery && addressParam.safeParse(buyerQuery).success + ? buyerQuery.toLowerCase() + : account; + const payload = await antseedFundingVault.buildOperatorConsentPayload(account, buyerAddress); + if (!payload.enabled) { + return json({ error: "base buyer operator bridge is not configured", account, buyerAddress }, 503); + } + return json(payload); + } + + const operatorAcceptMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/operator\/accept$/); + if (request.method === "POST" && operatorAcceptMatch) { + const account = decodeURIComponent(operatorAcceptMatch[1]).toLowerCase(); + const body = await parseJson(request); + const parsed = OperatorAcceptSchema.safeParse(body); + if (!parsed.success) return json({ error: parsed.error.flatten() }, 400); + + const buyerAddress = (parsed.data.buyerAddress ?? account).toLowerCase(); + if (!antseedFundingVault.enabled || !antseedFundingVault.vaultAddress) { + return json({ error: "base buyer operator bridge is not configured", account, buyerAddress }, 503); + } + + const operatorStatus = await antseedFundingVault.getBuyerOperatorStatus(account, buyerAddress); + if (operatorStatus.operatorAccepted) { + return json({ account, buyerAddress, operator: operatorStatus, message: "operator already accepted" }); + } + + const [chainId, depositsAddress, domain, nonce] = await Promise.all([ + antseedFundingVault.getChainId(), + antseedFundingVault.getDepositsAddress(), + antseedFundingVault.getDepositsSigningDomain(), + antseedFundingVault.getOperatorNonce(buyerAddress) + ]); + + let signer: string; + try { + signer = recoverSetOperatorSigner( + chainId, + depositsAddress, + operatorStatus.operatorAddress!, + nonce, + parsed.data.buyerSig, + domain + ).toLowerCase(); + } catch { + return json({ error: "invalid buyer signature" }, 400); + } + + if (signer !== buyerAddress) { + return json({ error: "buyer signature does not match buyerAddress" }, 400); + } + + try { + const bridge = await antseedFundingVault.acceptBuyerOperator(buyerAddress, nonce, parsed.data.buyerSig); + const operator = await antseedFundingVault.getBuyerOperatorStatus(account, buyerAddress); + return json({ account, buyerAddress, operator, bridge }); + } catch (error) { + const message = error instanceof Error ? error.message : "operator accept failed"; + return json({ error: message, account, buyerAddress }, 502); + } + } + + const operatorMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/operator$/); + if (request.method === "GET" && operatorMatch) { + const account = decodeURIComponent(operatorMatch[1]).toLowerCase(); + const buyerQuery = url.searchParams.get("buyer"); + const buyerAddress = buyerQuery && addressParam.safeParse(buyerQuery).success + ? buyerQuery.toLowerCase() + : account; + const operator = await antseedFundingVault.getBuyerOperatorStatus(account, buyerAddress); + return json(operator); + } + if (request.method === "POST" && url.pathname === "/v1/celo/events/record") { const body = await parseJson(request); @@ -239,13 +347,63 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis const withdrawableMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/withdrawable$/); if (request.method === "GET" && withdrawableMatch) { const account = decodeURIComponent(withdrawableMatch[1]).toLowerCase(); - const buyer = url.searchParams.get("buyer"); - const buyerParsed = buyer ? addressParam.safeParse(buyer) : { success: false as const }; - if (!buyerParsed.success) { - return json({ error: "buyer query param required" }, 400); + const buyerQuery = url.searchParams.get("buyer"); + const buyerAddress = buyerQuery && addressParam.safeParse(buyerQuery).success + ? buyerQuery.toLowerCase() + : account; + const result = await antseedFundingVault.getWithdrawablePrincipal(buyerAddress); + return json({ account, buyerAddress, ...result }); + } + + const withdrawPayloadMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/withdraw\/payload$/); + if (request.method === "GET" && withdrawPayloadMatch) { + const account = decodeURIComponent(withdrawPayloadMatch[1]).toLowerCase(); + const buyerQuery = url.searchParams.get("buyer"); + const buyerAddress = buyerQuery && addressParam.safeParse(buyerQuery).success + ? buyerQuery.toLowerCase() + : account; + const amountParam = url.searchParams.get("amountMicroUsd"); + const recipientParam = url.searchParams.get("recipient"); + if (!amountParam || !/^\d+$/.test(amountParam)) { + return json({ error: "amountMicroUsd query param required" }, 400); + } + const recipientParsed = recipientParam ? addressParam.safeParse(recipientParam) : { success: false as const }; + if (!recipientParsed.success) { + return json({ error: "recipient query param required" }, 400); + } + const amountMicroUsd = BigInt(amountParam); + if (amountMicroUsd <= 0n) { + return json({ error: "amountMicroUsd must be greater than zero" }, 400); } - const result = await antseedFundingVault.getWithdrawablePrincipal(buyerParsed.data); - return json({ account, ...result }); + if (!antseedFundingVault.enabled || !antseedFundingVault.vaultAddress) { + return json({ error: "base buyer operator bridge is not configured", account, buyerAddress }, 503); + } + const [chainId, withdrawable] = await Promise.all([ + antseedFundingVault.getChainId(), + antseedFundingVault.getWithdrawablePrincipal(buyerAddress) + ]); + if (amountMicroUsd > BigInt(withdrawable.withdrawableMicroUsd)) { + return json({ + error: "amount exceeds withdrawable principal", + withdrawableMicroUsd: withdrawable.withdrawableMicroUsd + }, 400); + } + const timestamp = Math.floor(Date.now() / 1000); + return json({ + account, + buyerAddress, + recipient: recipientParsed.data.toLowerCase(), + amountMicroUsd: amountMicroUsd.toString(), + timestamp, + typedData: buildWithdrawPrincipalPayload( + chainId, + antseedFundingVault.vaultAddress, + buyerAddress, + amountMicroUsd, + recipientParsed.data, + timestamp + ) + }); } const withdrawMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/withdraw$/); @@ -416,7 +574,7 @@ async function fundCredit( antseedFundingVault: AntSeedFundingVaultClient ): Promise<{ [key: string]: unknown }> { if (entry.fundingStatus === "funded") { - throw new Error(`cannot fund credit with status ${entry.fundingStatus}`); + return { ...entry, bridge: { enabled: antseedFundingVault.enabled, skipped: true } }; } const buyer = entry.buyerAddress || entry.account; try { diff --git a/backend/test/celo-events.test.ts b/backend/test/celo-events.test.ts index 5592401..44eee04 100644 --- a/backend/test/celo-events.test.ts +++ b/backend/test/celo-events.test.ts @@ -79,19 +79,19 @@ test("decodeBuyerFromUserData decodes abi-encoded address from Superfluid userDa assert.equal(decodeBuyerFromUserData("0x" + "00".repeat(32)), undefined); // zero address }); -test("fetches GD price from reserve currentPriceCDAI", async () => { - const reserveAbi = new Interface(["function currentPriceCDAI() view returns (uint256)"]); +test("fetches GD price from reserve currentPriceDAI", async () => { + const reserveAbi = new Interface(["function currentPriceDAI() view returns (uint256)"]); const previousFetch = globalThis.fetch; globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { const body = JSON.parse(String(init?.body)); assert.equal(body.method, "eth_call"); const callData = String(body.params[0].data); - const expectedSelector = reserveAbi.encodeFunctionData("currentPriceCDAI", []).slice(0, 10); + const expectedSelector = reserveAbi.encodeFunctionData("currentPriceDAI", []).slice(0, 10); assert.equal(callData.slice(0, 10), expectedSelector); return Response.json({ jsonrpc: "2.0", id: body.id, - result: reserveAbi.encodeFunctionResult("currentPriceCDAI", [500000000000000000n]) + result: reserveAbi.encodeFunctionResult("currentPriceDAI", [500000000000000000n]) }); }) as typeof fetch; diff --git a/backend/test/kv-credit-store.test.ts b/backend/test/kv-credit-store.test.ts index dab726d..3e47660 100644 --- a/backend/test/kv-credit-store.test.ts +++ b/backend/test/kv-credit-store.test.ts @@ -267,11 +267,12 @@ test("markFundingResult updates lastStreamCreditAt for stream sources", async () }); const before = await store.getUser("0xABC"); + await new Promise((resolve) => setTimeout(resolve, 5)); await store.markFundingResult(entry, { funded: true, txHash: "0xstream" }); const after = await store.getUser("0xABC"); - // lastStreamCreditAt should be updated for stream sources - assert.notEqual(after.lastStreamCreditAt, before.createdAt); + assert.notEqual(after.lastStreamCreditAt, before.lastStreamCreditAt); + assert.equal(after.lastStreamCreditAt, after.updatedAt); }); test("listGdCredits paginates and filters by status", async () => { diff --git a/backend/test/operator-auth.test.ts b/backend/test/operator-auth.test.ts new file mode 100644 index 0000000..c49590f --- /dev/null +++ b/backend/test/operator-auth.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Wallet, verifyTypedData } from "ethers"; +import { + buildSetOperatorPayload, + recoverSetOperatorSigner, + SET_OPERATOR_TYPES +} from "../src/operator-auth.js"; + +test("buildSetOperatorPayload matches wallet signTypedData", async () => { + const wallet = Wallet.createRandom(); + const chainId = 8453; + const depositsAddress = "0x00000000000000000000000000000000000000aa"; + const operatorAddress = "0x0000000000000000000000000000000000000bbb"; + const nonce = 7n; + const domain = { name: "AntseedDeposits", version: "1" }; + + const payload = buildSetOperatorPayload(chainId, depositsAddress, operatorAddress, nonce, domain); + const sig = await wallet.signTypedData(payload.domain, SET_OPERATOR_TYPES, { + operator: operatorAddress, + nonce + }); + + const recovered = recoverSetOperatorSigner( + chainId, + depositsAddress, + operatorAddress, + nonce, + sig, + domain + ); + + assert.equal(recovered.toLowerCase(), wallet.address.toLowerCase()); + assert.equal( + verifyTypedData(payload.domain, SET_OPERATOR_TYPES, { operator: operatorAddress, nonce }, sig).toLowerCase(), + wallet.address.toLowerCase() + ); +}); diff --git a/backend/test/withdraw-auth.test.ts b/backend/test/withdraw-auth.test.ts index 24f8c06..223809a 100644 --- a/backend/test/withdraw-auth.test.ts +++ b/backend/test/withdraw-auth.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { Wallet, verifyTypedData } from "ethers"; import { assertWithdrawTimestampFresh, + buildWithdrawPrincipalPayload, recoverWithdrawPrincipalSigner, withdrawPrincipalDomain, WITHDRAW_PRINCIPAL_TYPES, @@ -64,3 +65,33 @@ test("recoverWithdrawPrincipalSigner matches ethers typed-data signing", async ( wallet.address.toLowerCase() ); }); + +test("buildWithdrawPrincipalPayload matches wallet signTypedData", async () => { + const wallet = Wallet.createRandom(); + const chainId = 8453; + const verifyingContract = "0x00000000000000000000000000000000000000aa"; + const amount = 1_000_000n; + const recipient = "0x0000000000000000000000000000000000000bbb"; + const timestamp = 1_700_000_000; + + const payload = buildWithdrawPrincipalPayload( + chainId, + verifyingContract, + wallet.address, + amount, + recipient, + timestamp + ); + + const sig = await wallet.signTypedData(payload.domain, WITHDRAW_PRINCIPAL_TYPES, { + buyer: wallet.address, + amount, + recipient, + timestamp + }); + + assert.equal( + recoverWithdrawPrincipalSigner(chainId, verifyingContract, wallet.address, amount, recipient, BigInt(timestamp), sig).toLowerCase(), + wallet.address.toLowerCase() + ); +}); diff --git a/backend/test/worker.test.ts b/backend/test/worker.test.ts index cd62527..8476522 100644 --- a/backend/test/worker.test.ts +++ b/backend/test/worker.test.ts @@ -264,30 +264,74 @@ test("GET /v1/accounts/:account/transactions returns paginated gd credits", asyn } }); -test("GET /v1/accounts/:account/withdrawable requires buyer query param", async () => { +test("GET /v1/accounts/:account/withdrawable returns disabled bridge state", async () => { const account = "0x0000000000000000000000000000000000000abc"; const res = await worker.fetch( new Request(`https://worker.test/v1/accounts/${account}/withdrawable`), env(), {} as ExecutionContext ); - assert.equal(res.status, 400); + assert.equal(res.status, 200); + const body = await res.json() as { enabled: boolean; withdrawableMicroUsd: string; buyerAddress: string }; + assert.equal(body.enabled, false); + assert.equal(body.withdrawableMicroUsd, "0"); + assert.equal(body.buyerAddress, account); }); -test("GET /v1/accounts/:account/withdrawable returns disabled bridge state", async () => { +test("GET /v1/accounts/:account/operator returns disabled bridge state", async () => { const account = "0x0000000000000000000000000000000000000abc"; - const buyer = "0x0000000000000000000000000000000000000aaa"; const res = await worker.fetch( - new Request(`https://worker.test/v1/accounts/${account}/withdrawable?buyer=${buyer}`), + new Request(`https://worker.test/v1/accounts/${account}/operator`), env(), {} as ExecutionContext ); assert.equal(res.status, 200); - const body = await res.json() as { enabled: boolean; withdrawableMicroUsd: string }; + const body = await res.json() as { enabled: boolean; operatorAccepted: boolean; consentNonce: string }; assert.equal(body.enabled, false); + assert.equal(body.operatorAccepted, false); + assert.equal(body.consentNonce, "0"); +}); + +test("GET /v1/accounts/:account/operator/consent-payload returns 503 when bridge disabled", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const res = await worker.fetch( + new Request(`https://worker.test/v1/accounts/${account}/operator/consent-payload`), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 503); +}); + +test("GET /v1/accounts/:account/status returns profile and operator summary", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const res = await worker.fetch( + new Request(`https://worker.test/v1/accounts/${account}/status`), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 200); + const body = await res.json() as { + account: string; + profile: { totalGdDepositedWei: string }; + operator: { operatorAccepted: boolean }; + withdrawableMicroUsd: string; + }; + assert.equal(body.account, account); + assert.equal(body.profile.totalGdDepositedWei, "0"); + assert.equal(body.operator.operatorAccepted, false); assert.equal(body.withdrawableMicroUsd, "0"); }); +test("GET /v1/accounts/:account/withdraw/payload validates query params", async () => { + const account = "0x0000000000000000000000000000000000000abc"; + const res = await worker.fetch( + new Request(`https://worker.test/v1/accounts/${account}/withdraw/payload`), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 400); +}); + test("POST /v1/accounts/:account/withdraw rejects when bridge disabled", async () => { const account = "0x0000000000000000000000000000000000000abc"; const res = await worker.fetch(new Request(`https://worker.test/v1/accounts/${account}/withdraw`, { diff --git a/backend/wrangler.toml b/backend/wrangler.toml index c6913c7..442e645 100644 --- a/backend/wrangler.toml +++ b/backend/wrangler.toml @@ -9,10 +9,10 @@ compatibility_flags = ["nodejs_compat"] # wrangler secret put ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY [vars] GD_MICRO_USD_PER_TOKEN = "1000000" -CELO_RPC_URL=https://forno.celo.org -CELO_VAULT_ADDRESS=0x -CELO_GOODID_ADDRESS=0xC361A6E67822a0EDc17D899227dd9FC50BD62F42 -CELO_RESERVE_PRICE_ORACLE_ADDRESS=0x2fFBB49055d487DdBBb0C052Cd7c2a02A7971e41 +CELO_RPC_URL = "https://forno.celo.org" +CELO_VAULT_ADDRESS = "0x" +CELO_GOODID_ADDRESS = "0xC361A6E67822a0EDc17D899227dd9FC50BD62F42" +CELO_RESERVE_PRICE_ORACLE_ADDRESS = "0x2fFBB49055d487DdBBb0C052Cd7c2a02A7971e41" [[kv_namespaces]] binding = "ANTSEED_KV" From 17f6987cb00c61d369bec88f6ffdfcdcb3ef2cab Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 25 Jun 2026 16:27:36 -0400 Subject: [PATCH 3/6] feat: implement buyer linking functionality in KVCreditStore, update API to reflect buyer information, and enhance tests for buyer-related logic --- backend/README.md | 2 +- backend/src/kv-credit-store.ts | 56 +++++++++++++++++++---- backend/src/types.ts | 1 + backend/src/worker.ts | 17 +++---- backend/test/kv-credit-store.test.ts | 68 ++++++++++++++++++++++++++++ backend/test/worker.test.ts | 5 +- 6 files changed, 129 insertions(+), 20 deletions(-) diff --git a/backend/README.md b/backend/README.md index d463462..8f1e6e7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,7 +14,7 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base - `GET /health` - `GET /config/status` -- `GET /v1/accounts/:account/status` — dashboard: profile, operator consent, withdrawable, outstanding +- `GET /v1/accounts/:account/status` — dashboard: profile, linked `buyer`, operator consent, withdrawable, outstanding - `GET /v1/accounts/:account/credit` - `GET /v1/accounts/:account/outstanding` - `GET /v1/accounts/:account/transactions` — paginated `gdCredits` history (`status`, `limit`, `cursor`) diff --git a/backend/src/kv-credit-store.ts b/backend/src/kv-credit-store.ts index 33103a0..85ef778 100644 --- a/backend/src/kv-credit-store.ts +++ b/backend/src/kv-credit-store.ts @@ -73,16 +73,20 @@ export class KVCreditStore { if (effectiveBonusMicroUsd > 0n) { await this.addMonthlyBonusUsed(rootAccount, month, effectiveBonusMicroUsd); } - await this.updateUser(account, rootAccount, (current) => ({ - ...current, - rootAccount: rootAccount, - createdAt: current.createdAt ?? now, - updatedAt: now, - streamFlowRateWeiPerSecond: input.flowRate ? input.flowRate.toString() : current.streamFlowRateWeiPerSecond, - totalGdDepositedWei: addDecimalStrings(current.totalGdDepositedWei, entry.gdAmountWei), - totalGDStreamedWei: input.source.startsWith("stream") ? addDecimalStrings(current.totalGDStreamedWei, entry.gdAmountWei) : current.totalGDStreamedWei, - totalOutstandingFundingMicroUsd: addDecimalStrings(current.totalOutstandingFundingMicroUsd, entry.totalCreditMicroUsd), - })); + const effectiveBuyer = (entry.buyerAddress ?? account).toLowerCase(); + await this.updateUser(account, rootAccount, (current) => { + assertBuyerMatches(current, effectiveBuyer); + return { + ...current, + rootAccount: rootAccount, + createdAt: current.createdAt ?? now, + updatedAt: now, + streamFlowRateWeiPerSecond: input.flowRate ? input.flowRate.toString() : current.streamFlowRateWeiPerSecond, + totalGdDepositedWei: addDecimalStrings(current.totalGdDepositedWei, entry.gdAmountWei), + totalGDStreamedWei: input.source.startsWith("stream") ? addDecimalStrings(current.totalGDStreamedWei, entry.gdAmountWei) : current.totalGDStreamedWei, + totalOutstandingFundingMicroUsd: addDecimalStrings(current.totalOutstandingFundingMicroUsd, entry.totalCreditMicroUsd), + }; + }); return (await this.getJson(`${GD_CREDIT_PREFIX}${entryId}`))!; } @@ -150,6 +154,18 @@ export class KVCreditStore { return normalizeProfile(saved, normalized); } + async setBuyer(account: string, buyerAddress: string, rootAccount?: string): Promise { + const normalized = normalizeAccount(account); + const buyer = normalizeAccount(buyerAddress); + const root = normalizeAccount(rootAccount ?? account); + const now = new Date().toISOString(); + await this.updateUser(normalized, root, (current) => ({ + ...assignBuyer(current, buyer), + updatedAt: now, + })); + return this.getUser(normalized); + } + private async addGdCreditToAccount(account: string, entryId: string): Promise { const key = `${USER_GD_CREDITS_PREFIX}${account}`; const ids = (await this.getJson(key)) ?? []; @@ -179,6 +195,7 @@ export class KVCreditStore { if (normalizedRoot !== normalized) { const rootCurrent = await this.getUser(normalizedRoot); const rootNext = mutate({ ...rootCurrent, account: normalizedRoot, rootAccount: normalizedRoot }); + rootNext.buyer = rootCurrent.buyer; await this.putJson(`${USER_PREFIX}${normalizedRoot}`, rootNext); } } @@ -207,9 +224,28 @@ function normalizeProfile(saved: Partial | undefined, account totalGDStreamedWei: saved?.totalGDStreamedWei ?? "0", totalOutstandingFundingMicroUsd: saved?.totalOutstandingFundingMicroUsd ?? "0", lastStreamCreditAt: saved?.lastStreamCreditAt ?? createdAt, + buyer: normalizeBuyer(saved?.buyer), }; } +function normalizeBuyer(buyer: string | undefined): string | undefined { + return buyer ? buyer.toLowerCase() : undefined; +} + +function assignBuyer(profile: UserCreditProfile, buyer: string): UserCreditProfile { + const normalized = buyer.toLowerCase(); + if (!profile.buyer) return { ...profile, buyer: normalized }; + if (profile.buyer === normalized) return profile; + throw new Error(`payer ${profile.account} is linked to buyer ${profile.buyer}, cannot use ${normalized}`); +} + +function assertBuyerMatches(profile: UserCreditProfile, buyer: string): void { + const normalized = buyer.toLowerCase(); + if (profile.buyer && profile.buyer !== normalized) { + throw new Error(`payer ${profile.account} is linked to buyer ${profile.buyer}, cannot credit buyer ${normalized}`); + } +} + function normalizeAccount(account: string): string { return account.toLowerCase(); } diff --git a/backend/src/types.ts b/backend/src/types.ts index 2d1ce4c..a34e017 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -30,6 +30,7 @@ export type UserCreditProfile = { totalOutstandingFundingMicroUsd: string; streamFlowRateWeiPerSecond: string; lastStreamCreditAt: string; + buyer?: string; }; export type GdCreditEntry = { diff --git a/backend/src/worker.ts b/backend/src/worker.ts index f81cbce..ca90600 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -148,20 +148,19 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis const statusMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/status$/); if (request.method === "GET" && statusMatch) { const account = decodeURIComponent(statusMatch[1]).toLowerCase(); - const buyerQuery = url.searchParams.get("buyer"); - const buyerAddress = buyerQuery && addressParam.safeParse(buyerQuery).success - ? buyerQuery.toLowerCase() - : account; - const [profile, gdCredits, operator, withdrawable] = await Promise.all([ + const [profile, gdCredits] = await Promise.all([ store.getUser(account), store.getGdCredits(account), - antseedFundingVault.getBuyerOperatorStatus(account, buyerAddress), - antseedFundingVault.getWithdrawablePrincipal(buyerAddress) + ]); + const buyer = profile.buyer ?? account; + const [operator, withdrawable] = await Promise.all([ + antseedFundingVault.getBuyerOperatorStatus(account, buyer), + antseedFundingVault.getWithdrawablePrincipal(buyer), ]); const outstandingFundingCredits = gdCredits.filter((entry) => entry.fundingStatus === "failed" || entry.fundingStatus === "pending"); return json({ account, - buyerAddress, + buyer: profile.buyer ?? null, profile, operator, withdrawableMicroUsd: withdrawable.withdrawableMicroUsd, @@ -228,6 +227,8 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis try { const bridge = await antseedFundingVault.acceptBuyerOperator(buyerAddress, nonce, parsed.data.buyerSig); + const rootAccount = await fetchGoodIdRoot(account, cfg); + await store.setBuyer(account, buyerAddress, rootAccount ?? account); const operator = await antseedFundingVault.getBuyerOperatorStatus(account, buyerAddress); return json({ account, buyerAddress, operator, bridge }); } catch (error) { diff --git a/backend/test/kv-credit-store.test.ts b/backend/test/kv-credit-store.test.ts index 3e47660..101db80 100644 --- a/backend/test/kv-credit-store.test.ts +++ b/backend/test/kv-credit-store.test.ts @@ -251,6 +251,7 @@ test("getUser returns default profile for unknown account", async () => { assert.equal(user.totalBonusMicroUsd, "0"); assert.equal(user.totalOutstandingFundingMicroUsd, "0"); assert.equal(user.streamFlowRateWeiPerSecond, "0"); + assert.equal(user.buyer, undefined); }); test("markFundingResult updates lastStreamCreditAt for stream sources", async () => { @@ -302,3 +303,70 @@ test("listGdCredits paginates and filters by status", async () => { const secondPage = await store.listGdCredits("0xabc", { limit: 2, cursor: firstPage.nextCursor }); assert.equal(secondPage.transactions.length, 1); }); + +test("recordGdCredit does not set buyer on payer profile", async () => { + const store = new KVCreditStore(new MemoryKV() as never); + await store.recordGdCredit({ + id: "d1", + account: "0xPAYER", + source: "deposit", + gdAmountWei: 1_000_000_000_000_000_000n, + isVerified: true, + gdPrice: GD_PRICE, + maxBonusCapMicroUsd: 100_000_000n, + buyerAddress: "0xBuyer1", + }); + + const user = await store.getUser("0xPAYER"); + assert.equal(user.buyer, undefined); +}); + +test("recordGdCredit rejects credit when buyer differs from linked profile buyer", async () => { + const store = new KVCreditStore(new MemoryKV() as never); + await store.setBuyer("0xPAYER", "0xBuyer1"); + + await assert.rejects( + () => store.recordGdCredit({ + id: "d2", + account: "0xPAYER", + source: "deposit", + gdAmountWei: 1_000_000_000_000_000_000n, + isVerified: true, + gdPrice: GD_PRICE, + maxBonusCapMicroUsd: 100_000_000n, + buyerAddress: "0xBuyer2", + }), + /linked to buyer 0xbuyer1/ + ); +}); + +test("recordGdCredit accepts credit when buyer matches linked profile buyer", async () => { + const store = new KVCreditStore(new MemoryKV() as never); + await store.setBuyer("0xPAYER", "0xBuyer1"); + await store.recordGdCredit({ + id: "d1", + account: "0xPAYER", + source: "deposit", + gdAmountWei: 1_000_000_000_000_000_000n, + isVerified: true, + gdPrice: GD_PRICE, + maxBonusCapMicroUsd: 100_000_000n, + buyerAddress: "0xBuyer1", + }); + + const user = await store.getUser("0xPAYER"); + assert.equal(user.buyer, "0xbuyer1"); + assert.equal(user.totalOutstandingFundingMicroUsd, "1100000"); +}); + +test("setBuyer links buyer without recording credit", async () => { + const store = new KVCreditStore(new MemoryKV() as never); + await store.setBuyer("0xPAYER", "0xBuyerA"); + const user = await store.getUser("0xPAYER"); + assert.equal(user.buyer, "0xbuyera"); + + await assert.rejects( + () => store.setBuyer("0xPAYER", "0xBuyerB"), + /linked to buyer 0xbuyera/ + ); +}); diff --git a/backend/test/worker.test.ts b/backend/test/worker.test.ts index 8476522..9fbfaa6 100644 --- a/backend/test/worker.test.ts +++ b/backend/test/worker.test.ts @@ -312,11 +312,14 @@ test("GET /v1/accounts/:account/status returns profile and operator summary", as assert.equal(res.status, 200); const body = await res.json() as { account: string; - profile: { totalGdDepositedWei: string }; + buyer: string | null; + profile: { totalGdDepositedWei: string; buyer?: string }; operator: { operatorAccepted: boolean }; withdrawableMicroUsd: string; }; assert.equal(body.account, account); + assert.equal(body.buyer, null); + assert.equal(body.profile.buyer, undefined); assert.equal(body.profile.totalGdDepositedWei, "0"); assert.equal(body.operator.operatorAccepted, false); assert.equal(body.withdrawableMicroUsd, "0"); From abfa9d818984635c30dabfd3a918d04053f29304 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 25 Jun 2026 17:53:28 -0400 Subject: [PATCH 4/6] feat: add quote endpoints for G$ to AI credits and vice versa, update README and configuration files, and refactor reserve price fetching logic --- backend/.dev.vars.example | 1 + backend/README.md | 2 ++ backend/src/celo-events.ts | 13 ++++------ backend/src/credit-bonus.ts | 9 +++++-- backend/src/env.ts | 3 +++ backend/src/quote.ts | 41 ++++++++++++++++++++++++++++++++ backend/src/worker.ts | 20 ++++++++++++++++ backend/test/celo-events.test.ts | 12 ++++++---- backend/test/quote.test.ts | 36 ++++++++++++++++++++++++++++ backend/test/worker.test.ts | 26 ++++++++++++++++++++ backend/wrangler.toml | 1 + 11 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 backend/src/quote.ts create mode 100644 backend/test/quote.test.ts diff --git a/backend/.dev.vars.example b/backend/.dev.vars.example index 1750e79..07ee3e6 100644 --- a/backend/.dev.vars.example +++ b/backend/.dev.vars.example @@ -4,3 +4,4 @@ CELO_RPC_URL=https://forno.celo.org CELO_VAULT_ADDRESS=0x CELO_GOODID_ADDRESS=0xC361A6E67822a0EDc17D899227dd9FC50BD62F42 CELO_RESERVE_PRICE_ORACLE_ADDRESS=0x2fFBB49055d487DdBBb0C052Cd7c2a02A7971e41 +CELO_RESERVE_EXCHANGE_ID=0xba77f5c7bb3317643c6d81d1ef3f9913561741d92095f88efa402faf2cbe9124 diff --git a/backend/README.md b/backend/README.md index 8f1e6e7..bd83a16 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,6 +14,8 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base - `GET /health` - `GET /config/status` +- `GET /v1/quote/gd-to-credit` — G$ → AI credits via oracle (no bonus; bonus applied at record time) +- `GET /v1/quote/credit-to-gd` — AI credits → G$ via oracle (no bonus) - `GET /v1/accounts/:account/status` — dashboard: profile, linked `buyer`, operator consent, withdrawable, outstanding - `GET /v1/accounts/:account/credit` - `GET /v1/accounts/:account/outstanding` diff --git a/backend/src/celo-events.ts b/backend/src/celo-events.ts index b44218e..875ca85 100644 --- a/backend/src/celo-events.ts +++ b/backend/src/celo-events.ts @@ -12,12 +12,9 @@ const GOODID_ABI = new Interface([ ]); const RESERVE_ORACLE_ABI = new Interface([ - "function currentPriceDAI() view returns (uint256)" + "function currentPrice(bytes32 exchangeId) view returns (uint256)" ]); -const RESERVE_PRICE_DECIMALS = 1_000_000_000_000_000_000n; -const MICRO_USD_PER_USD = 1_000_000n; - export type ParsedCeloVaultEvent = | { kind: "deposit"; @@ -80,13 +77,13 @@ export async function fetchCeloVaultEventsForAccount( export async function fetchCurrentGdMicroUsdPerToken(cfg: RuntimeConfig): Promise { if (!cfg.CELO_RPC_URL || !cfg.CELO_RESERVE_PRICE_ORACLE_ADDRESS) return cfg.GD_MICRO_USD_PER_TOKEN; - const data = RESERVE_ORACLE_ABI.encodeFunctionData("currentPriceDAI", []); + const data = RESERVE_ORACLE_ABI.encodeFunctionData("currentPrice", [cfg.CELO_RESERVE_EXCHANGE_ID]); const result = await rpc(cfg.CELO_RPC_URL, "eth_call", [{ to: cfg.CELO_RESERVE_PRICE_ORACLE_ADDRESS, data }, "latest"]); if (!result || result === "0x") return cfg.GD_MICRO_USD_PER_TOKEN; - const [reservePriceDai] = RESERVE_ORACLE_ABI.decodeFunctionResult("currentPriceDAI", result); - const reservePrice = BigInt(reservePriceDai.toString()); + const [price] = RESERVE_ORACLE_ABI.decodeFunctionResult("currentPrice", result); + const reservePrice = BigInt(price.toString()); if (reservePrice > 0n) { - return (reservePrice * MICRO_USD_PER_USD) / RESERVE_PRICE_DECIMALS; + return reservePrice; } return cfg.GD_MICRO_USD_PER_TOKEN; diff --git a/backend/src/credit-bonus.ts b/backend/src/credit-bonus.ts index 5435097..59a7911 100644 --- a/backend/src/credit-bonus.ts +++ b/backend/src/credit-bonus.ts @@ -15,7 +15,7 @@ export type CreditBonusResult = { const REGULAR_BONUS_BPS = 1_000n; // +10% const STREAMING_BONUS_BPS = 2_000n; // +20% for streaming sources const BPS = 10_000n; - +const WEI_PER_GD = 1_000_000_000_000_000_000n; export function calculateCreditWithBonus(gdAmountWei: bigint, source: GdCreditEntry["source"], isVerified: boolean, gdPrice: bigint): CreditBonusResult { @@ -33,7 +33,12 @@ export function calculateCreditWithBonus(gdAmountWei: bigint, source: GdCreditEn } export function gdWeiToMicroUsd(gdAmountWei: bigint, gdMicroUsdPerToken: bigint): bigint { - return (gdAmountWei * gdMicroUsdPerToken) / 1_000_000_000_000_000_000n; + return (gdAmountWei * gdMicroUsdPerToken) / WEI_PER_GD; +} + +export function microUsdToGdWei(microUsd: bigint, gdMicroUsdPerToken: bigint): bigint { + if (gdMicroUsdPerToken <= 0n) return 0n; + return (microUsd * WEI_PER_GD) / gdMicroUsdPerToken; } export function monthlyStreamMicroUsd(flowRateWeiPerSecond: bigint, gdMicroUsdPerToken: bigint): bigint { diff --git a/backend/src/env.ts b/backend/src/env.ts index 0057a40..967b8e1 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -11,6 +11,7 @@ export interface Env { CELO_GD_SUPERTOKEN_ADDRESS?: string; CELO_GOODID_ADDRESS?: string; CELO_RESERVE_PRICE_ORACLE_ADDRESS?: string; + CELO_RESERVE_EXCHANGE_ID?: string; SUPERFLUID_SUBGRAPH_URL?: string; MAX_BONUS_CAP_MICRO_USD?: string; } @@ -25,6 +26,7 @@ export type RuntimeConfig = { CELO_GD_SUPERTOKEN_ADDRESS?: string; CELO_GOODID_ADDRESS?: string; CELO_RESERVE_PRICE_ORACLE_ADDRESS?: string; + CELO_RESERVE_EXCHANGE_ID?: string; SUPERFLUID_SUBGRAPH_URL?: string; MAX_BONUS_CAP_MICRO_USD: bigint; }; @@ -40,6 +42,7 @@ export function configFromEnv(env: Env): RuntimeConfig { CELO_GD_SUPERTOKEN_ADDRESS: env.CELO_GD_SUPERTOKEN_ADDRESS, CELO_GOODID_ADDRESS: env.CELO_GOODID_ADDRESS, CELO_RESERVE_PRICE_ORACLE_ADDRESS: env.CELO_RESERVE_PRICE_ORACLE_ADDRESS, + CELO_RESERVE_EXCHANGE_ID: env.CELO_RESERVE_EXCHANGE_ID ?? "0xba77f5c7bb3317643c6d81d1ef3f9913561741d92095f88efa402faf2cbe9124", SUPERFLUID_SUBGRAPH_URL: env.SUPERFLUID_SUBGRAPH_URL, MAX_BONUS_CAP_MICRO_USD: bigintEnv(env.MAX_BONUS_CAP_MICRO_USD, 100_000_000n) }; diff --git a/backend/src/quote.ts b/backend/src/quote.ts new file mode 100644 index 0000000..20808db --- /dev/null +++ b/backend/src/quote.ts @@ -0,0 +1,41 @@ +import { gdWeiToMicroUsd, microUsdToGdWei } from "./credit-bonus.js"; + +export type GdToCreditQuote = { + direction: "gd-to-credit"; + gdAmountWei: string; + gdMicroUsdPerToken: string; + creditMicroUsd: string; +}; + +export type CreditToGdQuote = { + direction: "credit-to-gd"; + creditMicroUsd: string; + gdMicroUsdPerToken: string; + gdAmountWei: string; +}; + +export function quoteGdToCredit(input: { + gdAmountWei: bigint; + gdMicroUsdPerToken: bigint; +}): GdToCreditQuote { + const creditMicroUsd = gdWeiToMicroUsd(input.gdAmountWei, input.gdMicroUsdPerToken); + return { + direction: "gd-to-credit", + gdAmountWei: input.gdAmountWei.toString(), + gdMicroUsdPerToken: input.gdMicroUsdPerToken.toString(), + creditMicroUsd: creditMicroUsd.toString() + }; +} + +export function quoteCreditToGd(input: { + creditMicroUsd: bigint; + gdMicroUsdPerToken: bigint; +}): CreditToGdQuote { + const gdAmountWei = microUsdToGdWei(input.creditMicroUsd, input.gdMicroUsdPerToken); + return { + direction: "credit-to-gd", + creditMicroUsd: input.creditMicroUsd.toString(), + gdMicroUsdPerToken: input.gdMicroUsdPerToken.toString(), + gdAmountWei: gdAmountWei.toString() + }; +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts index ca90600..cae6614 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -12,6 +12,7 @@ import { KVCreditStore } from "./kv-credit-store.js"; import { GdCreditEntry } from "./types.js"; import { assertWithdrawTimestampFresh, buildWithdrawPrincipalPayload, recoverWithdrawPrincipalSigner } from "./withdraw-auth.js"; import { recoverSetOperatorSigner } from "./operator-auth.js"; +import { quoteCreditToGd, quoteGdToCredit } from "./quote.js"; const CeloEventsRecordSchema = z.object({ txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(), @@ -135,6 +136,20 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis }); } + if (request.method === "GET" && url.pathname === "/v1/quote/gd-to-credit") { + const gdAmountWei = parseQuoteAmount(url.searchParams.get("gdAmountWei"), "gdAmountWei"); + if (typeof gdAmountWei === "string") return json({ error: gdAmountWei }, 400); + const gdMicroUsdPerToken = await fetchCurrentGdMicroUsdPerToken(cfg); + return json(quoteGdToCredit({ gdAmountWei, gdMicroUsdPerToken })); + } + + if (request.method === "GET" && url.pathname === "/v1/quote/credit-to-gd") { + const creditMicroUsd = parseQuoteAmount(url.searchParams.get("creditMicroUsd"), "creditMicroUsd"); + if (typeof creditMicroUsd === "string") return json({ error: creditMicroUsd }, 400); + const gdMicroUsdPerToken = await fetchCurrentGdMicroUsdPerToken(cfg); + return json(quoteCreditToGd({ creditMicroUsd, gdMicroUsdPerToken })); + } + const accountMatch = url.pathname.match(/^\/v1\/accounts\/([^/]+)\/credit$/); if (request.method === "GET" && accountMatch) { const account = decodeURIComponent(accountMatch[1]); @@ -569,6 +584,11 @@ function createStreamFundingId(account: string, date: Date): string { return `stream:${day}:${account.toLowerCase()}`; } +function parseQuoteAmount(value: string | null, field: string): bigint | string { + if (!value || !/^\d+$/.test(value)) return `${field} must be a non-negative integer string`; + return BigInt(value); +} + async function fundCredit( entry: GdCreditEntry, store: KVCreditStore, diff --git a/backend/test/celo-events.test.ts b/backend/test/celo-events.test.ts index 44eee04..1500c47 100644 --- a/backend/test/celo-events.test.ts +++ b/backend/test/celo-events.test.ts @@ -79,19 +79,20 @@ test("decodeBuyerFromUserData decodes abi-encoded address from Superfluid userDa assert.equal(decodeBuyerFromUserData("0x" + "00".repeat(32)), undefined); // zero address }); -test("fetches GD price from reserve currentPriceDAI", async () => { - const reserveAbi = new Interface(["function currentPriceDAI() view returns (uint256)"]); +test("fetches GD price from reserve currentPrice", async () => { + const exchangeId = "0xba77f5c7bb3317643c6d81d1ef3f9913561741d92095f88efa402faf2cbe9124"; + const reserveAbi = new Interface(["function currentPrice(bytes32 exchangeId) view returns (uint256)"]); const previousFetch = globalThis.fetch; globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { const body = JSON.parse(String(init?.body)); assert.equal(body.method, "eth_call"); const callData = String(body.params[0].data); - const expectedSelector = reserveAbi.encodeFunctionData("currentPriceDAI", []).slice(0, 10); + const expectedSelector = reserveAbi.encodeFunctionData("currentPrice", [exchangeId]).slice(0, 10); assert.equal(callData.slice(0, 10), expectedSelector); return Response.json({ jsonrpc: "2.0", id: body.id, - result: reserveAbi.encodeFunctionResult("currentPriceDAI", [500000000000000000n]) + result: reserveAbi.encodeFunctionResult("currentPrice", [500_000n]) }); }) as typeof fetch; @@ -100,9 +101,10 @@ test("fetches GD price from reserve currentPriceDAI", async () => { GD_MICRO_USD_PER_TOKEN: 1_000_000n, CELO_RPC_URL: "https://celo.example", CELO_RESERVE_PRICE_ORACLE_ADDRESS: "0x0000000000000000000000000000000000000abc", + CELO_RESERVE_EXCHANGE_ID: exchangeId, MAX_BONUS_CAP_MICRO_USD: 100_000_000n }); - assert.equal(price, 500000n); + assert.equal(price, 500_000n); } finally { globalThis.fetch = previousFetch; } diff --git a/backend/test/quote.test.ts b/backend/test/quote.test.ts new file mode 100644 index 0000000..64c24ad --- /dev/null +++ b/backend/test/quote.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { quoteCreditToGd, quoteGdToCredit } from "../src/quote.js"; + +const GD_PRICE = 1_000_000n; +const ONE_GD = 1_000_000_000_000_000_000n; + +test("quoteGdToCredit converts G$ wei to credit via oracle price only", () => { + const quote = quoteGdToCredit({ + gdAmountWei: 10n * ONE_GD, + gdMicroUsdPerToken: GD_PRICE + }); + + assert.equal(quote.creditMicroUsd, "10000000"); +}); + +test("quoteCreditToGd converts credit micro-USD to G$ wei", () => { + const quote = quoteCreditToGd({ + creditMicroUsd: 1_000_000n, + gdMicroUsdPerToken: GD_PRICE + }); + + assert.equal(quote.gdAmountWei, ONE_GD.toString()); +}); + +test("quoteGdToCredit and quoteCreditToGd round-trip", () => { + const gdToCredit = quoteGdToCredit({ + gdAmountWei: ONE_GD, + gdMicroUsdPerToken: GD_PRICE + }); + const creditToGd = quoteCreditToGd({ + creditMicroUsd: BigInt(gdToCredit.creditMicroUsd), + gdMicroUsdPerToken: GD_PRICE + }); + assert.equal(creditToGd.gdAmountWei, ONE_GD.toString()); +}); diff --git a/backend/test/worker.test.ts b/backend/test/worker.test.ts index 9fbfaa6..95dea55 100644 --- a/backend/test/worker.test.ts +++ b/backend/test/worker.test.ts @@ -363,3 +363,29 @@ test("POST /v1/channels/withdraw returns bridge disabled when not configured", a assert.equal(body.channelId, channelId); assert.equal(body.bridge.enabled, false); }); + +test("GET /v1/quote/gd-to-credit returns oracle conversion without bonus", async () => { + const res = await worker.fetch( + new Request("https://worker.test/v1/quote/gd-to-credit?gdAmountWei=1000000000000000000"), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 200); + const body = await res.json() as { + direction: string; + creditMicroUsd: string; + gdAmountWei: string; + }; + assert.equal(body.direction, "gd-to-credit"); + assert.equal(body.creditMicroUsd, "1000000"); + assert.equal(body.gdAmountWei, "1000000000000000000"); +}); + +test("GET /v1/quote/credit-to-gd validates creditMicroUsd", async () => { + const res = await worker.fetch( + new Request("https://worker.test/v1/quote/credit-to-gd"), + env(), + {} as ExecutionContext + ); + assert.equal(res.status, 400); +}); diff --git a/backend/wrangler.toml b/backend/wrangler.toml index 442e645..04ce8e5 100644 --- a/backend/wrangler.toml +++ b/backend/wrangler.toml @@ -13,6 +13,7 @@ CELO_RPC_URL = "https://forno.celo.org" CELO_VAULT_ADDRESS = "0x" CELO_GOODID_ADDRESS = "0xC361A6E67822a0EDc17D899227dd9FC50BD62F42" CELO_RESERVE_PRICE_ORACLE_ADDRESS = "0x2fFBB49055d487DdBBb0C052Cd7c2a02A7971e41" +CELO_RESERVE_EXCHANGE_ID = "0xba77f5c7bb3317643c6d81d1ef3f9913561741d92095f88efa402faf2cbe9124" [[kv_namespaces]] binding = "ANTSEED_KV" From d8ee9c2f002d37a143bb53908bb91c7683ae0590 Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 26 Jun 2026 10:07:16 -0400 Subject: [PATCH 5/6] chore: update cron trigger in README and configuration to daily for Superfluid stream settlement checks --- backend/README.md | 2 +- backend/wrangler.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index bd83a16..aaa745d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -8,7 +8,7 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base - KV namespace binding: `ANTSEED_KV` - Celo `CeloGdAntSeedVault` tx-log ingestion for G$ deposits and Superfluid stream updates - Optional Base `AntseedBuyerOperator` bridge client that calls `depositForWithId(buyer, amount, id)` -- Cron trigger every minute for stream bonus settlement checks +- Cron trigger daily (UTC midnight) for Superfluid stream settlement checks ## Endpoints diff --git a/backend/wrangler.toml b/backend/wrangler.toml index 04ce8e5..545f74a 100644 --- a/backend/wrangler.toml +++ b/backend/wrangler.toml @@ -21,4 +21,4 @@ id = "0831898ded5048049353498b8fc962f0" preview_id = "11111111111111111111111111111111" [triggers] -crons = ["* * * * *"] +crons = ["0 0 * * *"] From 4cb05a1f5c5a6270841c04bf3cd8cfe0cc50c5bc Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 26 Jun 2026 10:07:31 -0400 Subject: [PATCH 6/6] feat: implement stream accrual logic with utility functions and tests for calculating elapsed time and accrued amounts --- backend/src/stream-accrual.ts | 23 +++++++++++++++++++++ backend/src/worker.ts | 32 +++++++++++++++++++++-------- backend/test/stream-accrual.test.ts | 27 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 backend/src/stream-accrual.ts create mode 100644 backend/test/stream-accrual.test.ts diff --git a/backend/src/stream-accrual.ts b/backend/src/stream-accrual.ts new file mode 100644 index 0000000..a07395d --- /dev/null +++ b/backend/src/stream-accrual.ts @@ -0,0 +1,23 @@ +export const STREAM_ACCRUAL_MAX_SECONDS = 86_400; + +export function streamAccrualElapsedSeconds( + updatedAtTimestamp: bigint | string, + maxElapsedSeconds = STREAM_ACCRUAL_MAX_SECONDS, + nowSeconds = BigInt(Math.floor(Date.now() / 1000)) +): bigint { + const updatedAt = BigInt(updatedAtTimestamp); + const sinceUpdate = nowSeconds > updatedAt ? nowSeconds - updatedAt : 0n; + const maxElapsed = BigInt(maxElapsedSeconds); + return sinceUpdate < maxElapsed ? sinceUpdate : maxElapsed; +} + +export function streamGdAmountWei( + flowRateWeiPerSecond: bigint, + updatedAtTimestamp: bigint | string, + maxElapsedSeconds = STREAM_ACCRUAL_MAX_SECONDS, + nowSeconds = BigInt(Math.floor(Date.now() / 1000)) +): bigint { + const elapsed = streamAccrualElapsedSeconds(updatedAtTimestamp, maxElapsedSeconds, nowSeconds); + if (elapsed <= 0n || flowRateWeiPerSecond <= 0n) return 0n; + return flowRateWeiPerSecond * elapsed; +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts index cae6614..5664044 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -13,6 +13,7 @@ import { GdCreditEntry } from "./types.js"; import { assertWithdrawTimestampFresh, buildWithdrawPrincipalPayload, recoverWithdrawPrincipalSigner } from "./withdraw-auth.js"; import { recoverSetOperatorSigner } from "./operator-auth.js"; import { quoteCreditToGd, quoteGdToCredit } from "./quote.js"; +import { STREAM_ACCRUAL_MAX_SECONDS, streamGdAmountWei } from "./stream-accrual.js"; const CeloEventsRecordSchema = z.object({ txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(), @@ -58,10 +59,9 @@ const SUPERFLUID_CELO_SUBGRAPH_URL = "https://subgraph-endpoints.superfluid.dev/ type SuperfluidIncomingStream = { account: string; - gdAmountWei: string; flowRateWeiPerSecond: string; + updatedAtTimestamp: string; lastUpdateAt: string; - /** AntSeed buyer decoded from the most recent FlowUpdatedEvent userdata. */ buyerAddress?: string; }; @@ -85,6 +85,12 @@ export default { const streams = await fetchSuperfluidIncomingStreams(cfg); const createdAt = new Date().toISOString(); for (const stream of streams) { + const gdAmountWei = streamGdAmountWei( + BigInt(stream.flowRateWeiPerSecond), + stream.updatedAtTimestamp + ); + if (gdAmountWei <= 0n) continue; + const rootAccount = await fetchGoodIdRoot(stream.account, cfg); const depositId = createStreamFundingId(stream.account, new Date(createdAt)); const entry = await store.recordGdCredit({ @@ -92,7 +98,7 @@ export default { account: stream.account, rootAccount, source: "streamCron", - gdAmountWei: BigInt(stream.gdAmountWei), + gdAmountWei, flowRate: BigInt(stream.flowRateWeiPerSecond), isVerified: !!rootAccount, // if root acccount was found it is whitelisted gdPrice, @@ -511,13 +517,24 @@ async function route(request: Request, env: Env, _ctx: ExecutionContext): Promis const lastCreditMs = Date.parse(profile.lastStreamCreditAt); const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - lastCreditMs) / 1000)); - if (elapsedSeconds < 60 * 60 * 24) { // if last credit was less than 24h ago, don't issue new credits to prevent abuse and return how many seconds are left until next credit can be issued - return json({ account, streams: [], message: "stream credits were issued less than 60 seconds ago" }); + if (elapsedSeconds < STREAM_ACCRUAL_MAX_SECONDS) { + const retryAfterSeconds = STREAM_ACCRUAL_MAX_SECONDS - elapsedSeconds; + return json({ + account, + streams: [], + message: "stream credits were issued less than 24 hours ago", + retryAfterSeconds + }); } const recorded = []; for (const stream of streams) { - const gdAmountWei = BigInt(stream.flowRateWeiPerSecond) * BigInt(elapsedSeconds); + const sinceLastCredit = Math.min(elapsedSeconds, STREAM_ACCRUAL_MAX_SECONDS); + const gdAmountWei = streamGdAmountWei( + BigInt(stream.flowRateWeiPerSecond), + stream.updatedAtTimestamp, + sinceLastCredit + ); if (gdAmountWei <= 0n) continue; const depositId = createStreamFundingId(account, now); @@ -708,7 +725,6 @@ async function fetchSuperfluidStreams(cfg: ReturnType, sen const batch = parsed.data.data.streams.map((stream) => { const flowRateWeiPerSecond = stream.currentFlowRate; - const gdAmountWei = (BigInt(flowRateWeiPerSecond) * 60n).toString(); const updatedAtSeconds = Number(stream.updatedAtTimestamp); const lastUpdateAt = Number.isFinite(updatedAtSeconds) ? new Date(updatedAtSeconds * 1000).toISOString() @@ -717,8 +733,8 @@ async function fetchSuperfluidStreams(cfg: ReturnType, sen const buyerAddress = decodeBuyerFromUserData(rawUserData); return { account: stream.sender.id.toLowerCase(), - gdAmountWei, flowRateWeiPerSecond, + updatedAtTimestamp: stream.updatedAtTimestamp, lastUpdateAt, buyerAddress }; diff --git a/backend/test/stream-accrual.test.ts b/backend/test/stream-accrual.test.ts new file mode 100644 index 0000000..4c65798 --- /dev/null +++ b/backend/test/stream-accrual.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + STREAM_ACCRUAL_MAX_SECONDS, + streamAccrualElapsedSeconds, + streamGdAmountWei +} from "../src/stream-accrual.js"; + +test("streamAccrualElapsedSeconds caps at one day", () => { + const now = 1_000_000n; + const updatedTwoDaysAgo = now - BigInt(STREAM_ACCRUAL_MAX_SECONDS * 2); + assert.equal( + streamAccrualElapsedSeconds(updatedTwoDaysAgo, STREAM_ACCRUAL_MAX_SECONDS, now), + BigInt(STREAM_ACCRUAL_MAX_SECONDS) + ); +}); + +test("streamAccrualElapsedSeconds uses time since updatedAt when under one day", () => { + const now = 10_000n; + const updatedAt = 7_000n; + assert.equal(streamAccrualElapsedSeconds(updatedAt, STREAM_ACCRUAL_MAX_SECONDS, now), 3_000n); +}); + +test("streamGdAmountWei multiplies flow rate by elapsed seconds", () => { + const amount = streamGdAmountWei(100n, 0n, STREAM_ACCRUAL_MAX_SECONDS, 3_600n); + assert.equal(amount, 100n * 3_600n); +});