Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/stake2pay-credit-sync.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
25 changes: 25 additions & 0 deletions api/src/db/migrations/0001_flowery_sleeper.sql
Original file line number Diff line number Diff line change
@@ -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");
237 changes: 237 additions & 0 deletions api/src/db/migrations/meta/0001_snapshot.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
7 changes: 7 additions & 0 deletions api/src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
50 changes: 49 additions & 1 deletion api/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<Record<string, unknown>>(),
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')`),
],
);
Loading
Loading