From e10d5f6af77749b1f37d91a44eae4af2d3666f2a Mon Sep 17 00:00:00 2001 From: jaswinder6991 Date: Tue, 28 Jul 2026 17:31:50 +0200 Subject: [PATCH 1/2] feat: credit/entitlement ledger + Stake2Pay demo page Adds a canonical credit layer (entitlements + entitlement_transactions, NUMERIC balances, NULLS NOT DISTINCT dedup on personal credits, unique source_ref idempotency) and a dedicated /subscriptions/stake2pay page that turns a confirmed NEAR stake into visible inference credits. - api: EntitlementService with atomic grant/balance updates; creditList and subscriptionCreditSync routes. The sync route re-reads the subscription from the provider plugin itself rather than trusting a client-supplied amount, grants keyed on the on-chain lock id, and retries briefly when the chain shows a subscription but the lock hasn't propagated yet. - ui: new stake2pay page with wallet connect, stake flow, live credit counter, and a guard that switches to sync-only (no re-staking) when the wallet already has an active subscription. Extracted signWalletIntent into lib/wallet-intent.ts so /subscriptions and the new page share identical signing logic. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CthJ9b2Zdz9P7er2sebiPe --- .changeset/stake2pay-credit-sync.md | 9 + api/src/contract.ts | 24 ++ .../db/migrations/0001_flowery_sleeper.sql | 25 ++ api/src/db/migrations/meta/0001_snapshot.json | 237 +++++++++++++ api/src/db/migrations/meta/_journal.json | 7 + api/src/db/schema.ts | 50 ++- api/src/index.ts | 78 ++++- api/src/lib/credits.ts | 129 +++++++ ui/src/lib/wallet-intent.ts | 63 ++++ ui/src/routeTree.gen.ts | 21 ++ ui/src/routes/subscriptions.tsx | 65 +--- ui/src/routes/subscriptions_.stake2pay.tsx | 324 ++++++++++++++++++ 12 files changed, 966 insertions(+), 66 deletions(-) create mode 100644 .changeset/stake2pay-credit-sync.md create mode 100644 api/src/db/migrations/0001_flowery_sleeper.sql create mode 100644 api/src/db/migrations/meta/0001_snapshot.json create mode 100644 api/src/lib/credits.ts create mode 100644 ui/src/lib/wallet-intent.ts create mode 100644 ui/src/routes/subscriptions_.stake2pay.tsx diff --git a/.changeset/stake2pay-credit-sync.md b/.changeset/stake2pay-credit-sync.md new file mode 100644 index 0000000..d7f0918 --- /dev/null +++ b/.changeset/stake2pay-credit-sync.md @@ -0,0 +1,9 @@ +--- +"api": minor +"ui": minor +--- + +Credit/entitlement ledger and a dedicated Stake2Pay demo page that turns a confirmed NEAR stake into visible inference credits. + +- api: `entitlements` / `entitlement_transactions` tables (NUMERIC balances, `NULLS NOT DISTINCT` personal-credit dedup, unique `source_ref` idempotency key) and an `EntitlementService` for atomic grant/balance tracking. New `creditList` (`GET /credits`) and `subscriptionCreditSync` (`POST /subscriptions/{provider}/credits/sync`) routes — the sync route re-reads the subscription from the provider plugin itself (never trusts a client-supplied amount), grants credits keyed on the on-chain lock id, and retries briefly when the chain shows a subscription but the lock hasn't propagated yet. +- ui: new `/subscriptions/stake2pay` page — NEAR wallet connect, single-plan stake flow, live credit counter with optimistic update reconciled against the server, and a guard that switches to a "sync only" action (no re-staking) when the wallet already has an active subscription. Shared `signWalletIntent` logic extracted out of `/subscriptions` into `lib/wallet-intent.ts` so both pages sign transactions identically. diff --git a/api/src/contract.ts b/api/src/contract.ts index 0187083..26d20f9 100644 --- a/api/src/contract.ts +++ b/api/src/contract.ts @@ -137,6 +137,11 @@ const CreateSubscriptionInputSchema = z.object({ metadata: z.record(z.string(), z.string()).optional(), }); +const CreditBalanceSchema = z.object({ + creditType: z.string(), + balance: z.string(), +}); + export const contract = oc.router({ ping: oc.route({ method: "GET", path: "/ping" }).output( z.object({ @@ -237,6 +242,25 @@ export const contract = oc.router({ }), ) .output(SubscriptionActionSchema), + + creditList: oc.route({ method: "GET", path: "/credits" }).output(z.array(CreditBalanceSchema)), + + subscriptionCreditSync: oc + .route({ method: "POST", path: "/subscriptions/{provider}/credits/sync" }) + .input( + z.object({ + provider: z.string(), + planId: z.string(), + payerRef: z.string().optional(), + }), + ) + .output( + z.object({ + granted: z.boolean(), + reason: z.enum(["granted", "already_synced", "not_ready", "not_staked"]), + balances: z.array(CreditBalanceSchema), + }), + ), }); export type ContractType = typeof contract; diff --git a/api/src/db/migrations/0001_flowery_sleeper.sql b/api/src/db/migrations/0001_flowery_sleeper.sql new file mode 100644 index 0000000..60970a9 --- /dev/null +++ b/api/src/db/migrations/0001_flowery_sleeper.sql @@ -0,0 +1,25 @@ +CREATE TABLE "entitlement_transactions" ( + "id" text PRIMARY KEY NOT NULL, + "entitlement_id" text NOT NULL, + "type" text NOT NULL, + "amount" numeric NOT NULL, + "source" text NOT NULL, + "source_ref" text, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "entitlement_transactions_type_check" CHECK ("entitlement_transactions"."type" IN ('grant', 'consume')) +); +--> statement-breakpoint +CREATE TABLE "entitlements" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "organization_id" text, + "credit_type" text DEFAULT 'default' NOT NULL, + "balance" numeric DEFAULT '0' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "entitlements_user_org_type_unique" UNIQUE NULLS NOT DISTINCT("user_id","organization_id","credit_type") +); +--> statement-breakpoint +ALTER TABLE "entitlement_transactions" ADD CONSTRAINT "entitlement_transactions_entitlement_id_entitlements_id_fk" FOREIGN KEY ("entitlement_id") REFERENCES "public"."entitlements"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "entitlement_transactions_source_ref_unique" ON "entitlement_transactions" USING btree ("source_ref"); \ No newline at end of file diff --git a/api/src/db/migrations/meta/0001_snapshot.json b/api/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..2f7d3c3 --- /dev/null +++ b/api/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,237 @@ +{ + "id": "c0049ed7-c975-4441-9d8e-1414ecccb21d", + "prevId": "8436f065-0140-4361-a897-404e33506d1e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.entitlement_transactions": { + "name": "entitlement_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entitlement_transactions_source_ref_unique": { + "name": "entitlement_transactions_source_ref_unique", + "columns": [ + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlement_transactions_entitlement_id_entitlements_id_fk": { + "name": "entitlement_transactions_entitlement_id_entitlements_id_fk", + "tableFrom": "entitlement_transactions", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "entitlement_transactions_type_check": { + "name": "entitlement_transactions_type_check", + "value": "\"entitlement_transactions\".\"type\" IN ('grant', 'consume')" + } + }, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credit_type": { + "name": "credit_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_user_org_type_unique": { + "name": "entitlements_user_org_type_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "organization_id", "credit_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upvotes": { + "name": "upvotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thing_id": { + "name": "thing_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upvotes_thing_user_unique": { + "name": "upvotes_thing_user_unique", + "columns": [ + { + "expression": "thing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/src/db/migrations/meta/_journal.json b/api/src/db/migrations/meta/_journal.json index d55c055..cdb7e27 100644 --- a/api/src/db/migrations/meta/_journal.json +++ b/api/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1778470014668, "tag": "0000_famous_fabian_cortez", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785248906089, + "tag": "0001_flowery_sleeper", + "breakpoints": true } ] } diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index cf4a620..1e042f4 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,4 +1,14 @@ -import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { + check, + jsonb, + numeric, + pgTable, + text, + timestamp, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; export const upvotes = pgTable( "upvotes", @@ -10,3 +20,41 @@ export const upvotes = pgTable( }, (table) => [uniqueIndex("upvotes_thing_user_unique").on(table.thingId, table.userId)], ); + +export const entitlements = pgTable( + "entitlements", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + organizationId: text("organization_id"), + creditType: text("credit_type").notNull().default("default"), + balance: numeric("balance").notNull().default("0"), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + unique("entitlements_user_org_type_unique") + .on(table.userId, table.organizationId, table.creditType) + .nullsNotDistinct(), + ], +); + +export const entitlementTransactions = pgTable( + "entitlement_transactions", + { + id: text("id").primaryKey(), + entitlementId: text("entitlement_id") + .notNull() + .references(() => entitlements.id), + type: text("type").notNull(), + amount: numeric("amount").notNull(), + source: text("source").notNull(), + sourceRef: text("source_ref"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("entitlement_transactions_source_ref_unique").on(table.sourceRef), + check("entitlement_transactions_type_check", sql`${table.type} IN ('grant', 'consume')`), + ], +); diff --git a/api/src/index.ts b/api/src/index.ts index 68df22f..84423e5 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -4,7 +4,8 @@ import { ORPCError } from "every-plugin/orpc"; import { z } from "every-plugin/zod"; import { contract } from "./contract"; import { DatabaseLive, DatabaseTag } from "./db/layer"; -import { ContextSchema } from "./lib/context"; +import { ContextSchema, runEffect } from "./lib/context"; +import { EntitlementService, EntitlementServiceLive, yoctoToNear } from "./lib/credits"; import type { PluginsClient } from "./lib/plugins-types.gen"; export default createPlugin.withPlugins()({ @@ -24,8 +25,9 @@ export default createPlugin.withPlugins()({ DatabaseTag, DatabaseLive(config.secrets.API_DATABASE_URL), ); + const credits = yield* tools.buildService(EntitlementService, EntitlementServiceLive(db)); const { auth, ...restPlugins } = plugins; - return { auth, plugins: restPlugins, db }; + return { auth, plugins: restPlugins, db, credits }; }), createRouter: (services, builder) => { @@ -67,6 +69,35 @@ export default createPlugin.withPlugins()({ return resolved; }; + const requireUserId = (context: { userId?: string | null }) => { + if (!context.userId) { + throw new ORPCError("UNAUTHORIZED", { message: "Sign in to view credits" }); + } + return context.userId; + }; + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // Public NEAR RPC endpoints load-balance across nodes with no read-after-write + // guarantee, so a subscription's status can flip to "active" on one node while + // its lock object is still unindexed on another. Retry a few times before + // treating "status active but no lock data yet" as "nothing to grant". + const getSubscriptionWithRetry = async ( + client: { getSubscription: (input: { planId: string; payerRef: string }) => Promise }, + planId: string, + payerRef: string, + ): Promise<{ status: string; amount?: string; metadata?: Record }> => { + const retryDelaysMs = [300, 600, 1200]; + let subscription = await client.getSubscription({ planId, payerRef }); + for (const delay of retryDelaysMs) { + const hasLockData = !!subscription.metadata?.lastLockId && !!subscription.amount; + if (subscription.status === "none" || hasLockData) break; + await sleep(delay); + subscription = await client.getSubscription({ planId, payerRef }); + } + return subscription; + }; + return { ping: builder.ping.handler(async () => ({ status: "ok", @@ -185,6 +216,49 @@ export default createPlugin.withPlugins()({ payerRef, })) as any; }), + + creditList: builder.creditList.handler(async ({ context }) => { + const userId = requireUserId(context); + const organizationId = context.organization?.activeOrganizationId ?? null; + return await runEffect(services.credits.getBalances({ userId, organizationId })); + }), + + subscriptionCreditSync: builder.subscriptionCreditSync.handler(async ({ input, context }) => { + const userId = requireUserId(context); + const organizationId = context.organization?.activeOrganizationId ?? null; + const { provider, planId } = input; + const factory = getSubscriptionPlugin(provider); + const client = (factory as (opts?: unknown) => any)(); + const payerRef = requirePayerRef(input.payerRef, context); + + const subscription = await getSubscriptionWithRetry(client, planId, payerRef); + + const lockId = subscription.metadata?.lastLockId; + const amount = subscription.amount; + + let granted = false; + let reason: "granted" | "already_synced" | "not_ready" | "not_staked" = "not_staked"; + + if (subscription.status !== "none" && lockId && amount) { + const result = await runEffect( + services.credits.grantCredits({ + userId, + organizationId, + amount: yoctoToNear(amount), + source: `${provider}:lock`, + sourceRef: lockId, + metadata: { provider, planId, payerRef }, + }), + ); + granted = result.granted; + reason = result.granted ? "granted" : "already_synced"; + } else if (subscription.status !== "none") { + reason = "not_ready"; + } + + const balances = await runEffect(services.credits.getBalances({ userId, organizationId })); + return { granted, reason, balances }; + }), }; }, }); diff --git a/api/src/lib/credits.ts b/api/src/lib/credits.ts new file mode 100644 index 0000000..ffda2bd --- /dev/null +++ b/api/src/lib/credits.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { Context, Effect, Layer } from "every-plugin/effect"; +import type { Database } from "../db"; +import { DatabaseError } from "../db"; +import { entitlements, entitlementTransactions } from "../db/schema"; + +const YOCTO_PER_NEAR = 10n ** 24n; + +export function yoctoToNear(yocto: string): string { + const value = BigInt(yocto); + const whole = value / YOCTO_PER_NEAR; + const fraction = (value % YOCTO_PER_NEAR).toString().padStart(24, "0").replace(/0+$/, ""); + return fraction ? `${whole}.${fraction}` : whole.toString(); +} + +export interface GrantCreditsInput { + userId: string; + organizationId: string | null; + creditType?: string; + amount: string; + source: string; + sourceRef: string | null; + metadata?: Record; +} + +export interface GrantCreditsResult { + granted: boolean; + balance: string; +} + +export interface CreditBalance { + creditType: string; + balance: string; +} + +export class EntitlementService extends Context.Tag("EntitlementService")< + EntitlementService, + { + readonly grantCredits: ( + input: GrantCreditsInput, + ) => Effect.Effect; + readonly getBalances: (params: { + userId: string; + organizationId: string | null; + }) => Effect.Effect; + } +>() {} + +export const EntitlementServiceLive = (db: Database) => + Layer.succeed(EntitlementService, { + grantCredits: (input) => + Effect.tryPromise({ + try: () => + db.transaction(async (tx) => { + const creditType = input.creditType ?? "default"; + + const [entitlement] = await tx + .insert(entitlements) + .values({ + id: randomUUID(), + userId: input.userId, + organizationId: input.organizationId, + creditType, + balance: "0", + }) + .onConflictDoUpdate({ + target: [entitlements.userId, entitlements.organizationId, entitlements.creditType], + set: { updatedAt: new Date() }, + }) + .returning(); + + if (!entitlement) { + throw new Error("Entitlement upsert returned no row"); + } + + const [inserted] = await tx + .insert(entitlementTransactions) + .values({ + id: randomUUID(), + entitlementId: entitlement.id, + type: "grant", + amount: input.amount, + source: input.source, + sourceRef: input.sourceRef, + metadata: input.metadata ?? null, + }) + .onConflictDoNothing({ target: entitlementTransactions.sourceRef }) + .returning(); + + if (!inserted) { + return { granted: false, balance: entitlement.balance }; + } + + const [updated] = await tx + .update(entitlements) + .set({ + balance: sql`${entitlements.balance} + ${input.amount}::numeric`, + updatedAt: new Date(), + }) + .where(eq(entitlements.id, entitlement.id)) + .returning(); + + if (!updated) { + throw new Error("Entitlement balance update returned no row"); + } + + return { granted: true, balance: updated.balance }; + }), + catch: (cause) => new DatabaseError({ stage: "load", cause }), + }), + + getBalances: (params) => + Effect.tryPromise({ + try: () => + db + .select({ creditType: entitlements.creditType, balance: entitlements.balance }) + .from(entitlements) + .where( + and( + eq(entitlements.userId, params.userId), + params.organizationId === null + ? isNull(entitlements.organizationId) + : eq(entitlements.organizationId, params.organizationId), + ), + ), + catch: (cause) => new DatabaseError({ stage: "load", cause }), + }), + }); diff --git a/ui/src/lib/wallet-intent.ts b/ui/src/lib/wallet-intent.ts new file mode 100644 index 0000000..ca83aa5 --- /dev/null +++ b/ui/src/lib/wallet-intent.ts @@ -0,0 +1,63 @@ +import type { AuthClient } from "@/app"; + +export type WalletIntent = { + kind: "wallet_intent"; + networkId: string; + contractId: string; + actions: Array<{ + methodName: string; + args: Record; + deposit: string; + gas: string; + }>; +}; + +export type SubscriptionInfo = { + id?: string; + planId: string; + status: "active" | "cancel_at_period_end" | "pending_unstake" | "ended" | "none"; + amount?: string; + currency?: string; + currentPeriodEnd?: string; + payerRef: string; + metadata?: Record; +}; + +export type SubscriptionAction = + | WalletIntent + | { kind: "redirect"; url: string } + | { kind: "executed"; subscription: SubscriptionInfo }; + +export async function signWalletIntent(authClient: AuthClient, intent: WalletIntent) { + const connected = await authClient.near.ensureConnected(); + if (!connected) { + throw new Error("Connect a NEAR wallet to sign this transaction"); + } + const accountId = authClient.near.getAccountId(); + if (!accountId) { + throw new Error("No NEAR account linked to this session"); + } + const network = authClient.near.getNetwork(); + if (network !== intent.networkId) { + throw new Error(`Wallet is on ${network}, but this subscription needs ${intent.networkId}`); + } + + // max_total_prepaid_gas is 1000 Tgas as of protocol v84/v85 (verified via + // EXPERIMENTAL_protocol_config); intents exceeding it sign as sequential txs. + const totalGas = intent.actions.reduce((sum, action) => sum + BigInt(action.gas), 0n); + const batches = + totalGas <= 1_000_000_000_000_000n + ? [intent.actions] + : intent.actions.map((action) => [action]); + + for (const batch of batches) { + let tx = authClient.near.client.transaction(accountId); + for (const action of batch) { + tx = tx.functionCall(intent.contractId, action.methodName, action.args, { + gas: action.gas as `${number}`, + attachedDeposit: BigInt(action.deposit), + }); + } + await tx.send(); + } +} diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 866c1b4..29a631a 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as SubscriptionsRouteImport } from './routes/subscriptions' import { Route as PaymentsRouteImport } from './routes/payments' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' +import { Route as SubscriptionsStake2payRouteImport } from './routes/subscriptions_.stake2pay' import { Route as LayoutSkillRouteImport } from './routes/_layout/skill' import { Route as LayoutLoginRouteImport } from './routes/_layout/login' import { Route as LayoutAuthenticatedRouteImport } from './routes/_layout/_authenticated' @@ -38,6 +39,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) +const SubscriptionsStake2payRoute = SubscriptionsStake2payRouteImport.update({ + id: '/subscriptions_/stake2pay', + path: '/subscriptions/stake2pay', + getParentRoute: () => rootRouteImport, +} as any) const LayoutSkillRoute = LayoutSkillRouteImport.update({ id: '/skill', path: '/skill', @@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { '/subscriptions': typeof SubscriptionsRoute '/login': typeof LayoutLoginRoute '/skill': typeof LayoutSkillRoute + '/subscriptions/stake2pay': typeof SubscriptionsStake2payRoute '/home': typeof LayoutAuthenticatedHomeRoute '/settings': typeof LayoutAuthenticatedSettingsRoute } @@ -79,6 +86,7 @@ export interface FileRoutesByTo { '/': typeof LayoutIndexRoute '/login': typeof LayoutLoginRoute '/skill': typeof LayoutSkillRoute + '/subscriptions/stake2pay': typeof SubscriptionsStake2payRoute '/home': typeof LayoutAuthenticatedHomeRoute '/settings': typeof LayoutAuthenticatedSettingsRoute } @@ -90,6 +98,7 @@ export interface FileRoutesById { '/_layout/_authenticated': typeof LayoutAuthenticatedRouteWithChildren '/_layout/login': typeof LayoutLoginRoute '/_layout/skill': typeof LayoutSkillRoute + '/subscriptions_/stake2pay': typeof SubscriptionsStake2payRoute '/_layout/': typeof LayoutIndexRoute '/_layout/_authenticated/home': typeof LayoutAuthenticatedHomeRoute '/_layout/_authenticated/settings': typeof LayoutAuthenticatedSettingsRoute @@ -102,6 +111,7 @@ export interface FileRouteTypes { | '/subscriptions' | '/login' | '/skill' + | '/subscriptions/stake2pay' | '/home' | '/settings' fileRoutesByTo: FileRoutesByTo @@ -111,6 +121,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/skill' + | '/subscriptions/stake2pay' | '/home' | '/settings' id: @@ -121,6 +132,7 @@ export interface FileRouteTypes { | '/_layout/_authenticated' | '/_layout/login' | '/_layout/skill' + | '/subscriptions_/stake2pay' | '/_layout/' | '/_layout/_authenticated/home' | '/_layout/_authenticated/settings' @@ -130,6 +142,7 @@ export interface RootRouteChildren { LayoutRoute: typeof LayoutRouteWithChildren PaymentsRoute: typeof PaymentsRoute SubscriptionsRoute: typeof SubscriptionsRoute + SubscriptionsStake2payRoute: typeof SubscriptionsStake2payRoute } declare module '@tanstack/react-router' { @@ -162,6 +175,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } + '/subscriptions_/stake2pay': { + id: '/subscriptions_/stake2pay' + path: '/subscriptions/stake2pay' + fullPath: '/subscriptions/stake2pay' + preLoaderRoute: typeof SubscriptionsStake2payRouteImport + parentRoute: typeof rootRouteImport + } '/_layout/skill': { id: '/_layout/skill' path: '/skill' @@ -234,6 +254,7 @@ const rootRouteChildren: RootRouteChildren = { LayoutRoute: LayoutRouteWithChildren, PaymentsRoute: PaymentsRoute, SubscriptionsRoute: SubscriptionsRoute, + SubscriptionsStake2payRoute: SubscriptionsStake2payRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/ui/src/routes/subscriptions.tsx b/ui/src/routes/subscriptions.tsx index 3f6cbbf..ee10b56 100644 --- a/ui/src/routes/subscriptions.tsx +++ b/ui/src/routes/subscriptions.tsx @@ -3,7 +3,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { ArrowRight, CheckCircle2, Loader2, RefreshCw, Wallet } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import type { AuthClient } from "@/app"; import { sessionQueryOptions, useApiClient, useAuthClient } from "@/app"; import { AppFooter } from "@/components/app-footer"; import { TopNav } from "@/components/top-nav"; @@ -11,6 +10,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { formatPlanAmount, formatPlanRange, nearToYocto, yoctoToNear } from "@/lib/near-amount"; import { pollUntil } from "@/lib/poll"; +import type { SubscriptionAction, SubscriptionInfo } from "@/lib/wallet-intent"; +import { signWalletIntent } from "@/lib/wallet-intent"; export const Route = createFileRoute("/subscriptions")({ validateSearch: (search: Record) => ({ @@ -53,34 +54,6 @@ type Plan = { metadata?: Record; }; -type SubscriptionInfo = { - id?: string; - planId: string; - status: "active" | "cancel_at_period_end" | "pending_unstake" | "ended" | "none"; - amount?: string; - currency?: string; - currentPeriodEnd?: string; - payerRef: string; - metadata?: Record; -}; - -type WalletIntent = { - kind: "wallet_intent"; - networkId: string; - contractId: string; - actions: Array<{ - methodName: string; - args: Record; - deposit: string; - gas: string; - }>; -}; - -type SubscriptionAction = - | WalletIntent - | { kind: "redirect"; url: string } - | { kind: "executed"; subscription: SubscriptionInfo }; - const BRAND_COLORS: Record = { stake2pay: "#00C08B", stripe: "#635BFF", @@ -113,40 +86,6 @@ const STATUS_STYLES: Record = { none: "bg-muted text-muted-foreground", }; -async function signWalletIntent(authClient: AuthClient, intent: WalletIntent) { - const connected = await authClient.near.ensureConnected(); - if (!connected) { - throw new Error("Connect a NEAR wallet to sign this transaction"); - } - const accountId = authClient.near.getAccountId(); - if (!accountId) { - throw new Error("No NEAR account linked to this session"); - } - const network = authClient.near.getNetwork(); - if (network !== intent.networkId) { - throw new Error(`Wallet is on ${network}, but this subscription needs ${intent.networkId}`); - } - - // max_total_prepaid_gas is 1000 Tgas as of protocol v84/v85 (verified via - // EXPERIMENTAL_protocol_config); intents exceeding it sign as sequential txs. - const totalGas = intent.actions.reduce((sum, action) => sum + BigInt(action.gas), 0n); - const batches = - totalGas <= 1_000_000_000_000_000n - ? [intent.actions] - : intent.actions.map((action) => [action]); - - for (const batch of batches) { - let tx = authClient.near.client.transaction(accountId); - for (const action of batch) { - tx = tx.functionCall(intent.contractId, action.methodName, action.args, { - gas: action.gas as `${number}`, - attachedDeposit: BigInt(action.deposit), - }); - } - await tx.send(); - } -} - function SubscriptionsPage() { const { checkout } = Route.useSearch(); const apiClient = useApiClient(); diff --git a/ui/src/routes/subscriptions_.stake2pay.tsx b/ui/src/routes/subscriptions_.stake2pay.tsx new file mode 100644 index 0000000..5144305 --- /dev/null +++ b/ui/src/routes/subscriptions_.stake2pay.tsx @@ -0,0 +1,324 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { Loader2, Wallet } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { sessionQueryOptions, useApiClient, useAuthClient } from "@/app"; +import { AppFooter } from "@/components/app-footer"; +import { TopNav } from "@/components/top-nav"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { nearToYocto, yoctoToNear } from "@/lib/near-amount"; +import { pollUntil } from "@/lib/poll"; +import type { SubscriptionAction, SubscriptionInfo } from "@/lib/wallet-intent"; +import { signWalletIntent } from "@/lib/wallet-intent"; + +const PROVIDER = "stake2pay"; +// stake2pay's product has several price tiers (Starter/Basic/Pro at different +// NEAR ranges); this page only demos the smallest "Starter" tier, so it must +// pick that exact price rather than trust array order from listPlans. +const DEMO_STAKE_NEAR = "1"; + +type Plan = { + id: string; + name: string; + minAmount: string; + maxAmount: string; +}; + +type CreditBalance = { + creditType: string; + balance: string; +}; + +export const Route = createFileRoute("/subscriptions_/stake2pay")({ + beforeLoad: async ({ context, location }) => { + const { queryClient, authClient } = context; + const session = await queryClient.ensureQueryData( + sessionQueryOptions(authClient, context.session), + ); + if (!session?.user) { + throw redirect({ to: "/login", search: { redirect: location.href } }); + } + return { session }; + }, + head: () => ({ + meta: [ + { title: "Stake2Pay — pay.everything.dev" }, + { name: "description", content: "Stake NEAR, earn inference credits." }, + ], + }), + component: Stake2PayPage, +}); + +function Stake2PayPage() { + const apiClient = useApiClient(); + const authClient = useAuthClient(); + const queryClient = useQueryClient(); + + const [nearAccountId, setNearAccountId] = useState(null); + const [inputNear, setInputNear] = useState(DEMO_STAKE_NEAR); + const [optimisticBalance, setOptimisticBalance] = useState(null); + + useEffect(() => { + authClient.near.setNetwork("testnet"); + setNearAccountId(authClient.near.getAccountId()); + }, [authClient]); + + const { data: plans } = useQuery({ + queryKey: ["subscription-plans", PROVIDER], + queryFn: () => apiClient.subscriptionPlans({ provider: PROVIDER }), + }); + const demoStakeYocto = nearToYocto(DEMO_STAKE_NEAR); + const plan = + (plans as Plan[] | undefined)?.find( + (p) => + p.name === "Starter" && + demoStakeYocto !== null && + demoStakeYocto >= BigInt(p.minAmount) && + demoStakeYocto <= BigInt(p.maxAmount), + ) ?? null; + + const { data: subscription, refetch: refetchSubscription } = useQuery({ + queryKey: ["subscription-status", PROVIDER, plan?.id, nearAccountId], + queryFn: () => + apiClient.subscriptionGet({ + provider: PROVIDER, + planId: plan?.id ?? "", + payerRef: nearAccountId ?? undefined, + }) as Promise, + enabled: !!plan && !!nearAccountId, + retry: false, + }); + const alreadyStaked = subscription ? subscription.status !== "none" : false; + + const { data: balances, refetch: refetchBalances } = useQuery({ + queryKey: ["credits"], + queryFn: () => apiClient.creditList() as Promise, + }); + const defaultBalance = balances?.find((b) => b.creditType === "default")?.balance ?? "0"; + const displayBalance = optimisticBalance ?? defaultBalance; + + const connectWallet = useMutation({ + mutationFn: async () => { + await authClient.signIn.near(); + }, + onSuccess: () => setNearAccountId(authClient.near.getAccountId()), + onError: (error: Error) => toast.error(error.message || "Wallet connection failed"), + }); + + const notifySyncResult = (sync: { granted: boolean; reason: string }) => { + switch (sync.reason) { + case "granted": + toast.success("Credits synced"); + break; + case "already_synced": + toast.info("Already up to date — no new stake since your last sync"); + break; + case "not_ready": + toast.info("Stake confirmed on-chain, but credits haven't synced yet — try again shortly"); + break; + default: + toast.info("Nothing staked yet"); + } + }; + + const syncCredits = useMutation({ + mutationFn: async () => { + if (!plan) throw new Error("No plan to sync"); + const payerRef = authClient.near.getAccountId(); + if (!payerRef) throw new Error("Connect a NEAR wallet first"); + + const sync = await apiClient.subscriptionCreditSync({ + provider: PROVIDER, + planId: plan.id, + payerRef, + }); + queryClient.setQueryData(["credits"], sync.balances); + return sync; + }, + onSuccess: notifySyncResult, + onError: (error: Error) => toast.error(error.message || "Sync failed"), + }); + + const amountYocto = plan ? nearToYocto(inputNear) : null; + const amountValid = + !!plan && + amountYocto !== null && + amountYocto >= BigInt(plan.minAmount) && + amountYocto <= BigInt(plan.maxAmount); + + const stake = useMutation({ + mutationFn: async () => { + if (!plan || !amountYocto) throw new Error("Enter a valid stake amount"); + + const action = (await apiClient.subscriptionCreate({ + provider: PROVIDER, + planId: plan.id, + amount: amountYocto.toString(), + })) as SubscriptionAction; + + if (action.kind !== "wallet_intent") { + throw new Error("Unexpected subscription action from stake2pay"); + } + + await signWalletIntent(authClient, action); + + const staked = yoctoToNear(amountYocto.toString()); + setOptimisticBalance((Number(defaultBalance) + Number(staked)).toString()); + toast.info("Stake sent — waiting for the chain to confirm"); + + const payerRef = authClient.near.getAccountId(); + if (!payerRef) throw new Error("Wallet disconnected before confirmation"); + + await pollUntil( + () => + apiClient.subscriptionGet({ + provider: PROVIDER, + planId: plan.id, + payerRef, + }) as Promise, + (s) => s.status === "active", + ); + + const sync = await apiClient.subscriptionCreditSync({ + provider: PROVIDER, + planId: plan.id, + payerRef, + }); + queryClient.setQueryData(["credits"], sync.balances); + setOptimisticBalance(null); + return sync; + }, + onSuccess: (sync) => { + if (sync.reason === "granted") { + toast.success("Staked — credits updated"); + } else { + notifySyncResult(sync); + } + }, + onError: (error: Error) => { + setOptimisticBalance(null); + toast.error(error.message || "Stake failed"); + }, + onSettled: () => { + refetchBalances(); + refetchSubscription(); + }, + }); + + return ( +
+ +
+ +
+
+
+
+

+ Stake2Pay +

+

+ Stake NEAR, earn inference credits. +

+
+ +
+

+ Credit balance +

+

{displayBalance}

+
+
+ +
+
+ {(["testnet", "mainnet"] as const).map((option) => ( + + {option} + + ))} +
+ + {!nearAccountId ? ( + + ) : alreadyStaked ? ( +
+

+ You already have a {subscription?.status.replace(/_/g, " ")} stake for this plan. + Staking again is disabled — sync it into credits instead. +

+ +
+ ) : ( +
+
+ + setInputNear(event.target.value)} + disabled={stake.isPending} + /> + {plan && !amountValid && ( +

+ Enter between {yoctoToNear(plan.minAmount)} and {yoctoToNear(plan.maxAmount)}{" "} + NEAR +

+ )} +
+ + +
+ )} +
+
+
+ + +
+ ); +} From ab5b889c4c73bfac441cf3c0ce0007e0382cf893 Mon Sep 17 00:00:00 2001 From: jaswinder6991 Date: Tue, 28 Jul 2026 18:34:15 +0200 Subject: [PATCH 2/2] fix: close credit-sync security/correctness gaps found in review - bind subscriptionCreditSync's payerRef to the caller's linked NEAR accounts instead of trusting the client-supplied value - always grant/read stake2pay credits as personal (org=null) on both the write and read path, avoiding a balance that reverts to 0 when an org is active - exclude "ended" from grant eligibility and from "already staked", so a withdrawn lock can't be credited and a finished subscription doesn't block re-staking - retry the RPC read through all backoffs instead of giving up the instant a lagging node reports "none" - reject subscriptionCreditSync for any provider but stake2pay - scope the UI credits cache key by organization, restore the NEAR network on unmount, do optimistic balance math in yoctoNEAR bigint instead of lossy Number(), and close the double-stake race by writing the confirmed subscription status synchronously - add integration coverage for grant idempotency, payerRef ownership, withdrawn-lock exclusion, and provider guarding Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CthJ9b2Zdz9P7er2sebiPe --- api/src/index.ts | 49 +++++++++++--- api/tests/integration/e2e-stake2pay.test.ts | 75 ++++++++++++++++++++- ui/src/routes/subscriptions_.stake2pay.tsx | 28 ++++++-- 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/api/src/index.ts b/api/src/index.ts index 84423e5..b2bd834 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -76,6 +76,27 @@ export default createPlugin.withPlugins()({ return context.userId; }; + const requireOwnPayerRef = ( + payerRef: string | undefined, + context: { + near?: { + primaryAccountId?: string | null; + linkedAccounts?: Array<{ accountId: string }>; + }; + }, + ) => { + const resolved = requirePayerRef(payerRef, context); + const owned = + resolved === context.near?.primaryAccountId || + (context.near?.linkedAccounts ?? []).some((account) => account.accountId === resolved); + if (!owned) { + throw new ORPCError("FORBIDDEN", { + message: "payerRef must be a NEAR account linked to your session", + }); + } + return resolved; + }; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Public NEAR RPC endpoints load-balance across nodes with no read-after-write @@ -91,7 +112,7 @@ export default createPlugin.withPlugins()({ let subscription = await client.getSubscription({ planId, payerRef }); for (const delay of retryDelaysMs) { const hasLockData = !!subscription.metadata?.lastLockId && !!subscription.amount; - if (subscription.status === "none" || hasLockData) break; + if (hasLockData) break; await sleep(delay); subscription = await client.getSubscription({ planId, payerRef }); } @@ -219,31 +240,39 @@ export default createPlugin.withPlugins()({ creditList: builder.creditList.handler(async ({ context }) => { const userId = requireUserId(context); - const organizationId = context.organization?.activeOrganizationId ?? null; - return await runEffect(services.credits.getBalances({ userId, organizationId })); + const personalOrganizationId = null; + return await runEffect( + services.credits.getBalances({ userId, organizationId: personalOrganizationId }), + ); }), subscriptionCreditSync: builder.subscriptionCreditSync.handler(async ({ input, context }) => { const userId = requireUserId(context); - const organizationId = context.organization?.activeOrganizationId ?? null; const { provider, planId } = input; + if (provider !== "stake2pay") { + throw new ORPCError("NOT_IMPLEMENTED", { + message: `Credit sync is only available for stake2pay, not ${provider}`, + }); + } + const personalOrganizationId = null; const factory = getSubscriptionPlugin(provider); const client = (factory as (opts?: unknown) => any)(); - const payerRef = requirePayerRef(input.payerRef, context); + const payerRef = requireOwnPayerRef(input.payerRef, context); const subscription = await getSubscriptionWithRetry(client, planId, payerRef); const lockId = subscription.metadata?.lastLockId; const amount = subscription.amount; + const grantableStatuses = new Set(["active", "cancel_at_period_end", "pending_unstake"]); let granted = false; let reason: "granted" | "already_synced" | "not_ready" | "not_staked" = "not_staked"; - if (subscription.status !== "none" && lockId && amount) { + if (grantableStatuses.has(subscription.status) && lockId && amount) { const result = await runEffect( services.credits.grantCredits({ userId, - organizationId, + organizationId: personalOrganizationId, amount: yoctoToNear(amount), source: `${provider}:lock`, sourceRef: lockId, @@ -252,11 +281,13 @@ export default createPlugin.withPlugins()({ ); granted = result.granted; reason = result.granted ? "granted" : "already_synced"; - } else if (subscription.status !== "none") { + } else if (grantableStatuses.has(subscription.status)) { reason = "not_ready"; } - const balances = await runEffect(services.credits.getBalances({ userId, organizationId })); + const balances = await runEffect( + services.credits.getBalances({ userId, organizationId: personalOrganizationId }), + ); return { granted, reason, balances }; }), }; diff --git a/api/tests/integration/e2e-stake2pay.test.ts b/api/tests/integration/e2e-stake2pay.test.ts index 9f31ffe..fc331ef 100644 --- a/api/tests/integration/e2e-stake2pay.test.ts +++ b/api/tests/integration/e2e-stake2pay.test.ts @@ -322,7 +322,15 @@ async function createStake2PayE2EContext( const nearAccountId = req.headers[NEAR_ACCOUNT_HEADER]; const context = { reqHeaders: toWebHeaders(req), - ...(typeof nearAccountId === "string" ? { near: { primaryAccountId: nearAccountId } } : {}), + ...(typeof nearAccountId === "string" + ? { + userId: `user-${nearAccountId}`, + near: { + primaryAccountId: nearAccountId, + linkedAccounts: [{ accountId: nearAccountId }], + }, + } + : {}), }; try { @@ -595,6 +603,71 @@ describe("E2E: stake2pay through the aggregator API (mocked NEAR RPC)", () => { deposit: "1", }); }); + + describe("subscriptionCreditSync", () => { + it("grants credits for an active lock, then is idempotent on re-sync", async () => { + chainState.subscription = { ...activeSubscription, last_lock_id: "lock_credit_1" }; + chainState.lock = { ...activeLock, lock_id: "lock_credit_1" }; + + const first = await ctx.authedClient.subscriptionCreditSync({ + provider: PROVIDER, + planId: STARTER_PRICE_ID, + }); + expect(first.granted).toBe(true); + expect(first.reason).toBe("granted"); + expect(first.balances).toEqual([{ creditType: "default", balance: "5" }]); + + const second = await ctx.authedClient.subscriptionCreditSync({ + provider: PROVIDER, + planId: STARTER_PRICE_ID, + }); + expect(second.granted).toBe(false); + expect(second.reason).toBe("already_synced"); + expect(second.balances).toEqual([{ creditType: "default", balance: "5" }]); + }); + + it("rejects a payerRef that isn't linked to the caller's session", async () => { + await expect( + ctx.authedClient.subscriptionCreditSync({ + provider: PROVIDER, + planId: STARTER_PRICE_ID, + payerRef: "mallory.testnet", + }), + ).rejects.toThrow(/FORBIDDEN|linked/); + }); + + it("does not grant credits for a lock that was withdrawn before ever syncing", async () => { + chainState.subscription = { + ...activeSubscription, + last_lock_id: "lock_credit_withdrawn", + status: "Cancelled", + end_ns: PAST_END_NS, + }; + chainState.lock = { + ...activeLock, + lock_id: "lock_credit_withdrawn", + status: "Withdrawn", + }; + + const sync = await ctx.authedClient.subscriptionCreditSync({ + provider: PROVIDER, + planId: STARTER_PRICE_ID, + }); + + expect(sync.granted).toBe(false); + expect(sync.reason).toBe("not_staked"); + expect(sync.balances).toEqual([{ creditType: "default", balance: "5" }]); + }); + + it("rejects credit sync for a non-stake2pay provider", async () => { + await expect( + ctx.authedClient.subscriptionCreditSync({ + provider: "stripe", + planId: STARTER_PRICE_ID, + }), + ).rejects.toThrow(/NOT_IMPLEMENTED|stake2pay/); + }); + }); }); /** diff --git a/ui/src/routes/subscriptions_.stake2pay.tsx b/ui/src/routes/subscriptions_.stake2pay.tsx index 5144305..b6ef1f4 100644 --- a/ui/src/routes/subscriptions_.stake2pay.tsx +++ b/ui/src/routes/subscriptions_.stake2pay.tsx @@ -55,14 +55,21 @@ function Stake2PayPage() { const apiClient = useApiClient(); const authClient = useAuthClient(); const queryClient = useQueryClient(); + const { session } = Route.useRouteContext(); + const activeOrganizationId = session.session?.activeOrganizationId ?? null; + const creditsQueryKey = ["credits", activeOrganizationId]; const [nearAccountId, setNearAccountId] = useState(null); const [inputNear, setInputNear] = useState(DEMO_STAKE_NEAR); const [optimisticBalance, setOptimisticBalance] = useState(null); useEffect(() => { + const previousNetwork = authClient.near.getNetwork(); authClient.near.setNetwork("testnet"); setNearAccountId(authClient.near.getAccountId()); + return () => { + authClient.near.setNetwork(previousNetwork); + }; }, [authClient]); const { data: plans } = useQuery({ @@ -90,10 +97,12 @@ function Stake2PayPage() { enabled: !!plan && !!nearAccountId, retry: false, }); - const alreadyStaked = subscription ? subscription.status !== "none" : false; + const alreadyStaked = subscription + ? subscription.status !== "none" && subscription.status !== "ended" + : false; const { data: balances, refetch: refetchBalances } = useQuery({ - queryKey: ["credits"], + queryKey: creditsQueryKey, queryFn: () => apiClient.creditList() as Promise, }); const defaultBalance = balances?.find((b) => b.creditType === "default")?.balance ?? "0"; @@ -134,7 +143,7 @@ function Stake2PayPage() { planId: plan.id, payerRef, }); - queryClient.setQueryData(["credits"], sync.balances); + queryClient.setQueryData(creditsQueryKey, sync.balances); return sync; }, onSuccess: notifySyncResult, @@ -164,14 +173,15 @@ function Stake2PayPage() { await signWalletIntent(authClient, action); - const staked = yoctoToNear(amountYocto.toString()); - setOptimisticBalance((Number(defaultBalance) + Number(staked)).toString()); + const stakedYocto = amountYocto; + const currentYocto = nearToYocto(defaultBalance) ?? 0n; + setOptimisticBalance(yoctoToNear((currentYocto + stakedYocto).toString())); toast.info("Stake sent — waiting for the chain to confirm"); const payerRef = authClient.near.getAccountId(); if (!payerRef) throw new Error("Wallet disconnected before confirmation"); - await pollUntil( + const activeSubscription = await pollUntil( () => apiClient.subscriptionGet({ provider: PROVIDER, @@ -180,13 +190,17 @@ function Stake2PayPage() { }) as Promise, (s) => s.status === "active", ); + queryClient.setQueryData( + ["subscription-status", PROVIDER, plan.id, nearAccountId], + activeSubscription, + ); const sync = await apiClient.subscriptionCreditSync({ provider: PROVIDER, planId: plan.id, payerRef, }); - queryClient.setQueryData(["credits"], sync.balances); + queryClient.setQueryData(creditsQueryKey, sync.balances); setOptimisticBalance(null); return sync; },