diff --git a/apps/api/drizzle/0032_melodic_onslaught.sql b/apps/api/drizzle/0032_melodic_onslaught.sql new file mode 100644 index 0000000000..9731bc65a5 --- /dev/null +++ b/apps/api/drizzle/0032_melodic_onslaught.sql @@ -0,0 +1,22 @@ +CREATE TYPE "public"."x402_transaction_status" AS ENUM('pending', 'settled', 'succeeded', 'failed');--> statement-breakpoint +CREATE TABLE "x402_transactions" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + "user_id" uuid NOT NULL, + "status" "x402_transaction_status" DEFAULT 'pending' NOT NULL, + "amount" integer NOT NULL, + "currency" varchar(3) DEFAULT 'usd' NOT NULL, + "network" varchar(100) NOT NULL, + "asset" varchar(255) NOT NULL, + "payment_hash" varchar(64) NOT NULL, + "payer_address" varchar(255), + "settlement_tx_hash" varchar(255), + "error_message" varchar(1000), + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "x402_transactions" ADD CONSTRAINT "x402_transactions_user_id_userSetting_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."userSetting"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "x402_transactions_payment_hash_unique" ON "x402_transactions" USING btree ("payment_hash");--> statement-breakpoint +CREATE INDEX "x402_transactions_user_id_idx" ON "x402_transactions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "x402_transactions_status_idx" ON "x402_transactions" USING btree ("status");--> statement-breakpoint +CREATE INDEX "x402_transactions_user_id_created_at_idx" ON "x402_transactions" USING btree ("user_id","created_at"); \ No newline at end of file diff --git a/apps/api/drizzle/0033_x402_deploy_columns.sql b/apps/api/drizzle/0033_x402_deploy_columns.sql new file mode 100644 index 0000000000..1e69e1f943 --- /dev/null +++ b/apps/api/drizzle/0033_x402_deploy_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE "x402_transactions" ADD COLUMN "deployment_dseq" varchar(100);--> statement-breakpoint +ALTER TABLE "x402_transactions" ADD COLUMN "deploy_failed" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0032_snapshot.json b/apps/api/drizzle/meta/0032_snapshot.json new file mode 100644 index 0000000000..98601461ff --- /dev/null +++ b/apps/api/drizzle/meta/0032_snapshot.json @@ -0,0 +1,1521 @@ +{ + "id": "e3a9268e-1b45-45f3-b721-c5157ab61a57", + "prevId": "4655dea1-63ec-468b-b060-502553acd772", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_transactions": { + "name": "stripe_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stripe_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "stripe_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_refunded": { + "name": "amount_refunded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bonus_amount": { + "name": "bonus_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_promotion_code_id": { + "name": "stripe_promotion_code_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_brand": { + "name": "card_brand", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_last4": { + "name": "card_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_transactions_stripe_invoice_id_unique": { + "name": "stripe_transactions_stripe_invoice_id_unique", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"stripe_transactions\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_idx": { + "name": "stripe_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_payment_intent_id_idx": { + "name": "stripe_transactions_stripe_payment_intent_id_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_charge_id_idx": { + "name": "stripe_transactions_stripe_charge_id_idx", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_coupon_id_idx": { + "name": "stripe_transactions_stripe_coupon_id_idx", + "columns": [ + { + "expression": "stripe_coupon_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_promotion_code_id_idx": { + "name": "stripe_transactions_stripe_promotion_code_id_idx", + "columns": [ + { + "expression": "stripe_promotion_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_status_idx": { + "name": "stripe_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_created_at_idx": { + "name": "stripe_transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_created_at_idx": { + "name": "stripe_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_transactions_user_id_userSetting_id_fk": { + "name": "stripe_transactions_user_id_userSetting_id_fk", + "tableFrom": "stripe_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_settings": { + "name": "wallet_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auto_reload_enabled": { + "name": "auto_reload_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_settings_user_id_idx": { + "name": "wallet_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_settings_wallet_id_user_wallets_id_fk": { + "name": "wallet_settings_wallet_id_user_wallets_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "user_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_settings_user_id_userSetting_id_fk": { + "name": "wallet_settings_user_id_userSetting_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_settings_wallet_id_unique": { + "name": "wallet_settings_wallet_id_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.x402_transactions": { + "name": "x402_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "x402_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "network": { + "name": "network", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_hash": { + "name": "payment_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "settlement_tx_hash": { + "name": "settlement_tx_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "x402_transactions_payment_hash_unique": { + "name": "x402_transactions_payment_hash_unique", + "columns": [ + { + "expression": "payment_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "x402_transactions_user_id_idx": { + "name": "x402_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "x402_transactions_status_idx": { + "name": "x402_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "x402_transactions_user_id_created_at_idx": { + "name": "x402_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "x402_transactions_user_id_userSetting_id_fk": { + "name": "x402_transactions_user_id_userSetting_id_fk", + "tableFrom": "x402_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_user_agent": { + "name": "last_user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_fingerprint": { + "name": "last_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template": { + "name": "template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "copiedFromId": { + "name": "copiedFromId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cpu": { + "name": "cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage": { + "name": "storage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sdl": { + "name": "sdl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "template_userId_idx": { + "name": "template_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templateFavorite": { + "name": "templateFavorite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "templateId": { + "name": "templateId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "addedDate": { + "name": "addedDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "templateFavorite_userId_templateId_unique": { + "name": "templateFavorite_userId_templateId_unique", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "templateId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templateFavorite_templateId_template_id_fk": { + "name": "templateFavorite_templateId_template_id_fk", + "tableFrom": "templateFavorite", + "tableTo": "template", + "columnsFrom": [ + "templateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "id_auto_top_up_enabled_closed_idx": { + "name": "id_auto_top_up_enabled_closed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auto_top_up_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_settings_user_id_userSetting_id_fk": { + "name": "deployment_settings_user_id_userSetting_id_fk", + "tableFrom": "deployment_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dseq_user_id_idx": { + "name": "dseq_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "dseq", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_format": { + "name": "key_format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_userSetting_id_fk": { + "name": "api_keys_user_id_userSetting_id_fk", + "tableFrom": "api_keys", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_unique": { + "name": "api_keys_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_codes": { + "name": "email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_codes_user_id_idx": { + "name": "email_verification_codes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verification_codes_user_id_userSetting_id_fk": { + "name": "email_verification_codes_user_id_userSetting_id_fk", + "tableFrom": "email_verification_codes", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.stripe_transaction_status": { + "name": "stripe_transaction_status", + "schema": "public", + "values": [ + "created", + "pending", + "requires_action", + "succeeded", + "failed", + "refunded", + "canceled" + ] + }, + "public.stripe_transaction_type": { + "name": "stripe_transaction_type", + "schema": "public", + "values": [ + "payment_intent", + "coupon_claim", + "manual_credit" + ] + }, + "public.x402_transaction_status": { + "name": "x402_transaction_status", + "schema": "public", + "values": [ + "pending", + "settled", + "succeeded", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0033_snapshot.json b/apps/api/drizzle/meta/0033_snapshot.json new file mode 100644 index 0000000000..4106514a95 --- /dev/null +++ b/apps/api/drizzle/meta/0033_snapshot.json @@ -0,0 +1,1534 @@ +{ + "id": "a918c389-d55e-4de3-9960-9e7f81ad6031", + "prevId": "e3a9268e-1b45-45f3-b721-c5157ab61a57", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_transactions": { + "name": "stripe_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stripe_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "stripe_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_refunded": { + "name": "amount_refunded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bonus_amount": { + "name": "bonus_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_promotion_code_id": { + "name": "stripe_promotion_code_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_brand": { + "name": "card_brand", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_last4": { + "name": "card_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_transactions_stripe_invoice_id_unique": { + "name": "stripe_transactions_stripe_invoice_id_unique", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"stripe_transactions\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_idx": { + "name": "stripe_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_payment_intent_id_idx": { + "name": "stripe_transactions_stripe_payment_intent_id_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_charge_id_idx": { + "name": "stripe_transactions_stripe_charge_id_idx", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_coupon_id_idx": { + "name": "stripe_transactions_stripe_coupon_id_idx", + "columns": [ + { + "expression": "stripe_coupon_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_promotion_code_id_idx": { + "name": "stripe_transactions_stripe_promotion_code_id_idx", + "columns": [ + { + "expression": "stripe_promotion_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_status_idx": { + "name": "stripe_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_created_at_idx": { + "name": "stripe_transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_created_at_idx": { + "name": "stripe_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_transactions_user_id_userSetting_id_fk": { + "name": "stripe_transactions_user_id_userSetting_id_fk", + "tableFrom": "stripe_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_settings": { + "name": "wallet_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auto_reload_enabled": { + "name": "auto_reload_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_settings_user_id_idx": { + "name": "wallet_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_settings_wallet_id_user_wallets_id_fk": { + "name": "wallet_settings_wallet_id_user_wallets_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "user_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_settings_user_id_userSetting_id_fk": { + "name": "wallet_settings_user_id_userSetting_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_settings_wallet_id_unique": { + "name": "wallet_settings_wallet_id_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.x402_transactions": { + "name": "x402_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "x402_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "network": { + "name": "network", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_hash": { + "name": "payment_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "settlement_tx_hash": { + "name": "settlement_tx_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "deployment_dseq": { + "name": "deployment_dseq", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "deploy_failed": { + "name": "deploy_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "x402_transactions_payment_hash_unique": { + "name": "x402_transactions_payment_hash_unique", + "columns": [ + { + "expression": "payment_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "x402_transactions_user_id_idx": { + "name": "x402_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "x402_transactions_status_idx": { + "name": "x402_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "x402_transactions_user_id_created_at_idx": { + "name": "x402_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "x402_transactions_user_id_userSetting_id_fk": { + "name": "x402_transactions_user_id_userSetting_id_fk", + "tableFrom": "x402_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_user_agent": { + "name": "last_user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_fingerprint": { + "name": "last_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template": { + "name": "template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "copiedFromId": { + "name": "copiedFromId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cpu": { + "name": "cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage": { + "name": "storage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sdl": { + "name": "sdl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "template_userId_idx": { + "name": "template_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templateFavorite": { + "name": "templateFavorite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "templateId": { + "name": "templateId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "addedDate": { + "name": "addedDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "templateFavorite_userId_templateId_unique": { + "name": "templateFavorite_userId_templateId_unique", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "templateId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templateFavorite_templateId_template_id_fk": { + "name": "templateFavorite_templateId_template_id_fk", + "tableFrom": "templateFavorite", + "tableTo": "template", + "columnsFrom": [ + "templateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "id_auto_top_up_enabled_closed_idx": { + "name": "id_auto_top_up_enabled_closed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auto_top_up_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_settings_user_id_userSetting_id_fk": { + "name": "deployment_settings_user_id_userSetting_id_fk", + "tableFrom": "deployment_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dseq_user_id_idx": { + "name": "dseq_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "dseq", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_format": { + "name": "key_format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_userSetting_id_fk": { + "name": "api_keys_user_id_userSetting_id_fk", + "tableFrom": "api_keys", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_unique": { + "name": "api_keys_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_codes": { + "name": "email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_codes_user_id_idx": { + "name": "email_verification_codes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verification_codes_user_id_userSetting_id_fk": { + "name": "email_verification_codes_user_id_userSetting_id_fk", + "tableFrom": "email_verification_codes", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.stripe_transaction_status": { + "name": "stripe_transaction_status", + "schema": "public", + "values": [ + "created", + "pending", + "requires_action", + "succeeded", + "failed", + "refunded", + "canceled" + ] + }, + "public.stripe_transaction_type": { + "name": "stripe_transaction_type", + "schema": "public", + "values": [ + "payment_intent", + "coupon_claim", + "manual_credit" + ] + }, + "public.x402_transaction_status": { + "name": "x402_transaction_status", + "schema": "public", + "values": [ + "pending", + "settled", + "succeeded", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 54e2af281a..f72c69da01 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -225,6 +225,20 @@ "when": 1784062230879, "tag": "0031_lively_gwen_stacy", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1784324135779, + "tag": "0032_melodic_onslaught", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1784326297570, + "tag": "0033_x402_deploy_columns", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 69999f9ff4..2e5efe6556 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -71,6 +71,9 @@ "@typescript-eslint/parser": "^7.18.0", "@ucast/core": "^1.10.2", "@ucast/mongo2js": "^1.4.0", + "@x402/core": "^2.19.0", + "@x402/evm": "^2.19.0", + "@x402/hono": "^2.19.0", "async-sema": "^3.1.1", "auth0": "^4.16.0", "axios": "^1.7.2", diff --git a/apps/api/src/app/providers/jobs.provider.ts b/apps/api/src/app/providers/jobs.provider.ts index c5d1fca24b..5a2ab62a82 100644 --- a/apps/api/src/app/providers/jobs.provider.ts +++ b/apps/api/src/app/providers/jobs.provider.ts @@ -1,6 +1,8 @@ import { container } from "tsyringe"; import { WalletBalanceReloadCheckHandler } from "@src/billing/services/wallet-balance-reload-check/wallet-balance-reload-check.handler"; +import { X402ReconcileJobService } from "@src/billing/services/x402-reconcile-job/x402-reconcile-job.service"; +import { X402ReconcileSettledHandler } from "@src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler"; import type { AppInitializer } from "@src/core/providers/app-initializer"; import { APP_INITIALIZER, ON_APP_START } from "@src/core/providers/app-initializer"; import { JobQueueService } from "@src/core/services/job-queue/job-queue.service"; @@ -23,8 +25,11 @@ container.register(APP_INITIALIZER, { container.resolve(TrialDeploymentLeaseCreatedHandler), container.resolve(EnableDeploymentAlertHandler), container.resolve(WalletBalanceReloadCheckHandler), - container.resolve(FirstPurchaseBonusGrantedHandler) + container.resolve(FirstPurchaseBonusGrantedHandler), + container.resolve(X402ReconcileSettledHandler) ]); + + await container.resolve(X402ReconcileJobService).scheduleInitial(); } } satisfies AppInitializer }); diff --git a/apps/api/src/auth/services/ability/ability.service.ts b/apps/api/src/auth/services/ability/ability.service.ts index 2ac8f28f82..070d0e67a9 100644 --- a/apps/api/src/auth/services/ability/ability.service.ts +++ b/apps/api/src/auth/services/ability/ability.service.ts @@ -20,6 +20,7 @@ export class AbilityService { { action: "read", subject: "User", conditions: { id: "${user.id}" } }, { action: "verify-email", subject: "User", conditions: { email: "${user.email}" } }, { action: ["create", "read", "delete"], subject: "StripePayment" }, + { action: ["create", "read"], subject: "X402Payment" }, { action: "manage", subject: "PaymentMethod", conditions: { userId: "${user.id}" } }, { action: "create", subject: "VerificationEmail", conditions: { id: "${user.id}" } }, { action: "manage", subject: "DeploymentSetting", conditions: { userId: "${user.id}" } }, @@ -33,6 +34,7 @@ export class AbilityService { { action: "read", subject: "User", conditions: { id: "${user.id}" } }, { action: "verify-email", subject: "User", conditions: { email: "${user.email}" } }, { action: ["create", "read", "delete"], subject: "StripePayment" }, + { action: ["create", "read"], subject: "X402Payment" }, { action: "manage", subject: "PaymentMethod", conditions: { userId: "${user.id}" } }, { action: "create", subject: "VerificationEmail", conditions: { id: "${user.id}" } }, { action: "manage", subject: "DeploymentSetting", conditions: { userId: "${user.id}" } }, diff --git a/apps/api/src/billing/config/env.config.spec.ts b/apps/api/src/billing/config/env.config.spec.ts new file mode 100644 index 0000000000..3508b62389 --- /dev/null +++ b/apps/api/src/billing/config/env.config.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { envSchema } from "@src/billing/config/env.config"; + +describe("billing envSchema x402 sandbox firewall", () => { + it("rejects a testnet X402_NETWORK when NETWORK is mainnet", () => { + const result = envSchema.safeParse(baseEnv({ NETWORK: "mainnet", X402_NETWORK: "eip155:84532" })); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find(i => i.path.includes("X402_NETWORK")); + expect(issue?.message).toContain("testnet"); + } + }); + + it("accepts a testnet X402_NETWORK when NETWORK is sandbox", () => { + const result = envSchema.safeParse(baseEnv({ NETWORK: "sandbox", X402_NETWORK: "eip155:84532" })); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.X402_NETWORK).toBe("eip155:84532"); + } + }); + + it("accepts a testnet X402_NETWORK when NETWORK is testnet", () => { + const result = envSchema.safeParse(baseEnv({ NETWORK: "testnet", X402_NETWORK: "eip155:84532" })); + + expect(result.success).toBe(true); + }); + + it("accepts a mainnet X402_NETWORK when NETWORK is mainnet", () => { + const result = envSchema.safeParse(baseEnv({ NETWORK: "mainnet", X402_NETWORK: "eip155:8453" })); + + expect(result.success).toBe(true); + }); + + it("accepts the default mainnet X402_NETWORK on a sandbox deployment", () => { + const env = baseEnv({ NETWORK: "sandbox" }); + delete env.X402_NETWORK; + + const result = envSchema.safeParse(env); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.X402_NETWORK).toBe("eip155:8453"); + } + }); + + function baseEnv(overrides: Record = {}): Record { + return { + NETWORK: "sandbox", + RPC_NODE_ENDPOINT: "https://rpc.example.com", + TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT: 10, + TRIAL_FEES_ALLOWANCE_AMOUNT: 10, + DEPLOYMENT_GRANT_DENOM: "uakt", + FEE_ALLOWANCE_REFILL_THRESHOLD: 1, + FEE_ALLOWANCE_REFILL_AMOUNT: 1, + DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT: 1, + STRIPE_SECRET_KEY: "sk_test", + STRIPE_PRODUCT_ID: "prod_test", + STRIPE_WEBHOOK_SECRET: "whsec_test", + CONSOLE_WEB_PAYMENT_LINK: "https://console.example.com/pay", + TX_SIGNER_BASE_URL: "https://tx-signer.example.com", + X402_ENABLED: "true", + X402_PAY_TO_ADDRESS: "0x1111111111111111111111111111111111111111", + X402_NETWORK: "eip155:8453", + ...overrides + }; + } +}); diff --git a/apps/api/src/billing/config/env.config.ts b/apps/api/src/billing/config/env.config.ts index a1d6767610..fece49a61a 100644 --- a/apps/api/src/billing/config/env.config.ts +++ b/apps/api/src/billing/config/env.config.ts @@ -1,11 +1,12 @@ import dotenv from "dotenv"; import { z } from "zod"; +import { isX402TestnetNetwork } from "@src/billing/config/x402-networks"; import { AUDITOR } from "@src/deployment/config/provider.config"; dotenv.config({ path: "env/.env.funding-wallet-index" }); -export const envSchema = z.object({ +const baseEnvSchema = z.object({ OLD_MASTER_WALLET_MNEMONIC: z.string().optional(), FUNDING_WALLET_MNEMONIC: z.string().optional(), FUNDING_WALLET_MNEMONIC_V1: z.string().optional(), @@ -52,7 +53,53 @@ export const envSchema = z.object({ message: "MANAGED_WALLET_TRIAL_BLOCKED_GPU_MODELS entries must be in 'vendor/model' format" }), MASTER_WALLET_TARGET_ACT_BALANCE: z.number({ coerce: true }).default(10_000_000_000), - TX_SIGNER_BASE_URL: z.string() + TX_SIGNER_BASE_URL: z.string(), + X402_ENABLED: z.enum(["true", "false"]).default("false"), + X402_PAY_TO_ADDRESS: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "X402_PAY_TO_ADDRESS must be an EVM address") + .optional(), + X402_NETWORK: z + .string() + .regex(/^[a-z0-9-]+:[a-zA-Z0-9-_]+$/, "X402_NETWORK must be a CAIP-2 network id, e.g. eip155:8453") + .default("eip155:8453") + .transform(value => value as `${string}:${string}`), + X402_FACILITATOR_URL: z.string().default("https://x402.org/facilitator"), + X402_MIN_TOP_UP_USD: z.number({ coerce: true }).default(1), + X402_MAX_TOP_UP_USD: z.number({ coerce: true }).default(1000), + // Pay-per-deploy deposit bounds (USD) for POST /v1/x402/deploy. + X402_MIN_DEPLOY_USD: z.number({ coerce: true }).default(1), + X402_MAX_DEPLOY_USD: z.number({ coerce: true }).default(1000), + // Abuse controls for the x402-paid endpoints, enforced per user against recent x402_transactions. + // Rolling window (seconds) over which the rate limit and cost ceiling are evaluated. + X402_ABUSE_WINDOW_SECONDS: z.number({ coerce: true }).default(3600), + // Max number of x402-paid requests a single user may make within the window. + X402_ABUSE_MAX_REQUESTS: z.number({ coerce: true }).default(10), + // Max cumulative USD a single user may pay via x402 within the window (cost ceiling). + X402_ABUSE_MAX_SPEND_USD: z.number({ coerce: true }).default(2000), + // How long a transaction may sit in `settled` before the reconcile job re-drives crediting. + // The delay keeps the job from racing the in-request credit of a freshly settled payment. + X402_RECONCILE_THRESHOLD_SECONDS: z.number({ coerce: true }).default(300), + // How often the reconcile job re-scans for stranded `settled` transactions. + X402_RECONCILE_INTERVAL_SECONDS: z.number({ coerce: true }).default(300), + // Max number of stale `settled` transactions reconciled per run. + X402_RECONCILE_BATCH_SIZE: z.number({ coerce: true }).default(100) +}); + +/** + * Sandbox firewall: a testnet x402 settlement network must never be paired with a + * mainnet Akash `NETWORK`, otherwise a testnet payment (worthless funds) could settle + * and credit a real mainnet balance. Enforced at config-parse time so a misconfigured + * deployment fails fast at startup instead of silently crediting free credits. + */ +export const envSchema = baseEnvSchema.superRefine((config, ctx) => { + if (config.NETWORK === "mainnet" && isX402TestnetNetwork(config.X402_NETWORK)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["X402_NETWORK"], + message: `X402_NETWORK "${config.X402_NETWORK}" is a testnet network and cannot be used while NETWORK is "mainnet": a testnet settlement must never credit a mainnet balance. Use a mainnet X402_NETWORK (e.g. eip155:8453) or run with NETWORK=sandbox/testnet.` + }); + } }); export type BillingConfig = z.infer; diff --git a/apps/api/src/billing/config/x402-networks.ts b/apps/api/src/billing/config/x402-networks.ts new file mode 100644 index 0000000000..49bf0a108e --- /dev/null +++ b/apps/api/src/billing/config/x402-networks.ts @@ -0,0 +1,25 @@ +/** + * CAIP-2 network ids for EVM chains that x402 can settle on. This is the single + * source of truth for classifying an `X402_NETWORK` value as mainnet vs testnet. + * + * The classification powers the sandbox firewall (see `env.config.ts`): a testnet + * settlement network is only allowed when the Akash `NETWORK` is not `mainnet`, so a + * testnet payment can never be settled against and credit a real (mainnet) balance. + */ +export const X402_TESTNET_NETWORKS = new Set([ + "eip155:84532", // Base Sepolia + "eip155:11155111", // Ethereum Sepolia + "eip155:421614", // Arbitrum Sepolia + "eip155:11155420", // Optimism Sepolia + "eip155:80002", // Polygon Amoy + "eip155:43113", // Avalanche Fuji + "eip155:97", // BNB Smart Chain Testnet + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" // Solana Devnet +]); + +/** + * Returns true when the given CAIP-2 network id is a known x402 testnet network. + */ +export function isX402TestnetNetwork(network: string): boolean { + return X402_TESTNET_NETWORKS.has(network); +} diff --git a/apps/api/src/billing/controllers/x402/x402.controller.spec.ts b/apps/api/src/billing/controllers/x402/x402.controller.spec.ts new file mode 100644 index 0000000000..2159a45d14 --- /dev/null +++ b/apps/api/src/billing/controllers/x402/x402.controller.spec.ts @@ -0,0 +1,115 @@ +import { faker } from "@faker-js/faker"; +import { HttpError } from "http-errors"; +import { container } from "tsyringe"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { AuthService } from "@src/auth/services/auth.service"; +import type { BillingConfig } from "@src/billing/providers"; +import type { X402TransactionOutput, X402TransactionRepository } from "@src/billing/repositories"; +import type { X402Service } from "@src/billing/services/x402/x402.service"; +import { X402_ERROR_CODES } from "@src/billing/services/x402/x402-error-codes"; +import { X402Controller } from "./x402.controller"; + +import { createUser } from "@test/seeders/user.seeder"; + +describe(X402Controller.name, () => { + describe("listTransactions", () => { + it("scopes the query to the authenticated user so it cannot read another user's rows", async () => { + const { controller, user, ability, x402TransactionRepository, scopedRepository } = setup(); + const transaction = createTransaction({ userId: user.id }); + scopedRepository.findByUserPaginated.mockResolvedValue({ transactions: [transaction], total: 1 }); + + const result = await controller.listTransactions({ limit: 25, offset: 0 }); + + expect(x402TransactionRepository.accessibleBy).toHaveBeenCalledWith(ability, "read"); + expect(scopedRepository.findByUserPaginated).toHaveBeenCalledWith({ userId: user.id, limit: 25, offset: 0 }); + expect(result).toEqual({ + data: [ + { + transactionId: transaction.id, + status: transaction.status, + amountUsdCents: transaction.amount, + currency: transaction.currency, + network: transaction.network, + asset: transaction.asset, + settlementTxHash: transaction.settlementTxHash, + payerAddress: transaction.payerAddress, + createdAt: transaction.createdAt.toISOString() + } + ], + pagination: { limit: 25, offset: 0, total: 1 } + }); + }); + + it("never passes a caller-supplied userId to the repository", async () => { + const { controller, user, scopedRepository } = setup(); + scopedRepository.findByUserPaginated.mockResolvedValue({ transactions: [], total: 0 }); + + await controller.listTransactions({ limit: 10, offset: 5 }); + + expect(scopedRepository.findByUserPaginated).toHaveBeenCalledWith({ userId: user.id, limit: 10, offset: 5 }); + }); + }); + + describe("topUp error codes", () => { + it("rejects a disabled deployment with the X402_DISABLED code", async () => { + const { controller } = setup({ isEnabled: false }); + + const error = await controller.topUp(mock(), 25).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).status).toBe(404); + expect((error as HttpError).data).toEqual({ errorCode: X402_ERROR_CODES.X402_DISABLED }); + }); + + it("rejects an out-of-bounds amount with the AMOUNT_OUT_OF_BOUNDS code", async () => { + const { controller } = setup(); + + const error = await controller.topUp(mock(), 10_000).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).status).toBe(400); + expect((error as HttpError).data).toEqual({ errorCode: X402_ERROR_CODES.AMOUNT_OUT_OF_BOUNDS }); + }); + }); + + function setup({ isEnabled = true }: { isEnabled?: boolean } = {}) { + const user = createUser(); + const ability = mock(); + const authService = mock({ currentUser: user, ability, isAuthenticated: true }); + container.registerInstance(AuthService, authService); + + const config = mock(); + Object.assign(config, { X402_MIN_TOP_UP_USD: 1, X402_MAX_TOP_UP_USD: 1000 }); + + const x402Service = mock({ isEnabled }); + + const scopedRepository = mock(); + const x402TransactionRepository = mock(); + x402TransactionRepository.accessibleBy.mockReturnValue(scopedRepository); + + const controller = new X402Controller(config, x402Service, x402TransactionRepository, authService); + + return { controller, user, ability, authService, config, x402Service, x402TransactionRepository, scopedRepository }; + } + + function createTransaction(overrides: Partial = {}): X402TransactionOutput { + return { + id: faker.string.uuid(), + userId: faker.string.uuid(), + status: "succeeded", + amount: 2500, + currency: "usd", + network: "eip155:8453", + asset: "0xusdc", + paymentHash: faker.string.alphanumeric(64), + payerAddress: "0xpayer", + settlementTxHash: "0xsettlement", + errorMessage: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides + }; + } +}); diff --git a/apps/api/src/billing/controllers/x402/x402.controller.ts b/apps/api/src/billing/controllers/x402/x402.controller.ts new file mode 100644 index 0000000000..91e7fdb446 --- /dev/null +++ b/apps/api/src/billing/controllers/x402/x402.controller.ts @@ -0,0 +1,98 @@ +import type { HTTPRequestContext } from "@x402/core/server"; +import createError from "http-errors"; +import { singleton } from "tsyringe"; + +import { AuthService, Protected } from "@src/auth/services/auth.service"; +import type { X402TransactionListQuery, X402TransactionListResponse } from "@src/billing/http-schemas/x402.schema"; +import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { X402TransactionRepository } from "@src/billing/repositories/x402-transaction/x402-transaction.repository"; +import type { X402DeployInput, X402DeployProcessResult, X402DiscoveryResult, X402TopUpProcessResult } from "@src/billing/services/x402/x402.service"; +import { X402Service } from "@src/billing/services/x402/x402.service"; +import { X402_ERROR_CODES } from "@src/billing/services/x402/x402-error-codes"; + +@singleton() +export class X402Controller { + constructor( + @InjectBillingConfig() private readonly config: BillingConfig, + private readonly x402Service: X402Service, + private readonly x402TransactionRepository: X402TransactionRepository, + private readonly authService: AuthService + ) {} + + @Protected([{ action: "create", subject: "X402Payment" }]) + async topUp(context: HTTPRequestContext, amountUsd: number): Promise { + if (!this.x402Service.isEnabled) { + throw createError(404, "x402 payments are not enabled", { data: { errorCode: X402_ERROR_CODES.X402_DISABLED } }); + } + + if (amountUsd < this.config.X402_MIN_TOP_UP_USD || amountUsd > this.config.X402_MAX_TOP_UP_USD) { + throw createError(400, `Top-up amount must be between ${this.config.X402_MIN_TOP_UP_USD} and ${this.config.X402_MAX_TOP_UP_USD} USD`, { + data: { errorCode: X402_ERROR_CODES.AMOUNT_OUT_OF_BOUNDS } + }); + } + + const { currentUser } = this.authService; + + return await this.x402Service.processTopUp(context, currentUser.id, amountUsd); + } + + // Requires `sign UserWallet` — the same ability normal deployment creation enforces + // (DeploymentController.create) — so the x402-paid path cannot bypass wallet-signing authorization, + // plus `create X402Payment` for the payment itself. Applies identically to bearer and api-key auth. + @Protected([ + { action: "sign", subject: "UserWallet" }, + { action: "create", subject: "X402Payment" } + ]) + async deploy(context: HTTPRequestContext, input: X402DeployInput): Promise { + if (!this.x402Service.isEnabled) { + throw createError(404, "x402 payments are not enabled", { data: { errorCode: X402_ERROR_CODES.X402_DISABLED } }); + } + + if (input.deposit < this.config.X402_MIN_DEPLOY_USD || input.deposit > this.config.X402_MAX_DEPLOY_USD) { + throw createError(400, `Deploy deposit must be between ${this.config.X402_MIN_DEPLOY_USD} and ${this.config.X402_MAX_DEPLOY_USD} USD`, { + data: { errorCode: X402_ERROR_CODES.AMOUNT_OUT_OF_BOUNDS } + }); + } + + const { currentUser } = this.authService; + + return await this.x402Service.processDeploy(context, currentUser.id, input); + } + + @Protected([{ action: "read", subject: "X402Payment" }]) + async listTransactions(query: X402TransactionListQuery): Promise { + const { currentUser, ability } = this.authService; + + const { transactions, total } = await this.x402TransactionRepository + .accessibleBy(ability, "read") + .findByUserPaginated({ userId: currentUser.id, limit: query.limit, offset: query.offset }); + + return { + data: transactions.map(transaction => ({ + transactionId: transaction.id, + status: transaction.status, + amountUsdCents: transaction.amount, + currency: transaction.currency, + network: transaction.network, + asset: transaction.asset, + settlementTxHash: transaction.settlementTxHash, + payerAddress: transaction.payerAddress, + createdAt: transaction.createdAt.toISOString() + })), + pagination: { + limit: query.limit, + offset: query.offset, + total + } + }; + } + + // Public discovery document; still 404s when x402 is disabled so agents don't discover a dead route. + getDiscovery(): X402DiscoveryResult { + if (!this.x402Service.isEnabled) { + throw createError(404, "x402 payments are not enabled", { data: { errorCode: X402_ERROR_CODES.X402_DISABLED } }); + } + + return this.x402Service.getDiscovery(); + } +} diff --git a/apps/api/src/billing/events/x402-reconcile-settled.ts b/apps/api/src/billing/events/x402-reconcile-settled.ts new file mode 100644 index 0000000000..ba401e56d2 --- /dev/null +++ b/apps/api/src/billing/events/x402-reconcile-settled.ts @@ -0,0 +1,14 @@ +import type { Job } from "@src/core"; +import { JOB_NAME } from "@src/core"; + +/** + * Recurring background job that re-drives crediting for x402 transactions stranded in `settled` + * (payment captured on-chain but wallet credit never applied). It carries no payload — each run + * scans the whole backlog. + */ +export class X402ReconcileSettled implements Job { + static readonly [JOB_NAME] = "x402-reconcile-settled"; + public readonly name = X402ReconcileSettled[JOB_NAME]; + public readonly version = 1; + public readonly data: Record = {}; +} diff --git a/apps/api/src/billing/http-schemas/x402.schema.ts b/apps/api/src/billing/http-schemas/x402.schema.ts new file mode 100644 index 0000000000..3cae6343e5 --- /dev/null +++ b/apps/api/src/billing/http-schemas/x402.schema.ts @@ -0,0 +1,150 @@ +import { z } from "@hono/zod-openapi"; + +import { SignTxResponseOutputSchema } from "@src/billing/http-schemas/tx.schema"; + +export const X402TopUpQuerySchema = z.object({ + amount: z.coerce.number().positive().openapi({ + description: "Top-up amount in USD (e.g. 25 or 25.5). Bounds are configured server-side.", + example: 25 + }) +}); + +export const X402TopUpResponseSchema = z.object({ + data: z.object({ + transactionId: z.string(), + amountUsdCents: z.number(), + network: z.string(), + settlementTxHash: z.string(), + payerAddress: z.string().optional() + }) +}); + +export const X402PaymentRequiredResponseSchema = z + .object({ + x402Version: z.number(), + error: z.string().optional(), + code: z.string().optional().openapi({ + description: "Stable machine-readable error code (e.g. PAYMENT_REQUIRED, PAYMENT_INVALID)", + example: "PAYMENT_REQUIRED" + }), + accepts: z.array( + z + .object({ + scheme: z.string(), + network: z.string(), + asset: z.string(), + amount: z.string(), + payTo: z.string(), + maxTimeoutSeconds: z.number() + }) + .passthrough() + ) + }) + .passthrough() + .openapi({ + description: "x402 payment-required body: retry the request with an X-PAYMENT header satisfying one of the accepted payment requirements" + }); + +export const X402DeployRequestSchema = z.object({ + sdl: z.string().openapi({ description: "Akash SDL (YAML) describing the deployment to create" }), + deposit: z.coerce.number().positive().openapi({ + description: "Deposit in USD that is both paid via x402 and used to fund the deployment. Bounds are configured server-side.", + example: 5 + }) +}); + +export const X402DeployResponseSchema = z.object({ + data: z.object({ + transactionId: z.string(), + amountUsdCents: z.number(), + network: z.string(), + settlementTxHash: z.string(), + payerAddress: z.string().optional(), + deploymentDseq: z.string(), + manifest: z.string(), + signTx: SignTxResponseOutputSchema.shape.data + }) +}); + +export const X402DeployFailedResponseSchema = z + .object({ + error: z.string(), + code: z.literal("DEPLOY_FAILED_FUNDS_CREDITED"), + message: z.string(), + transactionId: z.string(), + amountUsdCents: z.number(), + settlementTxHash: z.string() + }) + .openapi({ + description: + "The USDC payment settled and your Console balance was credited, but the deployment could not be created. " + + "The funds remain spendable in your Console balance; no on-chain reversal is attempted. Retry deployment via POST /v1/deployments." + }); + +export const X402DiscoveryResponseSchema = z + .object({ + x402Version: z.number(), + resources: z.array( + z.object({ + resource: z.string().openapi({ description: "The protected route, as METHOD PATH (e.g. 'POST /v1/x402/top-up')" }), + description: z.string(), + mimeType: z.string(), + accepts: z.array( + z.object({ + scheme: z.string(), + network: z.string().openapi({ description: "CAIP-2 settlement network id (e.g. eip155:8453)" }), + payTo: z.string(), + currency: z.string(), + minAmountUsd: z.number(), + maxAmountUsd: z.number(), + maxTimeoutSeconds: z.number() + }) + ) + }) + ) + }) + .openapi({ + description: "Public x402 discovery document: the list of payable resources and their accepted payment terms, mirroring the accepts a 402 response returns." + }); + +export type X402TopUpQuery = z.infer; +export type X402TopUpResponse = z.infer; +export type X402DeployRequest = z.infer; +export type X402DeployResponse = z.infer; + +export const X402TransactionListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(25).openapi({ + description: "Maximum number of transactions to return (1-100).", + example: 25 + }), + offset: z.coerce.number().int().min(0).default(0).openapi({ + description: "Number of transactions to skip for pagination.", + example: 0 + }) +}); + +export const X402TransactionSchema = z.object({ + transactionId: z.string().openapi({ description: "Console transaction id (use to reconcile the top-up)." }), + status: z.enum(["pending", "settled", "succeeded", "failed"]).openapi({ description: "Lifecycle status of the payment." }), + amountUsdCents: z.number().openapi({ description: "Credited amount in USD cents." }), + currency: z.string().openapi({ description: "ISO currency of the credited amount." }), + network: z.string().openapi({ description: "CAIP-2 network id the payment settled on (e.g. eip155:8453)." }), + asset: z.string().openapi({ description: "Settlement asset contract address (USDC)." }), + settlementTxHash: z.string().nullable().openapi({ description: "On-chain settlement transaction hash, once settled." }), + payerAddress: z.string().nullable().openapi({ description: "Wallet address that funded the payment." }), + createdAt: z.string().openapi({ description: "ISO-8601 creation timestamp." }) +}); + +export const X402TransactionListResponseSchema = z.object({ + data: z.array(X402TransactionSchema), + pagination: z.object({ + limit: z.number(), + offset: z.number(), + total: z.number().openapi({ description: "Total number of transactions belonging to the caller." }) + }) +}); + +export type X402TransactionListQuery = z.infer; +export type X402TransactionDto = z.infer; +export type X402TransactionListResponse = z.infer; +export type X402DiscoveryResponse = z.infer; diff --git a/apps/api/src/billing/model-schemas/index.ts b/apps/api/src/billing/model-schemas/index.ts index 8a6f91dd9f..14b8423f2b 100644 --- a/apps/api/src/billing/model-schemas/index.ts +++ b/apps/api/src/billing/model-schemas/index.ts @@ -2,3 +2,4 @@ export * from "./user-wallet/user-wallet.schema"; export * from "./payment-method/payment-method.schema"; export * from "./stripe-transaction/stripe-transaction.schema"; export * from "@src/billing/model-schemas/wallet-setting/wallet-setting.schema"; +export * from "./x402-transaction/x402-transaction.schema"; diff --git a/apps/api/src/billing/model-schemas/x402-transaction/x402-transaction.schema.ts b/apps/api/src/billing/model-schemas/x402-transaction/x402-transaction.schema.ts new file mode 100644 index 0000000000..584b3b6b0b --- /dev/null +++ b/apps/api/src/billing/model-schemas/x402-transaction/x402-transaction.schema.ts @@ -0,0 +1,55 @@ +import { relations, sql } from "drizzle-orm"; +import { boolean, index, integer, pgEnum, pgTable, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core"; + +import { Users } from "@src/user/model-schemas"; + +export const x402TransactionStatusEnum = pgEnum("x402_transaction_status", [ + "pending", + // Payment settled on-chain but wallet credit not yet applied (crash-recovery window) + "settled", + "succeeded", + "failed" +]); + +export const X402Transactions = pgTable( + "x402_transactions", + { + id: uuid("id") + .primaryKey() + .notNull() + .default(sql`uuid_generate_v4()`), + userId: uuid("user_id") + .references(() => Users.id, { onDelete: "cascade" }) + .notNull(), + status: x402TransactionStatusEnum("status").notNull().default("pending"), + amount: integer("amount").notNull(), // Amount in cents + currency: varchar("currency", { length: 3 }).notNull().default("usd"), + network: varchar("network", { length: 100 }).notNull(), // CAIP-2 network id, e.g. eip155:8453 + asset: varchar("asset", { length: 255 }).notNull(), // Settlement asset address, e.g. USDC contract + // SHA-256 of the signed payment payload — makes crediting idempotent per payment + paymentHash: varchar("payment_hash", { length: 64 }).notNull(), + payerAddress: varchar("payer_address", { length: 255 }), + settlementTxHash: varchar("settlement_tx_hash", { length: 255 }), + errorMessage: varchar("error_message", { length: 1000 }), + // Dseq of the deployment funded by this payment (pay-per-deploy flow). Null for plain top-ups. + deploymentDseq: varchar("deployment_dseq", { length: 100 }), + // True when the payment settled and credited but the subsequent deployment creation failed. + // Funds remain in the user's Console balance; no on-chain reversal is ever attempted. + deployFailed: boolean("deploy_failed").notNull().default(false), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull() + }, + table => ({ + paymentHashUnique: uniqueIndex("x402_transactions_payment_hash_unique").on(table.paymentHash), + userIdIdx: index("x402_transactions_user_id_idx").on(table.userId), + statusIdx: index("x402_transactions_status_idx").on(table.status), + userIdCreatedAtIdx: index("x402_transactions_user_id_created_at_idx").on(table.userId, table.createdAt) + }) +); + +export const X402TransactionsRelations = relations(X402Transactions, ({ one }) => ({ + user: one(Users, { + fields: [X402Transactions.userId], + references: [Users.id] + }) +})); diff --git a/apps/api/src/billing/repositories/index.ts b/apps/api/src/billing/repositories/index.ts index f324c8ff17..80e7c93da3 100644 --- a/apps/api/src/billing/repositories/index.ts +++ b/apps/api/src/billing/repositories/index.ts @@ -2,3 +2,4 @@ export * from "./user-wallet/user-wallet.repository"; export * from "./payment-method/payment-method.repository"; export * from "./stripe-transaction/stripe-transaction.repository"; export * from "./wallet-settings/wallet-settings.repository"; +export * from "./x402-transaction/x402-transaction.repository"; diff --git a/apps/api/src/billing/repositories/x402-transaction/x402-transaction.repository.ts b/apps/api/src/billing/repositories/x402-transaction/x402-transaction.repository.ts new file mode 100644 index 0000000000..89c7965b0a --- /dev/null +++ b/apps/api/src/billing/repositories/x402-transaction/x402-transaction.repository.ts @@ -0,0 +1,146 @@ +import { and, asc, desc, eq, gte, lt, ne, sql } from "drizzle-orm"; +import { singleton } from "tsyringe"; + +import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers"; +import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; +import { TxService } from "@src/core/services"; + +type Table = ApiPgTables["X402Transactions"]; +export type X402TransactionInput = Table["$inferInsert"]; +export type X402TransactionOutput = Table["$inferSelect"]; + +export type X402TransactionStatus = X402TransactionOutput["status"]; + +export interface FindByUserPaginatedOptions { + userId: string; + limit: number; + offset: number; +} + +export interface PaginatedX402Transactions { + transactions: X402TransactionOutput[]; + total: number; +} + +@singleton() +export class X402TransactionRepository extends BaseRepository { + constructor( + @InjectPg() protected readonly pg: ApiPgDatabase, + @InjectPgTable("X402Transactions") protected readonly table: Table, + protected readonly txManager: TxService + ) { + super(pg, table, txManager, "X402Transaction", "X402Transactions"); + } + + accessibleBy(...abilityParams: AbilityParams) { + return new X402TransactionRepository(this.pg, this.table, this.txManager).withAbility(...abilityParams) as this; + } + + async findByPaymentHash(paymentHash: string): Promise { + const item = await this.cursor.query.X402Transactions.findFirst({ + where: this.whereAccessibleBy(eq(this.table.paymentHash, paymentHash)) + }); + + return item ? this.toOutput(item) : undefined; + } + + /** + * Atomically transitions a transaction from `settled` to `succeeded`. + * + * The conditional `WHERE status = 'settled'` makes the credit exactly-once under concurrency: + * only the caller whose UPDATE actually affects a row (rowCount 1) may go on to credit the wallet. + * Any concurrent retry or the reconcile job observes rowCount 0 and must not credit. + */ + async markSettledAsSucceeded(id: X402TransactionOutput["id"]): Promise { + const updated = await this.cursor + .update(this.table) + .set({ status: "succeeded", updatedAt: sql`now()` }) + .where(and(eq(this.table.id, id), eq(this.table.status, "settled"))) + .returning({ id: this.table.id }); + + return updated.length === 1; + } + + /** + * Returns transactions stuck in `settled` (payment captured on-chain but wallet not yet credited) + * whose last update is older than `olderThan`, oldest first — the reconcile job's work backlog. + */ + async findStaleSettled(olderThan: Date, limit: number): Promise { + const items = await this.cursor.query.X402Transactions.findMany({ + where: this.whereAccessibleBy(and(eq(this.table.status, "settled"), lt(this.table.updatedAt, olderThan))), + orderBy: asc(this.table.updatedAt), + limit + }); + + return this.toOutputList(items); + } + + /** Records the dseq of the deployment funded by this payment (pay-per-deploy success). */ + async linkDeployment(id: X402TransactionOutput["id"], deploymentDseq: string): Promise { + await this.updateById(id, { deploymentDseq }); + } + + /** + * Flags a paid-and-credited transaction whose deployment creation failed. Funds stay credited to + * the user's Console balance; the flag + note record that no deployment was produced. + */ + async markDeployFailed(id: X402TransactionOutput["id"], errorMessage: string): Promise { + await this.updateById(id, { deployFailed: true, errorMessage }); + } + + /** + * Count of a user's non-failed x402 transactions created since `since` — the per-window request + * count the rate limit is enforced against. + */ + async countByUserSince(userId: X402TransactionOutput["userId"], since: Date): Promise { + const [row] = await this.cursor + .select({ count: sql`count(*)::int` }) + .from(this.table) + .where(and(eq(this.table.userId, userId), gte(this.table.createdAt, since), ne(this.table.status, "failed"))); + + return row?.count ?? 0; + } + + /** + * Sum (in cents) of a user's non-failed x402 transaction amounts created since `since` — the spend + * the per-window cost ceiling is enforced against. + */ + async sumAmountByUserSince(userId: X402TransactionOutput["userId"], since: Date): Promise { + const [row] = await this.cursor + .select({ total: sql`coalesce(sum(${this.table.amount}), 0)::int` }) + .from(this.table) + .where(and(eq(this.table.userId, userId), gte(this.table.createdAt, since), ne(this.table.status, "failed"))); + + return row?.total ?? 0; + } + + /** + * Lists a single user's transactions, newest first. The `userId` predicate is applied + * explicitly (in addition to any CASL ability) so a caller can never read another user's + * rows even if the ability for the X402Payment subject is unconditional. + */ + async findByUserPaginated(options: FindByUserPaginatedOptions): Promise { + const where = this.whereAccessibleBy(eq(this.table.userId, options.userId)); + + const [transactions, total] = await Promise.all([ + this.cursor.query.X402Transactions.findMany({ + where, + orderBy: [desc(this.table.createdAt)], + limit: options.limit, + offset: options.offset + }), + this.countByUser(options.userId) + ]); + + return { transactions: this.toOutputList(transactions), total }; + } + + private async countByUser(userId: string): Promise { + const items = await this.cursor.query.X402Transactions.findMany({ + where: this.whereAccessibleBy(eq(this.table.userId, userId)), + columns: { id: true } + }); + + return items.length; + } +} diff --git a/apps/api/src/billing/routes/index.ts b/apps/api/src/billing/routes/index.ts index 8c06433635..552e4c1c39 100644 --- a/apps/api/src/billing/routes/index.ts +++ b/apps/api/src/billing/routes/index.ts @@ -10,3 +10,4 @@ export * from "@src/billing/routes/stripe-payment-methods/stripe-payment-methods export * from "@src/billing/routes/get-balances/get-balances.router"; export * from "@src/billing/routes/usage/usage.router"; export * from "@src/billing/routes/wallet-settings/wallet-settings.router"; +export * from "@src/billing/routes/x402/x402.router"; diff --git a/apps/api/src/billing/routes/x402/x402.router.ts b/apps/api/src/billing/routes/x402/x402.router.ts new file mode 100644 index 0000000000..b7f241752e --- /dev/null +++ b/apps/api/src/billing/routes/x402/x402.router.ts @@ -0,0 +1,278 @@ +import { HonoAdapter } from "@x402/hono"; +import { container } from "tsyringe"; + +import { X402Controller } from "@src/billing/controllers/x402/x402.controller"; +import { + X402DeployFailedResponseSchema, + X402DeployRequestSchema, + X402DeployResponseSchema, + X402DiscoveryResponseSchema, + X402PaymentRequiredResponseSchema, + X402TopUpQuerySchema, + X402TopUpResponseSchema, + X402TransactionListQuerySchema, + X402TransactionListResponseSchema +} from "@src/billing/http-schemas/x402.schema"; +import { X402_ERROR_CODES } from "@src/billing/services/x402/x402-error-codes"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_BEARER_OR_API_KEY, SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; + +export const x402Router = new OpenApiHonoHandler(); + +const topUpRoute = createRoute({ + method: "post", + operationId: "createUsdcTopUp", + path: "/v1/x402/top-up", + summary: "Top up Console credits with a USDC payment (x402)", + description: + "Pay-per-request wallet top-up using the x402 payment protocol. " + + "Call without an X-PAYMENT header to receive a 402 response describing the accepted payment (network, asset, amount, payTo). " + + "Retry with a signed X-PAYMENT header to settle the payment on-chain and credit your Console balance. " + + "The amount query parameter is the top-up size in USD.", + tags: ["Payment"], + security: SECURITY_BEARER_OR_API_KEY, + request: { + query: X402TopUpQuerySchema + }, + responses: { + 200: { + description: "Payment settled and Console balance credited", + content: { + "application/json": { + schema: X402TopUpResponseSchema + } + } + }, + 402: { + description: "Payment required or payment invalid/failed to settle", + content: { + "application/json": { + schema: X402PaymentRequiredResponseSchema + } + } + }, + 409: { + description: "Payment already used for a previous top-up" + } + } +}); +x402Router.openapi(topUpRoute, async function x402TopUp(c) { + const { amount } = c.req.valid("query"); + const adapter = new HonoAdapter(c); + const result = await container.resolve(X402Controller).topUp( + { + adapter, + path: c.req.path, + method: c.req.method, + paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment") + }, + amount + ); + + switch (result.type) { + case "payment-required": + case "payment-rejected": { + Object.entries(result.response.headers).forEach(([key, value]) => c.header(key, value)); + if (result.response.isHtml) { + return c.html(String(result.response.body ?? ""), result.response.status as 402); + } + const body = result.response.body && typeof result.response.body === "object" ? result.response.body : {}; + // Surface a stable machine-readable code alongside the x402 SDK's payment-required body. + return c.json({ ...body, code: result.code }, result.response.status as 402); + } + case "duplicate-payment": + return c.json( + { + error: "Conflict", + code: X402_ERROR_CODES.DUPLICATE_PAYMENT, + message: "This payment was already used for a top-up", + transactionId: result.transactionId + }, + 409 + ); + case "success": { + Object.entries(result.headers).forEach(([key, value]) => c.header(key, value)); + return c.json({ data: result.data }, 200); + } + } +}); + +const deployRoute = createRoute({ + method: "post", + operationId: "createUsdcDeployment", + path: "/v1/x402/deploy", + summary: "Create and fund an Akash deployment with a single USDC payment (x402)", + description: + "Pay-per-deploy: one x402-paid call settles USDC, credits your Console balance, and creates a funded deployment — no prior balance required. " + + "Call without an X-PAYMENT header (but with the sdl + deposit body) to receive a 402 describing the accepted payment. " + + "Retry with a signed X-PAYMENT header to settle on-chain, credit your balance, and create the deployment. " + + "If settlement succeeds but deployment creation fails, the funds stay in your Console balance and a 502 with code DEPLOY_FAILED_FUNDS_CREDITED is returned.", + tags: ["Payment"], + security: SECURITY_BEARER_OR_API_KEY, + request: { + body: { + content: { + "application/json": { + schema: X402DeployRequestSchema + } + } + } + }, + responses: { + 200: { + description: "Payment settled, Console balance credited, and deployment created", + content: { + "application/json": { + schema: X402DeployResponseSchema + } + } + }, + 402: { + description: "Payment required, payment invalid/failed to settle, or per-user cost ceiling exceeded", + content: { + "application/json": { + schema: X402PaymentRequiredResponseSchema + } + } + }, + 409: { + description: "Payment already used for a previous deployment" + }, + 429: { + description: "Per-user rate limit exceeded for x402-paid requests" + }, + 502: { + description: "Payment settled and balance credited, but deployment creation failed (funds remain spendable)", + content: { + "application/json": { + schema: X402DeployFailedResponseSchema + } + } + } + } +}); + +const listTransactionsRoute = createRoute({ + method: "get", + operationId: "listUsdcTopUps", + path: "/v1/x402/transactions", + summary: "List your x402 USDC top-up transactions", + description: + "Returns the authenticated caller's x402 top-up history, newest first. " + + "Each row exposes the transaction status, credited amount, network, settlement transaction hash and creation time. " + + "Only the caller's own transactions are ever returned.", + tags: ["Payment"], + security: SECURITY_BEARER_OR_API_KEY, + request: { + query: X402TransactionListQuerySchema + }, + responses: { + 200: { + description: "Paginated list of the caller's x402 top-up transactions", + content: { + "application/json": { + schema: X402TransactionListResponseSchema + } + } + } + } +}); +x402Router.openapi(deployRoute, async function x402Deploy(c) { + const body = c.req.valid("json"); + const adapter = new HonoAdapter(c); + const result = await container.resolve(X402Controller).deploy( + { + adapter, + path: c.req.path, + method: c.req.method, + paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment") + }, + { sdl: body.sdl, deposit: body.deposit } + ); + + switch (result.type) { + case "payment-required": + case "payment-rejected": { + Object.entries(result.response.headers).forEach(([key, value]) => c.header(key, value)); + if (result.response.isHtml) { + return c.html(String(result.response.body ?? ""), result.response.status as 402); + } + const body = result.response.body && typeof result.response.body === "object" ? result.response.body : {}; + // Surface a stable machine-readable code alongside the x402 SDK's payment-required body. + return c.json({ ...body, code: result.code }, result.response.status as 402); + } + case "cost-ceiling-exceeded": + return c.json({ error: "Payment Required", message: "x402 cost ceiling exceeded for this user", ceilingUsdCents: result.ceilingUsdCents }, 402); + case "rate-limited": + c.header("Retry-After", String(result.retryAfterSeconds)); + return c.json({ error: "Too Many Requests", message: "x402 rate limit exceeded for this user" }, 429); + case "duplicate-payment": + return c.json( + { + error: "Conflict", + code: X402_ERROR_CODES.DUPLICATE_PAYMENT, + message: "This payment was already used for a deployment", + transactionId: result.transactionId + }, + 409 + ); + case "deploy-failed": { + Object.entries(result.headers).forEach(([key, value]) => c.header(key, value)); + return c.json( + { + error: "Bad Gateway", + code: "DEPLOY_FAILED_FUNDS_CREDITED" as const, + message: + "Your payment settled and your Console balance was credited, but the deployment could not be created. " + + "The funds remain in your Console balance; retry deployment via POST /v1/deployments.", + transactionId: result.transactionId, + amountUsdCents: result.amountUsdCents, + settlementTxHash: result.settlementTxHash + }, + 502 + ); + } + case "success": { + Object.entries(result.headers).forEach(([key, value]) => c.header(key, value)); + return c.json({ data: result.data }, 200); + } + } +}); + +x402Router.openapi(listTransactionsRoute, async function x402ListTransactions(c) { + const query = c.req.valid("query"); + const response = await container.resolve(X402Controller).listTransactions(query); + return c.json(response, 200); +}); + +const discoveryRoute = createRoute({ + method: "get", + operationId: "listPayableResources", + path: "/v1/x402/discovery", + summary: "Discover x402 payable resources and their accepted payments", + description: + "Public discovery document listing every x402-protected resource and the payments it accepts " + + "(route, scheme, network, asset/amount bounds, payTo). Mirrors the accepts returned in a 402 response, " + + "derived from the same canonical source, so agents can plan a payment without first triggering a 402.", + tags: ["Payment"], + security: SECURITY_NONE, + request: {}, + responses: { + 200: { + description: "The x402 discovery document", + content: { + "application/json": { + schema: X402DiscoveryResponseSchema + } + } + }, + 404: { + description: "x402 payments are not enabled" + } + } +}); +x402Router.openapi(discoveryRoute, async function x402Discovery(c) { + const result = container.resolve(X402Controller).getDiscovery(); + return c.json(result, 200); +}); diff --git a/apps/api/src/billing/services/x402-reconcile-job/x402-reconcile-job.service.spec.ts b/apps/api/src/billing/services/x402-reconcile-job/x402-reconcile-job.service.spec.ts new file mode 100644 index 0000000000..8084ce8333 --- /dev/null +++ b/apps/api/src/billing/services/x402-reconcile-job/x402-reconcile-job.service.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { BillingConfig } from "@src/billing/providers"; +import { X402ReconcileJobService } from "@src/billing/services/x402-reconcile-job/x402-reconcile-job.service"; +import type { JobQueueService } from "@src/core"; +import type { LoggerService } from "@src/core/providers/logging.provider"; + +describe(X402ReconcileJobService.name, () => { + describe("scheduleInitial", () => { + it("seeds the recurring job when x402 is enabled", async () => { + const { service, jobQueueService } = setup(); + + await service.scheduleInitial(); + + expect(jobQueueService.cancelCreatedBy).toHaveBeenCalledWith({ name: "x402-reconcile-settled", singletonKey: "x402-reconcile-settled" }); + expect(jobQueueService.enqueue).toHaveBeenCalledTimes(1); + const [, options] = jobQueueService.enqueue.mock.calls[0]; + expect(options).toMatchObject({ singletonKey: "x402-reconcile-settled", startAfter: expect.any(String) }); + }); + + it("does not seed the job when x402 is disabled", async () => { + const { service, jobQueueService } = setup({ X402_ENABLED: "false" }); + + await service.scheduleInitial(); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it("does not seed the job when the pay-to address is missing", async () => { + const { service, jobQueueService } = setup({ X402_PAY_TO_ADDRESS: undefined }); + + await service.scheduleInitial(); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + }); + + describe("schedule", () => { + it("cancels any pending duplicate before enqueuing the next run", async () => { + const { service, jobQueueService } = setup(); + + await service.schedule({ startAfterSeconds: 60 }); + + expect(jobQueueService.cancelCreatedBy).toHaveBeenCalledWith({ name: "x402-reconcile-settled", singletonKey: "x402-reconcile-settled" }); + expect(jobQueueService.enqueue).toHaveBeenCalledTimes(1); + }); + }); + + function setup(configOverrides: Partial = {}) { + const config = mock(); + Object.assign(config, { + X402_ENABLED: "true", + X402_PAY_TO_ADDRESS: "0x1111111111111111111111111111111111111111", + X402_RECONCILE_INTERVAL_SECONDS: 300, + ...configOverrides + }); + + const jobQueueService = mock(); + const logger = mock(); + + const service = new X402ReconcileJobService(config, jobQueueService, logger); + + return { service, config, jobQueueService, logger }; + } +}); diff --git a/apps/api/src/billing/services/x402-reconcile-job/x402-reconcile-job.service.ts b/apps/api/src/billing/services/x402-reconcile-job/x402-reconcile-job.service.ts new file mode 100644 index 0000000000..045d8c307b --- /dev/null +++ b/apps/api/src/billing/services/x402-reconcile-job/x402-reconcile-job.service.ts @@ -0,0 +1,53 @@ +import { addSeconds } from "date-fns"; +import { singleton } from "tsyringe"; + +import { X402ReconcileSettled } from "@src/billing/events/x402-reconcile-settled"; +import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { JOB_NAME, JobQueueService } from "@src/core"; +import { LoggerService } from "@src/core/providers/logging.provider"; + +const RECONCILE_QUEUE = X402ReconcileSettled[JOB_NAME]; + +@singleton() +export class X402ReconcileJobService { + constructor( + @InjectBillingConfig() private readonly config: BillingConfig, + private readonly jobQueueService: JobQueueService, + private readonly logger: LoggerService + ) { + this.logger.setContext(X402ReconcileJobService.name); + } + + private get isEnabled(): boolean { + return this.config.X402_ENABLED === "true" && !!this.config.X402_PAY_TO_ADDRESS; + } + + /** + * Seeds the recurring reconcile loop on app start. No-op when x402 is disabled so we never run a + * job that has nothing to reconcile. + */ + async scheduleInitial(): Promise { + if (!this.isEnabled) { + this.logger.info({ event: "X402_RECONCILE_SCHEDULE_SKIPPED", reason: "x402_disabled" }); + return; + } + + await this.schedule({ startAfterSeconds: this.config.X402_RECONCILE_INTERVAL_SECONDS }); + } + + /** + * Enqueues the next reconcile run. Any not-yet-started duplicate is cancelled first so restarts and + * self-rescheduling from the handler never pile up more than one pending job. + */ + async schedule(options?: { startAfterSeconds?: number }): Promise { + const singletonKey = RECONCILE_QUEUE; + await this.jobQueueService.cancelCreatedBy({ name: RECONCILE_QUEUE, singletonKey }); + + const startAfterSeconds = options?.startAfterSeconds ?? this.config.X402_RECONCILE_INTERVAL_SECONDS; + + return this.jobQueueService.enqueue(new X402ReconcileSettled(), { + singletonKey, + startAfter: addSeconds(new Date(), startAfterSeconds).toISOString() + }); + } +} diff --git a/apps/api/src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.spec.ts b/apps/api/src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.spec.ts new file mode 100644 index 0000000000..e82ea6061b --- /dev/null +++ b/apps/api/src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { X402ReconcileSettled } from "@src/billing/events/x402-reconcile-settled"; +import type { X402Service } from "@src/billing/services/x402/x402.service"; +import type { X402ReconcileJobService } from "@src/billing/services/x402-reconcile-job/x402-reconcile-job.service"; +import { X402ReconcileSettledHandler } from "@src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler"; +import { JOB_NAME } from "@src/core"; + +describe(X402ReconcileSettledHandler.name, () => { + it("accepts the x402-reconcile-settled queue as a singleton", () => { + const { handler } = setup(); + + expect(handler.accepts[JOB_NAME]).toBe("x402-reconcile-settled"); + expect(handler.policy).toBe("singleton"); + expect(handler.concurrency).toBe(1); + }); + + it("drives the reconcile pass and reschedules the next run", async () => { + const { handler, x402Service, x402ReconcileJobService } = setup(); + x402Service.reconcileStaleSettled.mockResolvedValue({ backlog: 3, credited: 2, failed: 0 }); + + await handler.handle(new X402ReconcileSettled().data); + + expect(x402Service.reconcileStaleSettled).toHaveBeenCalledTimes(1); + expect(x402ReconcileJobService.schedule).toHaveBeenCalledTimes(1); + }); + + it("still reschedules the next run when a pass throws", async () => { + const { handler, x402Service, x402ReconcileJobService } = setup(); + x402Service.reconcileStaleSettled.mockRejectedValue(new Error("scan failed")); + + await expect(handler.handle(new X402ReconcileSettled().data)).rejects.toThrow("scan failed"); + + expect(x402ReconcileJobService.schedule).toHaveBeenCalledTimes(1); + }); + + function setup() { + const x402Service = mock(); + const x402ReconcileJobService = mock(); + const handler = new X402ReconcileSettledHandler(x402Service, x402ReconcileJobService); + + return { handler, x402Service, x402ReconcileJobService }; + } +}); diff --git a/apps/api/src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.ts b/apps/api/src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.ts new file mode 100644 index 0000000000..54394ebb2a --- /dev/null +++ b/apps/api/src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.ts @@ -0,0 +1,33 @@ +import { createOtelLogger } from "@akashnetwork/logging/otel"; +import { singleton } from "tsyringe"; + +import { X402ReconcileSettled } from "@src/billing/events/x402-reconcile-settled"; +import { X402Service } from "@src/billing/services/x402/x402.service"; +import { X402ReconcileJobService } from "@src/billing/services/x402-reconcile-job/x402-reconcile-job.service"; +import type { JobHandler, JobPayload } from "@src/core"; + +@singleton() +export class X402ReconcileSettledHandler implements JobHandler { + public readonly accepts = X402ReconcileSettled; + + public readonly concurrency = 1; + + public readonly policy = "singleton"; + + private readonly logger = createOtelLogger({ context: X402ReconcileSettledHandler.name }); + + constructor( + private readonly x402Service: X402Service, + private readonly x402ReconcileJobService: X402ReconcileJobService + ) {} + + async handle(_payload: JobPayload): Promise { + try { + const result = await this.x402Service.reconcileStaleSettled(); + this.logger.info({ event: "X402_RECONCILE_JOB_DONE", ...result }); + } finally { + // Self-reschedule so the loop keeps running even if a single pass throws. + await this.x402ReconcileJobService.schedule(); + } + } +} diff --git a/apps/api/src/billing/services/x402/x402-error-codes.ts b/apps/api/src/billing/services/x402/x402-error-codes.ts new file mode 100644 index 0000000000..b07c0cf61f --- /dev/null +++ b/apps/api/src/billing/services/x402/x402-error-codes.ts @@ -0,0 +1,31 @@ +/** + * Stable, machine-readable error codes emitted in x402 error bodies (400/402/404/409). + * These are part of the public API contract — see doc/x402/USAGE.md. Do not rename or + * repurpose an existing code; add a new one instead. + */ +export const X402_ERROR_CODES = { + /** 404 — x402 payments are not enabled on this deployment. */ + X402_DISABLED: "X402_DISABLED", + /** 400 — requested top-up amount is outside the configured min/max bounds. */ + AMOUNT_OUT_OF_BOUNDS: "AMOUNT_OUT_OF_BOUNDS", + /** 402 — no payment attached yet; the body carries the accepted payment requirements. */ + PAYMENT_REQUIRED: "PAYMENT_REQUIRED", + /** 402 — the attached payment failed verification or on-chain settlement. */ + PAYMENT_INVALID: "PAYMENT_INVALID", + /** 409 — this payment was already used to credit a previous top-up. */ + DUPLICATE_PAYMENT: "DUPLICATE_PAYMENT", + /** 402 — the verified payment's settlement network differs from the configured/advertised network. */ + WRONG_NETWORK: "WRONG_NETWORK", + /** 402 — the payer authorized a different asset than the one the requirement settles. */ + WRONG_ASSET: "WRONG_ASSET", + /** 402 — the payer's authorized amount differs from the requirement amount. */ + AMOUNT_MISMATCH: "AMOUNT_MISMATCH" +} as const; + +export type X402ErrorCode = (typeof X402_ERROR_CODES)[keyof typeof X402_ERROR_CODES]; + +/** + * Pre-settle guardrail rejection codes — the subset of {@link X402_ERROR_CODES} returned when a + * verified payment is rejected before on-chain settlement (see `X402Service.validatePreSettle`). + */ +export type X402ValidationCode = typeof X402_ERROR_CODES.WRONG_NETWORK | typeof X402_ERROR_CODES.WRONG_ASSET | typeof X402_ERROR_CODES.AMOUNT_MISMATCH; diff --git a/apps/api/src/billing/services/x402/x402-http-server-factory.service.ts b/apps/api/src/billing/services/x402/x402-http-server-factory.service.ts new file mode 100644 index 0000000000..c7e8b65e62 --- /dev/null +++ b/apps/api/src/billing/services/x402/x402-http-server-factory.service.ts @@ -0,0 +1,21 @@ +import type { RoutesConfig } from "@x402/core/server"; +import { HTTPFacilitatorClient, x402HTTPResourceServer, x402ResourceServer } from "@x402/core/server"; +import type { Network } from "@x402/core/types"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { singleton } from "tsyringe"; + +export interface X402HttpServerOptions { + facilitatorUrl: string; + network: Network; + routes: RoutesConfig; +} + +@singleton() +export class X402HttpServerFactoryService { + create(options: X402HttpServerOptions): x402HTTPResourceServer { + const facilitatorClient = new HTTPFacilitatorClient({ url: options.facilitatorUrl }); + const resourceServer = new x402ResourceServer(facilitatorClient).register(options.network, new ExactEvmScheme()); + + return new x402HTTPResourceServer(resourceServer, options.routes); + } +} diff --git a/apps/api/src/billing/services/x402/x402.service.spec.ts b/apps/api/src/billing/services/x402/x402.service.spec.ts new file mode 100644 index 0000000000..d28b097117 --- /dev/null +++ b/apps/api/src/billing/services/x402/x402.service.spec.ts @@ -0,0 +1,651 @@ +import type { HTTPRequestContext, PaymentCancellationDispatcher, x402HTTPResourceServer } from "@x402/core/server"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { PostgresError } from "postgres"; +import { container } from "tsyringe"; +import { beforeEach, describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { BillingConfig } from "@src/billing/providers"; +import type { X402TransactionOutput, X402TransactionRepository } from "@src/billing/repositories"; +import type { RefillService } from "@src/billing/services/refill/refill.service"; +import { X402_TOP_UP_ROUTE, X402Service } from "@src/billing/services/x402/x402.service"; +import type { X402HttpServerFactoryService } from "@src/billing/services/x402/x402-http-server-factory.service"; +import { TxService } from "@src/core/services/tx/tx.service"; +import type { CreateDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; +import type { DeploymentWriterService } from "@src/deployment/services/deployment-writer/deployment-writer.service"; + +describe(X402Service.name, () => { + const userId = "test-user-id"; + const amountUsd = 25; + + beforeEach(() => { + const txService = mock(); + txService.transaction.mockImplementation(async cb => await cb()); + container.registerInstance(TxService, txService); + }); + + describe("processTopUp", () => { + it("returns payment instructions when no payment is attached", async () => { + const { service, httpServer } = setup(); + httpServer.processHTTPRequest.mockResolvedValue({ + type: "payment-error", + response: { status: 402, headers: {}, body: { x402Version: 2, accepts: [] } } + }); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(result).toMatchObject({ type: "payment-required", response: { status: 402 } }); + }); + + it("settles the payment, records the transaction and credits the wallet", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const transaction = createTransaction({ status: "pending" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + httpServer.processSettlement.mockResolvedValue({ + success: true, + transaction: "0xsettlementhash", + network: "eip155:8453", + payer: "0xpayer", + headers: { "PAYMENT-RESPONSE": "encoded" }, + requirements: createRequirements() + }); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue({ ...transaction, status: "settled" }); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(x402TransactionRepository.create).toHaveBeenCalledWith({ + userId, + status: "pending", + amount: 2500, + currency: "usd", + network: "eip155:8453", + asset: "0xusdc", + paymentHash: expect.any(String) + }); + expect(httpServer.processSettlement).toHaveBeenCalled(); + expect(x402TransactionRepository.updateById).toHaveBeenCalledWith(transaction.id, { + status: "settled", + settlementTxHash: "0xsettlementhash", + payerAddress: "0xpayer" + }); + expect(x402TransactionRepository.markSettledAsSucceeded).toHaveBeenCalledWith(transaction.id); + expect(refillService.topUpWallet).toHaveBeenCalledWith(transaction.amount, userId, { + payment: { + currency: "usd", + paymentMethodType: "x402", + transactionId: transaction.id + } + }); + expect(result).toMatchObject({ + type: "success", + headers: { "PAYMENT-RESPONSE": "encoded" }, + data: { transactionId: transaction.id, amountUsdCents: 2500, settlementTxHash: "0xsettlementhash" } + }); + }); + + it("marks the transaction failed and returns 402 instructions when settlement fails", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const transaction = createTransaction({ status: "pending" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + httpServer.processSettlement.mockResolvedValue({ + success: false, + transaction: "", + network: "eip155:8453", + errorReason: "insufficient_funds", + errorMessage: "not enough USDC", + headers: {}, + response: { status: 402, headers: {}, body: {} } + }); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockResolvedValue(transaction); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(x402TransactionRepository.updateById).toHaveBeenCalledWith(transaction.id, { + status: "failed", + errorMessage: "not enough USDC" + }); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + expect(result).toMatchObject({ type: "payment-required", response: { status: 402 } }); + }); + + it("rejects a payment that was already used for a successful top-up", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const transaction = createTransaction({ status: "succeeded" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(transaction); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(result).toEqual({ type: "duplicate-payment", transactionId: transaction.id }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + }); + + it("resumes crediting for a payment that settled but was not credited", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const transaction = createTransaction({ status: "settled", settlementTxHash: "0xsettlementhash" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue(transaction); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(x402TransactionRepository.markSettledAsSucceeded).toHaveBeenCalledWith(transaction.id); + expect(refillService.topUpWallet).toHaveBeenCalledWith(transaction.amount, transaction.userId, expect.anything()); + expect(result).toMatchObject({ type: "success", data: { transactionId: transaction.id, settlementTxHash: "0xsettlementhash" } }); + }); + + it("does not double credit when the transaction is already succeeded at credit time", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const transaction = createTransaction({ status: "pending" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + httpServer.processSettlement.mockResolvedValue({ + success: true, + transaction: "0xsettlementhash", + network: "eip155:8453", + headers: {}, + requirements: createRequirements() + }); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue({ ...transaction, status: "succeeded" }); + + await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + }); + }); + + describe("processDeploy (pay-per-deploy)", () => { + it("settles, credits the balance and creates a deployment, linking the dseq on the row", async () => { + const { service, httpServer, x402TransactionRepository, refillService, deploymentWriterService } = setup(); + const transaction = createTransaction({ status: "pending" }); + const deployment = createDeploymentResult(); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + httpServer.processSettlement.mockResolvedValue({ + success: true, + transaction: "0xsettlementhash", + network: "eip155:8453", + payer: "0xpayer", + headers: { "PAYMENT-RESPONSE": "encoded" }, + requirements: createRequirements() + }); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue({ ...transaction, status: "settled" }); + deploymentWriterService.create.mockResolvedValue(deployment); + + const result = await service.processDeploy(createDeployRequestContext(), userId, createDeployInput()); + + // Funding flows through the single refill choke point, with no first-purchase/trial bonus. + expect(refillService.topUpWallet).toHaveBeenCalledWith(transaction.amount, userId, { + payment: { currency: "usd", paymentMethodType: "x402", transactionId: transaction.id } + }); + expect(refillService.topUpWallet).toHaveBeenCalledTimes(1); + expect(deploymentWriterService.create).toHaveBeenCalledWith({ sdl: createDeployInput().sdl, deposit: amountUsd, userId }); + expect(x402TransactionRepository.linkDeployment).toHaveBeenCalledWith(transaction.id, deployment.dseq); + expect(x402TransactionRepository.markDeployFailed).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + type: "success", + headers: { "PAYMENT-RESPONSE": "encoded" }, + data: { transactionId: transaction.id, deploymentDseq: deployment.dseq, settlementTxHash: "0xsettlementhash", manifest: deployment.manifest } + }); + }); + + it("leaves funds credited and flags the row when deployment creation fails after settlement", async () => { + const { service, httpServer, x402TransactionRepository, refillService, deploymentWriterService } = setup(); + const transaction = createTransaction({ status: "pending" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + httpServer.processSettlement.mockResolvedValue({ + success: true, + transaction: "0xsettlementhash", + network: "eip155:8453", + payer: "0xpayer", + headers: {}, + requirements: createRequirements() + }); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue({ ...transaction, status: "settled" }); + deploymentWriterService.create.mockRejectedValue(new Error("broadcast failed")); + + const result = await service.processDeploy(createDeployRequestContext(), userId, createDeployInput()); + + // The credit happened; the money is safe in the Console balance and never reversed on-chain. + expect(refillService.topUpWallet).toHaveBeenCalledTimes(1); + expect(x402TransactionRepository.markDeployFailed).toHaveBeenCalledWith(transaction.id, "broadcast failed"); + expect(x402TransactionRepository.linkDeployment).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + type: "deploy-failed", + transactionId: transaction.id, + amountUsdCents: transaction.amount, + settlementTxHash: "0xsettlementhash", + message: "broadcast failed" + }); + }); + + it("rejects with rate-limited and never settles when the per-user request rate limit is exceeded", async () => { + const { service, httpServer, x402TransactionRepository, refillService, deploymentWriterService } = setup(); + const verified = createVerifiedResult(); + httpServer.processHTTPRequest.mockResolvedValue(verified); + x402TransactionRepository.countByUserSince.mockResolvedValue(10); + + const result = await service.processDeploy(createDeployRequestContext(), userId, createDeployInput()); + + expect(result).toMatchObject({ type: "rate-limited", retryAfterSeconds: 3600 }); + expect(verified.cancellationDispatcher.cancel).toHaveBeenCalledWith({ reason: "handler_failed", responseStatus: 429 }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + expect(deploymentWriterService.create).not.toHaveBeenCalled(); + }); + + it("rejects with cost-ceiling-exceeded and never settles when the per-user spend ceiling is exceeded", async () => { + const { service, httpServer, x402TransactionRepository, refillService, deploymentWriterService } = setup(); + const verified = createVerifiedResult(); + httpServer.processHTTPRequest.mockResolvedValue(verified); + // Recent spend already at the $2000 ceiling (in cents); this request would push past it. + x402TransactionRepository.sumAmountByUserSince.mockResolvedValue(200000); + + const result = await service.processDeploy(createDeployRequestContext(), userId, createDeployInput()); + + expect(result).toMatchObject({ type: "cost-ceiling-exceeded", ceilingUsdCents: 200000 }); + expect(verified.cancellationDispatcher.cancel).toHaveBeenCalledWith({ reason: "handler_failed", responseStatus: 402 }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + expect(deploymentWriterService.create).not.toHaveBeenCalled(); + }); + + it("rejects a payment already used for a previous deployment as a duplicate", async () => { + const { service, httpServer, x402TransactionRepository, refillService, deploymentWriterService } = setup(); + const transaction = createTransaction({ status: "succeeded", deploymentDseq: "999" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(transaction); + + const result = await service.processDeploy(createDeployRequestContext(), userId, createDeployInput()); + + expect(result).toEqual({ type: "duplicate-payment", transactionId: transaction.id }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + expect(deploymentWriterService.create).not.toHaveBeenCalled(); + }); + + it("returns payment-required instructions when no payment is attached", async () => { + const { service, httpServer, deploymentWriterService } = setup(); + httpServer.processHTTPRequest.mockResolvedValue({ + type: "payment-error", + response: { status: 402, headers: {}, body: { x402Version: 2, accepts: [] } } + }); + + const result = await service.processDeploy(createDeployRequestContext(), userId, createDeployInput()); + + expect(result).toMatchObject({ type: "payment-required", response: { status: 402 } }); + expect(deploymentWriterService.create).not.toHaveBeenCalled(); + }); + }); + + describe("pre-settle guardrails", () => { + it("rejects with WRONG_NETWORK and never settles when the requirement network differs from the configured network", async () => { + const { service, httpServer, refillService } = setup(); + const verified = createVerifiedResult(); + // Verified requirement is on a different network than the one we advertise (config eip155:8453). + verified.paymentRequirements = createRequirements({ network: "eip155:84532" }); + verified.paymentPayload.accepted = createRequirements({ network: "eip155:84532" }); + httpServer.processHTTPRequest.mockResolvedValue(verified); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(result).toMatchObject({ type: "payment-rejected", code: "WRONG_NETWORK", response: { status: 402 } }); + expect(verified.cancellationDispatcher.cancel).toHaveBeenCalled(); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + }); + + it("rejects with WRONG_ASSET and never settles when the payer authorized a different asset", async () => { + const { service, httpServer, refillService } = setup(); + const verified = createVerifiedResult(); + verified.paymentRequirements = createRequirements({ asset: "0xusdc" }); + verified.paymentPayload.accepted = createRequirements({ asset: "0xnotusdc" }); + httpServer.processHTTPRequest.mockResolvedValue(verified); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(result).toMatchObject({ type: "payment-rejected", code: "WRONG_ASSET" }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + }); + + it("rejects with AMOUNT_MISMATCH and never settles when the authorized amount differs from the requirement", async () => { + const { service, httpServer, refillService } = setup(); + const verified = createVerifiedResult(); + verified.paymentRequirements = createRequirements({ amount: "25000000" }); + verified.paymentPayload.accepted = createRequirements({ amount: "1" }); + httpServer.processHTTPRequest.mockResolvedValue(verified); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(result).toMatchObject({ type: "payment-rejected", code: "AMOUNT_MISMATCH" }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + }); + + it("settles when the verified requirement matches the configured network and the authorized payment", async () => { + const { service, httpServer, x402TransactionRepository } = setup(); + const transaction = createTransaction({ status: "pending" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + httpServer.processSettlement.mockResolvedValue({ + success: true, + transaction: "0xsettlementhash", + network: "eip155:8453", + headers: {}, + requirements: createRequirements() + }); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue({ ...transaction, status: "settled" }); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(httpServer.processSettlement).toHaveBeenCalled(); + expect(result).toMatchObject({ type: "success" }); + }); + }); + + describe("getDiscovery", () => { + it("advertises the configured accepts derived from the canonical source, including the deploy route", () => { + const { service } = setup(); + + const discovery = service.getDiscovery(); + + expect(discovery.x402Version).toBe(2); + const topUp = discovery.resources.find(resource => resource.resource === X402_TOP_UP_ROUTE); + expect(topUp).toMatchObject({ + accepts: [ + { + scheme: "exact", + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + currency: "USD", + minAmountUsd: 1, + maxAmountUsd: 1000, + maxTimeoutSeconds: 300 + } + ] + }); + // The v2.0 pay-per-deploy route is advertised from the same canonical source. + expect(discovery.resources.map(resource => resource.resource)).toContain("POST /v1/x402/deploy"); + }); + + it("stays in sync with the accepts the 402 flow serves (one canonical source)", async () => { + const { service, httpServer, httpServerFactory } = setup(); + httpServer.processHTTPRequest.mockResolvedValue({ + type: "payment-error", + response: { status: 402, headers: {}, body: { x402Version: 2, accepts: [] } } + }); + + // Trigger the 402 flow so the http server (and its routes config) is built. + await service.processTopUp(createRequestContext(), userId, amountUsd); + + const routesConfig = httpServerFactory.create.mock.calls[0][0].routes as Record; + const routeAccepts = routesConfig[X402_TOP_UP_ROUTE].accepts; + + const discovery = service.getDiscovery(); + const discoveryAccepts = discovery.resources[0].accepts[0]; + + expect(discovery.resources[0].resource).toBe(X402_TOP_UP_ROUTE); + expect(discoveryAccepts.scheme).toBe(routeAccepts.scheme); + expect(discoveryAccepts.network).toBe(routeAccepts.network); + expect(discoveryAccepts.payTo).toBe(routeAccepts.payTo); + }); + }); + + describe("money-integrity gate", () => { + it("credits exactly once when the same settled row is driven twice concurrently", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const transaction = createTransaction({ status: "settled", settlementTxHash: "0xsettlementhash" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(transaction); + x402TransactionRepository.findOneByAndLock.mockResolvedValue(transaction); + // The conditional UPDATE only affects a row for the first caller; the second sees rowCount 0. + x402TransactionRepository.markSettledAsSucceeded.mockReset(); + x402TransactionRepository.markSettledAsSucceeded.mockResolvedValueOnce(true).mockResolvedValue(false); + + await Promise.all([service.processTopUp(createRequestContext(), userId, amountUsd), service.processTopUp(createRequestContext(), userId, amountUsd)]); + + expect(refillService.topUpWallet).toHaveBeenCalledTimes(1); + }); + + it("reconciles a crash-before-credit transaction and credits exactly once", async () => { + const { service, x402TransactionRepository, refillService } = setup(); + const stranded = createTransaction({ status: "settled", settlementTxHash: "0xsettlementhash" }); + x402TransactionRepository.findStaleSettled.mockResolvedValue([stranded]); + x402TransactionRepository.findOneByAndLock.mockResolvedValue(stranded); + // First reconcile pass wins the transition; a second concurrent/duplicate pass gets rowCount 0. + x402TransactionRepository.markSettledAsSucceeded.mockReset(); + x402TransactionRepository.markSettledAsSucceeded.mockResolvedValueOnce(true).mockResolvedValue(false); + + const first = await service.reconcileStaleSettled(); + const second = await service.reconcileStaleSettled(); + + expect(refillService.topUpWallet).toHaveBeenCalledTimes(1); + expect(refillService.topUpWallet).toHaveBeenCalledWith(stranded.amount, stranded.userId, { + payment: { currency: "usd", paymentMethodType: "x402", transactionId: stranded.id } + }); + expect(first).toEqual({ backlog: 1, credited: 1, failed: 0 }); + expect(second).toEqual({ backlog: 1, credited: 0, failed: 0 }); + }); + + it("reports the stale settled backlog it scanned each run", async () => { + const { service, x402TransactionRepository } = setup(); + x402TransactionRepository.findStaleSettled.mockResolvedValue([]); + + const result = await service.reconcileStaleSettled(); + + expect(x402TransactionRepository.findStaleSettled).toHaveBeenCalledWith(expect.any(Date), 100); + expect(result).toEqual({ backlog: 0, credited: 0, failed: 0 }); + }); + + it("counts a failed credit without aborting the rest of the backlog", async () => { + const { service, x402TransactionRepository, refillService } = setup(); + const failing = createTransaction({ id: "aaaaaaaa-0000-0000-0000-000000000000", status: "settled" }); + const succeeding = createTransaction({ id: "bbbbbbbb-0000-0000-0000-000000000000", status: "settled" }); + x402TransactionRepository.findStaleSettled.mockResolvedValue([failing, succeeding]); + x402TransactionRepository.findOneByAndLock.mockImplementation(async query => (query?.id === failing.id ? failing : succeeding)); + refillService.topUpWallet.mockRejectedValueOnce(new Error("refill boom")); + + const result = await service.reconcileStaleSettled(); + + expect(result).toEqual({ backlog: 2, credited: 1, failed: 1 }); + }); + }); + + describe("payment_hash unique violation", () => { + it("resumes from the winning row instead of throwing when the insert loses the race", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const raced = createTransaction({ status: "settled", settlementTxHash: "0xsettlementhash" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValueOnce(undefined).mockResolvedValue(raced); + x402TransactionRepository.findOneByAndLock.mockResolvedValue(raced); + x402TransactionRepository.create.mockRejectedValue(uniqueViolation()); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ type: "success", data: { transactionId: raced.id } }); + }); + + it("returns a 409 duplicate when the racing request is still in flight", async () => { + const { service, httpServer, x402TransactionRepository, refillService } = setup(); + const inFlight = createTransaction({ status: "pending" }); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValueOnce(undefined).mockResolvedValue(inFlight); + x402TransactionRepository.create.mockRejectedValue(uniqueViolation()); + + const result = await service.processTopUp(createRequestContext(), userId, amountUsd); + + expect(result).toEqual({ type: "duplicate-payment", transactionId: inFlight.id }); + expect(httpServer.processSettlement).not.toHaveBeenCalled(); + expect(refillService.topUpWallet).not.toHaveBeenCalled(); + }); + + it("rethrows non-unique-violation insert errors", async () => { + const { service, httpServer, x402TransactionRepository } = setup(); + httpServer.processHTTPRequest.mockResolvedValue(createVerifiedResult()); + x402TransactionRepository.findByPaymentHash.mockResolvedValue(undefined); + x402TransactionRepository.create.mockRejectedValue(new Error("connection reset")); + + await expect(service.processTopUp(createRequestContext(), userId, amountUsd)).rejects.toThrow("connection reset"); + }); + }); + + describe("isEnabled", () => { + it("is disabled unless both the flag and the payTo address are configured", () => { + expect(setup({ X402_ENABLED: "false" }).service.isEnabled).toBe(false); + expect(setup({ X402_PAY_TO_ADDRESS: undefined }).service.isEnabled).toBe(false); + expect(setup().service.isEnabled).toBe(true); + }); + }); + + function setup(configOverrides: Partial = {}) { + const config = mock(); + Object.assign(config, { + X402_ENABLED: "true", + X402_PAY_TO_ADDRESS: "0x1111111111111111111111111111111111111111", + X402_NETWORK: "eip155:8453", + X402_FACILITATOR_URL: "https://x402.org/facilitator", + X402_MIN_TOP_UP_USD: 1, + X402_MAX_TOP_UP_USD: 1000, + X402_RECONCILE_THRESHOLD_SECONDS: 300, + X402_RECONCILE_INTERVAL_SECONDS: 300, + X402_RECONCILE_BATCH_SIZE: 100, + X402_MIN_DEPLOY_USD: 1, + X402_MAX_DEPLOY_USD: 1000, + X402_ABUSE_WINDOW_SECONDS: 3600, + X402_ABUSE_MAX_REQUESTS: 10, + X402_ABUSE_MAX_SPEND_USD: 2000, + ...configOverrides + }); + + const x402TransactionRepository = mock(); + x402TransactionRepository.markSettledAsSucceeded.mockResolvedValue(true); + x402TransactionRepository.countByUserSince.mockResolvedValue(0); + x402TransactionRepository.sumAmountByUserSince.mockResolvedValue(0); + const refillService = mock(); + const httpServer = mock(); + const httpServerFactory = mock(); + httpServerFactory.create.mockReturnValue(httpServer); + httpServer.initialize.mockResolvedValue(); + const deploymentWriterService = mock(); + + const service = new X402Service(config, x402TransactionRepository, refillService, httpServerFactory, deploymentWriterService); + + return { service, config, x402TransactionRepository, refillService, httpServer, httpServerFactory, deploymentWriterService }; + } + + function createDeployInput() { + return { sdl: 'version: "2.0"\nservices:\n web:\n image: nginx', deposit: amountUsd }; + } + + function createDeploymentResult(): CreateDeploymentResponse["data"] { + return { + dseq: "1234567890", + manifest: '[{"name":"web"}]', + signTx: { code: 0, transactionHash: "0xdeploytx", rawLog: "" } + }; + } + + function createRequestContext(): HTTPRequestContext { + return { + adapter: { + getHeader: () => undefined, + getMethod: () => "POST", + getPath: () => "/v1/x402/top-up", + getUrl: () => `https://console-api.akash.network/v1/x402/top-up?amount=${amountUsd}`, + getAcceptHeader: () => "application/json", + getUserAgent: () => "test", + getQueryParam: () => String(amountUsd) + }, + path: "/v1/x402/top-up", + method: "POST", + paymentHeader: "encoded-payment" + }; + } + + function createVerifiedResult() { + return { + type: "payment-verified" as const, + cancellationDispatcher: mock(), + paymentPayload: createPaymentPayload(), + paymentRequirements: createRequirements() + }; + } + + function createPaymentPayload(): PaymentPayload { + return { + x402Version: 2, + accepted: createRequirements(), + payload: { signature: "0xsig", authorization: { nonce: "0xnonce" } } + }; + } + + function createRequirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "exact", + network: "eip155:8453", + asset: "0xusdc", + amount: "25000000", + payTo: "0x1111111111111111111111111111111111111111", + maxTimeoutSeconds: 300, + extra: {}, + ...overrides + }; + } + + function uniqueViolation(): PostgresError { + const error = new PostgresError("duplicate key value violates unique constraint"); + (error as PostgresError & { code: string }).code = "23505"; + return error; + } + + function createTransaction(overrides: Partial = {}): X402TransactionOutput { + return { + id: "11111111-2222-3333-4444-555555555555", + userId, + status: "pending", + amount: 2500, + currency: "usd", + network: "eip155:8453", + asset: "0xusdc", + paymentHash: "hash", + payerAddress: null, + settlementTxHash: null, + errorMessage: null, + deploymentDseq: null, + deployFailed: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides + }; + } + + function createDeployRequestContext(): HTTPRequestContext { + return { + adapter: { + getHeader: () => undefined, + getMethod: () => "POST", + getPath: () => "/v1/x402/deploy", + getUrl: () => "https://console-api.akash.network/v1/x402/deploy", + getAcceptHeader: () => "application/json", + getUserAgent: () => "test", + getBody: () => ({ sdl: 'version: "2.0"', deposit: amountUsd }) + }, + path: "/v1/x402/deploy", + method: "POST", + paymentHeader: "encoded-payment" + }; + } +}); diff --git a/apps/api/src/billing/services/x402/x402.service.ts b/apps/api/src/billing/services/x402/x402.service.ts new file mode 100644 index 0000000000..3daf551202 --- /dev/null +++ b/apps/api/src/billing/services/x402/x402.service.ts @@ -0,0 +1,700 @@ +import { createOtelLogger } from "@akashnetwork/logging/otel"; +import type { + HTTPProcessResult, + HTTPRequestContext, + HTTPResponseInstructions, + PaymentCancellationDispatcher, + ProcessSettleSuccessResponse, + RouteConfig, + RoutesConfig, + x402HTTPResourceServer +} from "@x402/core/server"; +import type { Network, PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { createHash } from "crypto"; +import { singleton } from "tsyringe"; + +import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { type X402TransactionOutput, X402TransactionRepository } from "@src/billing/repositories/x402-transaction/x402-transaction.repository"; +import { RefillService } from "@src/billing/services/refill/refill.service"; +import { X402_ERROR_CODES, type X402ErrorCode, type X402ValidationCode } from "@src/billing/services/x402/x402-error-codes"; +import { X402HttpServerFactoryService } from "@src/billing/services/x402/x402-http-server-factory.service"; +import { WithTransaction } from "@src/core"; +import { isUniqueViolation } from "@src/core/repositories/base.repository"; +import type { CreateDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; +import { DeploymentWriterService } from "@src/deployment/services/deployment-writer/deployment-writer.service"; + +export const X402_TOP_UP_ROUTE = "POST /v1/x402/top-up"; +export const X402_DEPLOY_ROUTE = "POST /v1/x402/deploy"; + +type PaymentVerifiedResult = Extract; + +/** + * Canonical, statically-known description of one x402-protected route's accepted payment. + * This is the single source both the 402 flow (via {@link X402Service.buildRoutesConfig}) + * and the public discovery endpoint (via {@link X402Service.getDiscovery}) derive from. + */ +export interface X402RouteAccepts { + scheme: "exact"; + network: Network; + payTo: string; + maxTimeoutSeconds: number; + currency: "USD"; + minAmountUsd: number; + maxAmountUsd: number; +} + +/** How a route's on-chain price is resolved from the incoming request at 402 time. */ +export type X402PriceSource = "top-up-amount" | "deploy-deposit"; + +export interface X402CanonicalRoute { + route: string; + description: string; + mimeType: string; + priceSource: X402PriceSource; + accepts: X402RouteAccepts; +} + +export interface X402DiscoveryAccepts { + scheme: string; + network: string; + payTo: string; + currency: string; + minAmountUsd: number; + maxAmountUsd: number; + maxTimeoutSeconds: number; +} + +export interface X402DiscoveryResource { + resource: string; + description: string; + mimeType: string; + accepts: X402DiscoveryAccepts[]; +} + +export interface X402DiscoveryResult { + x402Version: number; + resources: X402DiscoveryResource[]; +} + +/** + * Outcome of settling an x402 payment and crediting the Console balance, shared by both the + * top-up and pay-per-deploy flows. `credited` carries the transaction row so a follow-on step + * (e.g. driving deployment creation) can key off it. + */ +type SettlementResult = + | { type: "payment-required"; code: X402ErrorCode; response: HTTPResponseInstructions } + | { type: "payment-rejected"; code: X402ValidationCode; response: HTTPResponseInstructions } + | { type: "duplicate-payment"; transactionId: string } + | { type: "credited"; transaction: X402TransactionOutput; headers: Record; settlementTxHash: string; payerAddress?: string }; + +type AbuseLimitViolation = { type: "rate-limited"; retryAfterSeconds: number } | { type: "cost-ceiling-exceeded"; ceilingUsdCents: number }; + +export type X402TopUpProcessResult = + | { type: "payment-required"; code: X402ErrorCode; response: HTTPResponseInstructions } + | { type: "payment-rejected"; code: X402ValidationCode; response: HTTPResponseInstructions } + | { type: "duplicate-payment"; transactionId: string } + | { + type: "success"; + headers: Record; + data: { + transactionId: string; + amountUsdCents: number; + network: string; + settlementTxHash: string; + payerAddress?: string; + }; + }; + +export type X402DeployInput = { sdl: string; deposit: number }; + +export type X402DeploySuccessData = { + transactionId: string; + amountUsdCents: number; + network: string; + settlementTxHash: string; + payerAddress?: string; + deploymentDseq: string; + manifest: string; + signTx: CreateDeploymentResponse["data"]["signTx"]; +}; + +export type X402DeployProcessResult = + | { type: "payment-required"; code: X402ErrorCode; response: HTTPResponseInstructions } + | { type: "payment-rejected"; code: X402ValidationCode; response: HTTPResponseInstructions } + | { type: "duplicate-payment"; transactionId: string } + | AbuseLimitViolation + | { + type: "deploy-failed"; + headers: Record; + transactionId: string; + amountUsdCents: number; + settlementTxHash: string; + message: string; + } + | { type: "success"; headers: Record; data: X402DeploySuccessData }; + +@singleton() +export class X402Service { + private readonly logger = createOtelLogger({ context: X402Service.name }); + + private httpServer?: x402HTTPResourceServer; + + private initPromise?: Promise; + + constructor( + @InjectBillingConfig() private readonly config: BillingConfig, + private readonly x402TransactionRepository: X402TransactionRepository, + private readonly refillService: RefillService, + private readonly httpServerFactory: X402HttpServerFactoryService, + private readonly deploymentWriterService: DeploymentWriterService + ) {} + + get isEnabled(): boolean { + return this.config.X402_ENABLED === "true" && !!this.config.X402_PAY_TO_ADDRESS; + } + + async processTopUp(context: HTTPRequestContext, userId: string, amountUsd: number): Promise { + const verified = await this.verifyPayment(context, "top-up"); + if (verified.type !== "payment-verified") { + return verified; + } + + const amountUsdCents = Math.round(amountUsd * 100); + const settlement = await this.settlePaymentAndCredit(context, { userId, amountUsdCents }, verified); + + if (settlement.type !== "credited") { + return settlement; + } + + const { transaction } = settlement; + this.logger.info({ + event: "X402_TOP_UP_SUCCEEDED", + transactionId: transaction.id, + userId, + amountUsdCents: transaction.amount, + network: transaction.network, + settlementTxHash: settlement.settlementTxHash + }); + + return { + type: "success", + headers: settlement.headers, + data: { + transactionId: transaction.id, + amountUsdCents: transaction.amount, + network: transaction.network, + settlementTxHash: settlement.settlementTxHash, + payerAddress: settlement.payerAddress + } + }; + } + + /** + * Pay-per-deploy: one x402-paid call settles USDC, credits the Console balance through the same + * `RefillService.topUpWallet` choke point as every other funding source, then drives the existing + * managed deployment creation service with the freshly credited balance. + * + * If settlement + credit succeed but deployment creation fails, the funds remain in the user's + * Console balance (never reversed on-chain): the row is flagged `deployFailed` and a `deploy-failed` + * result is returned so the caller knows the money is spendable. + */ + async processDeploy(context: HTTPRequestContext, userId: string, input: X402DeployInput): Promise { + const verified = await this.verifyPayment(context, "deploy"); + if (verified.type !== "payment-verified") { + return verified; + } + + const amountUsdCents = Math.round(input.deposit * 100); + + // Abuse controls run on the verified (paying) request, before any on-chain settlement, so an + // over-limit caller never has funds captured. Cancel the verified payment on rejection. + const violation = await this.checkAbuseLimits(userId, amountUsdCents); + if (violation) { + await verified.cancellationDispatcher.cancel({ reason: "handler_failed", responseStatus: violation.type === "rate-limited" ? 429 : 402 }); + return violation; + } + + const settlement = await this.settlePaymentAndCredit(context, { userId, amountUsdCents }, verified); + if (settlement.type !== "credited") { + return settlement; + } + + const { transaction } = settlement; + + try { + const deployment = await this.deploymentWriterService.create({ sdl: input.sdl, deposit: input.deposit, userId }); + await this.x402TransactionRepository.linkDeployment(transaction.id, deployment.dseq); + + this.logger.info({ + event: "X402_DEPLOY_SUCCEEDED", + transactionId: transaction.id, + userId, + amountUsdCents: transaction.amount, + deploymentDseq: deployment.dseq, + settlementTxHash: settlement.settlementTxHash + }); + + return { + type: "success", + headers: settlement.headers, + data: { + transactionId: transaction.id, + amountUsdCents: transaction.amount, + network: transaction.network, + settlementTxHash: settlement.settlementTxHash, + payerAddress: settlement.payerAddress, + deploymentDseq: deployment.dseq, + manifest: deployment.manifest, + signTx: deployment.signTx + } + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Deployment creation failed"; + await this.x402TransactionRepository.markDeployFailed(transaction.id, message); + + this.logger.error({ + event: "X402_DEPLOY_FAILED_FUNDS_CREDITED", + transactionId: transaction.id, + userId, + amountUsdCents: transaction.amount, + error + }); + + return { + type: "deploy-failed", + headers: settlement.headers, + transactionId: transaction.id, + amountUsdCents: transaction.amount, + settlementTxHash: settlement.settlementTxHash, + message + }; + } + } + + /** + * Re-drives crediting for every transaction stranded in `settled` (captured on-chain but not yet + * credited) past the configured threshold. Idempotent per row via the conditional credit gate, so + * it is safe to run alongside in-request crediting and concurrent reconcile runs. Emits a backlog + * count each run for observability. + */ + async reconcileStaleSettled(): Promise<{ backlog: number; credited: number; failed: number }> { + const cutoff = new Date(Date.now() - this.config.X402_RECONCILE_THRESHOLD_SECONDS * 1000); + const stale = await this.x402TransactionRepository.findStaleSettled(cutoff, this.config.X402_RECONCILE_BATCH_SIZE); + + this.logger.info({ + event: "X402_RECONCILE_BACKLOG", + backlog: stale.length, + thresholdSeconds: this.config.X402_RECONCILE_THRESHOLD_SECONDS, + cutoff: cutoff.toISOString() + }); + + let credited = 0; + let failed = 0; + + for (const transaction of stale) { + try { + if (await this.creditSettledTransaction(transaction.id)) { + credited++; + } + } catch (error) { + failed++; + this.logger.error({ event: "X402_RECONCILE_CREDIT_FAILED", transactionId: transaction.id, error }); + } + } + + this.logger.info({ event: "X402_RECONCILE_COMPLETED", backlog: stale.length, credited, failed }); + + return { backlog: stale.length, credited, failed }; + } + + private async verifyPayment( + context: HTTPRequestContext, + routeLabel: string + ): Promise { + const httpServer = this.getHttpServer(); + await this.initialize(httpServer); + + const result = await httpServer.processHTTPRequest(context); + + if (result.type === "payment-error") { + // A verify-stage failure with a payment attached is an invalid payment; without one it is the + // standard 402 challenge advertising the accepted payment requirements. + const code = context.paymentHeader ? X402_ERROR_CODES.PAYMENT_INVALID : X402_ERROR_CODES.PAYMENT_REQUIRED; + return { type: "payment-required", code, response: result.response }; + } + + if (result.type === "no-payment-required") { + throw new Error(`x402 ${routeLabel} route is misconfigured: payment is always required`); + } + + return result; + } + + private async settlePaymentAndCredit( + context: HTTPRequestContext, + input: { userId: string; amountUsdCents: number }, + verified: PaymentVerifiedResult + ): Promise { + const httpServer = this.getHttpServer(); + const { paymentPayload, paymentRequirements, declaredExtensions, cancellationDispatcher } = verified; + + // Pre-settle guardrail (defence in depth): the verified requirement about to be settled must + // match the configured network and the exact terms the payer authorized. On any mismatch we + // cancel the verified payment and never call processSettlement, so a bad payment is never + // settled or credited. + const validationCode = this.validatePreSettle(paymentPayload, paymentRequirements); + if (validationCode) { + await cancellationDispatcher.cancel({ reason: "handler_failed", responseStatus: 402 }); + this.logger.warn({ + event: "X402_PRE_SETTLE_REJECTED", + code: validationCode, + requirementNetwork: paymentRequirements.network, + requirementAsset: paymentRequirements.asset, + requirementAmount: paymentRequirements.amount + }); + return { type: "payment-rejected", code: validationCode, response: this.buildValidationResponse(validationCode, paymentRequirements) }; + } + + const paymentHash = this.hashPayment(paymentPayload); + + const existing = await this.x402TransactionRepository.findByPaymentHash(paymentHash); + const resolvedExisting = await this.resolveTerminalOrSettled(existing, cancellationDispatcher); + if (resolvedExisting) { + return resolvedExisting; + } + + // `existing` is now undefined or a resumable pending/failed attempt this request owns. + let transaction: X402TransactionOutput; + if (existing) { + transaction = existing; + } else { + const created = await this.createTransaction( + { userId: input.userId, amountUsdCents: input.amountUsdCents, paymentRequirements, paymentHash }, + cancellationDispatcher + ); + if (created.type === "resolved") { + return created.result; + } + transaction = created.transaction; + } + + const settleResult = await httpServer.processSettlement(paymentPayload, paymentRequirements, declaredExtensions, { request: context }); + + if (!settleResult.success) { + await this.x402TransactionRepository.updateById(transaction.id, { + status: "failed", + errorMessage: settleResult.errorMessage ?? settleResult.errorReason + }); + this.logger.warn({ event: "X402_SETTLEMENT_FAILED", transactionId: transaction.id, errorReason: settleResult.errorReason }); + return { type: "payment-required", code: X402_ERROR_CODES.PAYMENT_INVALID, response: settleResult.response }; + } + + // Settled on-chain: persist proof first so a crash before crediting is recoverable + await this.x402TransactionRepository.updateById(transaction.id, { + status: "settled", + settlementTxHash: settleResult.transaction, + payerAddress: settleResult.payer + }); + + await this.creditSettledTransaction(transaction.id); + + return { + type: "credited", + transaction, + headers: this.pickPaymentResponseHeaders(settleResult), + settlementTxHash: settleResult.transaction, + payerAddress: settleResult.payer + }; + } + + /** + * Per-user abuse controls for the x402-paid endpoints, evaluated against the user's recent + * (non-failed) x402 transactions within a rolling window: a request-count rate limit and a + * cumulative-spend cost ceiling. Both are config-driven and need no extra infrastructure. + */ + private async checkAbuseLimits(userId: string, amountUsdCents: number): Promise { + const windowSeconds = this.config.X402_ABUSE_WINDOW_SECONDS; + const since = new Date(Date.now() - windowSeconds * 1000); + + const recentCount = await this.x402TransactionRepository.countByUserSince(userId, since); + if (recentCount >= this.config.X402_ABUSE_MAX_REQUESTS) { + this.logger.warn({ event: "X402_RATE_LIMITED", userId, recentCount, max: this.config.X402_ABUSE_MAX_REQUESTS, windowSeconds }); + return { type: "rate-limited", retryAfterSeconds: windowSeconds }; + } + + const recentSpendCents = await this.x402TransactionRepository.sumAmountByUserSince(userId, since); + const ceilingCents = Math.round(this.config.X402_ABUSE_MAX_SPEND_USD * 100); + if (recentSpendCents + amountUsdCents > ceilingCents) { + this.logger.warn({ event: "X402_COST_CEILING_EXCEEDED", userId, recentSpendCents, amountUsdCents, ceilingCents }); + return { type: "cost-ceiling-exceeded", ceilingUsdCents: ceilingCents }; + } + + return undefined; + } + + private async resolveTerminalOrSettled( + existing: X402TransactionOutput | undefined, + cancellationDispatcher: PaymentCancellationDispatcher + ): Promise { + if (existing?.status === "succeeded") { + await cancellationDispatcher.cancel({ reason: "handler_failed", responseStatus: 409 }); + return { type: "duplicate-payment", transactionId: existing.id }; + } + + if (existing?.status === "settled") { + // Payment already settled on-chain but crediting was interrupted: resume it + await cancellationDispatcher.cancel({ reason: "handler_failed", responseStatus: 409 }); + await this.creditSettledTransaction(existing.id); + return { + type: "credited", + transaction: existing, + headers: {}, + settlementTxHash: existing.settlementTxHash ?? "", + payerAddress: existing.payerAddress ?? undefined + }; + } + + return undefined; + } + + private async createTransaction( + input: { userId: string; amountUsdCents: number; paymentRequirements: PaymentRequirements; paymentHash: string }, + cancellationDispatcher: PaymentCancellationDispatcher + ): Promise<{ type: "created"; transaction: X402TransactionOutput } | { type: "resolved"; result: SettlementResult }> { + try { + const transaction = await this.x402TransactionRepository.create({ + userId: input.userId, + status: "pending", + amount: input.amountUsdCents, + currency: "usd", + network: input.paymentRequirements.network, + asset: input.paymentRequirements.asset, + paymentHash: input.paymentHash + }); + return { type: "created", transaction }; + } catch (error) { + if (!isUniqueViolation(error)) { + throw error; + } + + // The DB-level UNIQUE(payment_hash) index lost the insert race to a concurrent request for the + // same payment. Re-read the winning row and resume/dedupe instead of surfacing a 500. + this.logger.info({ event: "X402_PAYMENT_HASH_CONFLICT", paymentHash: input.paymentHash }); + const raced = await this.x402TransactionRepository.findByPaymentHash(input.paymentHash); + const resolved = await this.resolveTerminalOrSettled(raced, cancellationDispatcher); + if (resolved) { + return { type: "resolved", result: resolved }; + } + + // The concurrent request is still mid-flight (pending/failed): do not settle the same payment + // twice. Report it as a duplicate; crediting is guaranteed by that request or the reconcile job. + await cancellationDispatcher.cancel({ reason: "handler_failed", responseStatus: 409 }); + return { type: "resolved", result: { type: "duplicate-payment", transactionId: raced!.id } }; + } + } + + /** + * Credits a settled transaction exactly once. Returns `true` only when this call performed the + * credit; `false` when there was nothing to credit or another caller already claimed it. + */ + @WithTransaction() + private async creditSettledTransaction(transactionId: string): Promise { + const transaction = await this.x402TransactionRepository.findOneByAndLock({ id: transactionId }); + + if (!transaction || transaction.status !== "settled") { + return false; + } + + // Atomic settled -> succeeded transition is the money-integrity gate: only the caller that wins + // the conditional UPDATE credits the wallet. A concurrent retry or the reconcile job sees the + // transition already claimed (rowCount 0) and returns without double-crediting. + const transitioned = await this.x402TransactionRepository.markSettledAsSucceeded(transaction.id); + + if (!transitioned) { + this.logger.info({ event: "X402_CREDIT_SKIPPED_ALREADY_CLAIMED", transactionId: transaction.id }); + return false; + } + + await this.refillService.topUpWallet(transaction.amount, transaction.userId, { + payment: { + currency: transaction.currency, + paymentMethodType: "x402", + transactionId: transaction.id + } + }); + + return true; + } + + /** + * The canonical list of x402-protected routes and their accepted payment terms. + * Both the 402 flow ({@link buildRoutesConfig}) and the public discovery endpoint + * ({@link getDiscovery}) derive from this single source, so the accepts advertised in + * a 402 response and in discovery can never drift apart. + */ + getCanonicalRoutes(): X402CanonicalRoute[] { + return [ + { + route: X402_TOP_UP_ROUTE, + description: "Top up Akash Console credits with a USDC payment", + mimeType: "application/json", + priceSource: "top-up-amount", + accepts: { + scheme: "exact", + network: this.config.X402_NETWORK, + payTo: this.config.X402_PAY_TO_ADDRESS!, + maxTimeoutSeconds: 300, + currency: "USD", + minAmountUsd: this.config.X402_MIN_TOP_UP_USD, + maxAmountUsd: this.config.X402_MAX_TOP_UP_USD + } + }, + { + route: X402_DEPLOY_ROUTE, + description: "Create and fund an Akash deployment with a single USDC payment", + mimeType: "application/json", + priceSource: "deploy-deposit", + accepts: { + scheme: "exact", + network: this.config.X402_NETWORK, + payTo: this.config.X402_PAY_TO_ADDRESS!, + maxTimeoutSeconds: 300, + currency: "USD", + minAmountUsd: this.config.X402_MIN_DEPLOY_USD, + maxAmountUsd: this.config.X402_MAX_DEPLOY_USD + } + } + ]; + } + + /** Public discovery document listing every x402 resource and its accepted payments. */ + getDiscovery(): X402DiscoveryResult { + return { + x402Version: 2, + resources: this.getCanonicalRoutes().map(route => ({ + resource: route.route, + description: route.description, + mimeType: route.mimeType, + accepts: [ + { + scheme: route.accepts.scheme, + network: route.accepts.network, + payTo: route.accepts.payTo, + currency: route.accepts.currency, + minAmountUsd: route.accepts.minAmountUsd, + maxAmountUsd: route.accepts.maxAmountUsd, + maxTimeoutSeconds: route.accepts.maxTimeoutSeconds + } + ] + })) + }; + } + + private buildRoutesConfig(): RoutesConfig { + const routes: Record = {}; + + for (const route of this.getCanonicalRoutes()) { + routes[route.route] = { + description: route.description, + mimeType: route.mimeType, + accepts: { + scheme: route.accepts.scheme, + network: route.accepts.network, + payTo: route.accepts.payTo, + price: route.priceSource === "deploy-deposit" ? context => this.parseDeposit(context) : context => `$${this.parseAmount(context)}`, + maxTimeoutSeconds: route.accepts.maxTimeoutSeconds + } + }; + } + + return routes; + } + + private getHttpServer(): x402HTTPResourceServer { + this.httpServer ??= this.httpServerFactory.create({ + facilitatorUrl: this.config.X402_FACILITATOR_URL, + network: this.config.X402_NETWORK, + routes: this.buildRoutesConfig() + }); + + return this.httpServer; + } + + /** + * Defence-in-depth guardrail run after the facilitator verifies a payment but before we + * settle it on-chain. The verified requirement about to be settled must match the network + * we advertise and must exactly match the terms the payer actually authorized. A mismatch + * means we never call {@link x402HTTPResourceServer.processSettlement}, so a payment for the + * wrong network, wrong asset, or a different amount is never settled or credited. + */ + private validatePreSettle(paymentPayload: PaymentPayload, paymentRequirements: PaymentRequirements): X402ValidationCode | undefined { + const authorized = paymentPayload.accepted; + + if (paymentRequirements.network !== this.config.X402_NETWORK || authorized.network !== paymentRequirements.network) { + return X402_ERROR_CODES.WRONG_NETWORK; + } + + if (authorized.asset !== paymentRequirements.asset) { + return X402_ERROR_CODES.WRONG_ASSET; + } + + if (authorized.amount !== paymentRequirements.amount) { + return X402_ERROR_CODES.AMOUNT_MISMATCH; + } + + return undefined; + } + + private buildValidationResponse(code: X402ValidationCode, paymentRequirements: PaymentRequirements): HTTPResponseInstructions { + return { + status: 402, + headers: {}, + body: { + x402Version: 2, + error: code, + accepts: [paymentRequirements] + } + }; + } + + private async initialize(httpServer: x402HTTPResourceServer): Promise { + if (!this.initPromise) { + this.initPromise = httpServer.initialize().catch(error => { + this.initPromise = undefined; + throw error; + }); + } + + await this.initPromise; + } + + private parseAmount(context: HTTPRequestContext): number { + const raw = context.adapter.getQueryParam?.("amount"); + const amount = Number(Array.isArray(raw) ? raw[0] : raw); + + if (!Number.isFinite(amount) || amount < this.config.X402_MIN_TOP_UP_USD || amount > this.config.X402_MAX_TOP_UP_USD) { + throw new Error(`x402 top-up amount must be between ${this.config.X402_MIN_TOP_UP_USD} and ${this.config.X402_MAX_TOP_UP_USD} USD`); + } + + return amount; + } + + private async parseDeposit(context: HTTPRequestContext): Promise { + const body = (await context.adapter.getBody?.()) as { deposit?: unknown } | undefined; + const deposit = Number(body?.deposit); + + if (!Number.isFinite(deposit) || deposit < this.config.X402_MIN_DEPLOY_USD || deposit > this.config.X402_MAX_DEPLOY_USD) { + throw new Error(`x402 deploy deposit must be between ${this.config.X402_MIN_DEPLOY_USD} and ${this.config.X402_MAX_DEPLOY_USD} USD`); + } + + return `$${deposit}`; + } + + private hashPayment(paymentPayload: PaymentPayload): string { + return createHash("sha256").update(JSON.stringify(paymentPayload.payload)).digest("hex"); + } + + private pickPaymentResponseHeaders(settleResult: ProcessSettleSuccessResponse): Record { + return settleResult.headers ?? {}; + } +} diff --git a/apps/api/src/routers/open-api-handlers.ts b/apps/api/src/routers/open-api-handlers.ts index ade9b8f7b7..82d2529dca 100644 --- a/apps/api/src/routers/open-api-handlers.ts +++ b/apps/api/src/routers/open-api-handlers.ts @@ -15,7 +15,8 @@ import { stripeTransactionsRouter, stripeWebhook, usageRouter, - walletSettingRouter + walletSettingRouter, + x402Router } from "@src/billing"; import { blockPredictionRouter, blocksRouter } from "@src/block"; import { certificateRouter } from "@src/certificate/routes/certificate.router"; @@ -65,6 +66,7 @@ export const openApiHonoHandlers: OpenApiHonoHandler[] = [ stripeCustomersRouter, stripePaymentMethodsRouter, stripeTransactionsRouter, + x402Router, usageRouter, registerUserRouter, getCurrentUserRouter, diff --git a/doc/x402/PLAN.md b/doc/x402/PLAN.md new file mode 100644 index 0000000000..b5bbdd4ae7 --- /dev/null +++ b/doc/x402/PLAN.md @@ -0,0 +1,49 @@ +# x402 Integration — Release Plan (through v2) + +> Owner process: a "PM council" workflow (multiple PM-persona agents + synthesis) decides scope per +> release; implementation fans out as workflows in git worktrees. This file records the decided +> plan. Status legend: ☐ planned · ◐ in progress · ☑ done. + +## Draft roadmap (pre-council; council output will replace this section) + +- **v1.0 — USDC top-up (core)** ◐ + - x402-gated `POST /v1/x402/top-up` (done, see STATE.md) + - `GET /v1/x402/transactions` listing endpoint + - Example agent client script (`@x402/fetch` + x-api-key): top up → deploy → close + - `doc/x402-payments.md` operator + user docs; env samples updated +- **v1.1 — Productionization** + - Functional/integration tests (mock facilitator) + - Reconciliation job for `settled`-but-uncredited transactions (pg-boss) + - Multi-network accepts (Base + Base Sepolia for sandbox; optional Solana via `@x402/svm`) +- **v1.5 — Surfaces** + - deploy-web: "Pay with USDC" flow in Top Up UI (x402 paywall or wallet connect) + - Auto-reload via x402 (wallet-settings parity with Stripe auto-reload) +- **v2.0 — Agent-native payments** + - Pay-per-deploy: single x402-paid call that creates+funds a deployment (no prior balance) + - x402-paid deposit-deployment (top up a specific deployment's escrow directly) + - OpenAPI/Bazaar discovery metadata so x402-aware agents can find the endpoints + +## Council decision log + +_(appended by the council workflow)_ + +## Worktree / branch map + +| Release | Branch | Worktree | Status | +|---|---|---|---| +| v1.0 core | `feat/x402-usdc-topup` | main checkout `/Users/colin/Code/console` | ◐ | +| v1.0 readback + on-ramp | `feat/x402-v1-0-readback-and-onramp` | `/Users/colin/Code/console-worktrees/x402-v1-0` | ☑ | +| v1.1 money-integrity gate | `feat/x402-v1-1-money-integrity-gate` | `/Users/colin/Code/console-worktrees/x402-v1-1` | ☑ | +| v1.2 sandbox + discovery | `feat/x402-v1-2-sandbox-and-discovery` | `/Users/colin/Code/console-worktrees/x402-v1-2` | ☑ | +| v2.0 pay-per-deploy | `feat/x402-v2-0-pay-per-deploy` | `/Users/colin/Code/console-worktrees/x402-v2-0` | ☑ | +| Integration (v1.0+v1.1+v1.2+v2.0) | `feat/x402-integration` | `/Users/colin/Code/console-worktrees/x402-integration` | ☑ | + +## Handoff instructions + +1. Read [STATE.md](./STATE.md) for what exists and repo conventions. +2. Claim a release: add a row above with your worktree path before starting. +3. Base new branches on `feat/x402-usdc-topup` (until it merges), one worktree per release: + `git -C /Users/colin/Code/console worktree add /Users/colin/Code/console-worktrees/ -b feat/x402-usdc-topup` +4. Definition of done per release: typecheck + lint + unit tests green in the worktree, docs + updated, STATE.md and this table updated, commits use conventional commits. No pushes/PRs + without user approval. diff --git a/doc/x402/RELEASE-NOTES-v1.0.md b/doc/x402/RELEASE-NOTES-v1.0.md new file mode 100644 index 0000000000..a2342bfd21 --- /dev/null +++ b/doc/x402/RELEASE-NOTES-v1.0.md @@ -0,0 +1,89 @@ +# x402 v1.0 — Release Notes + +Branch: `feat/x402-v1-0-readback-and-onramp` + +## Theme + +Turn the shipped `POST /v1/x402/top-up` endpoint into an adoption on-ramp: agents can read their own +payments back, copy-paste a working client, branch on stable error codes, and follow first-class docs. +Everything here is **additive** — the existing top-up flow is unchanged in behaviour. + +## What shipped + +### 1. Payment read-back — `GET /v1/x402/transactions` + +- New route on `x402Router` (`operationId: listUsdcTopUps`), `X402Controller.listTransactions()`, + and `X402TransactionRepository.findByUserPaginated()`. +- Paginated (`limit` 1–100, default 25; `offset` default 0), newest-first. Each row exposes + `transactionId`, `status`, `amountUsdCents`, `currency`, `network`, `asset`, `settlementTxHash`, + `payerAddress`, `createdAt`. +- **IDOR-safe:** the query is scoped to the authenticated user id explicitly in the repository + (`eq(userId)`), in addition to the CASL ability. A caller can never read another user's rows and + passes no user id of their own. Covered by `x402.controller.spec.ts`. + +### 2. Stable, machine-readable error codes + +Error bodies now carry a stable `code` field alongside the HTTP status, defined once in +`billing/services/x402/x402-error-codes.ts`: + +| HTTP | `code` | Source | +| ---- | ---------------------- | ---------------------------------------- | +| 402 | `PAYMENT_REQUIRED` | challenge (no payment attached) | +| 402 | `PAYMENT_INVALID` | verify/settlement failure | +| 400 | `AMOUNT_OUT_OF_BOUNDS` | amount outside min/max bounds | +| 409 | `DUPLICATE_PAYMENT` | payment already used for a top-up | +| 404 | `X402_DISABLED` | x402 not enabled | + +`400`/`404` codes are emitted from the controller via `http-errors` (`data.errorCode`, rendered by the +shared `HonoErrorHandlerService`); `402`/`409` are emitted from the service/router into the JSON body. + +### 3. Example agent client — `examples/x402/` + +- `top-up.ts`: full loop — trigger `402`, sign `X-PAYMENT`, retry to settle + credit, then poll + `GET /v1/x402/transactions` and print the `transactionId`. +- Built on the installed x402 v2 client API (`@x402/core/client` `x402Client` + `x402HTTPClient`, + `@x402/evm/exact/client` `registerExactEvmScheme`, `viem` signer). `@x402/fetch`'s + `wrapFetchWithPayment` is documented as the equivalent one-liner. +- **Testnet-safe:** defaults to Base Sepolia and refuses to sign a mainnet challenge unless + `X402_ALLOW_MAINNET=true`. +- Standalone package (own `package.json` + `tsconfig.json`), **not** part of the monorepo workspaces, + so it never affects the Console build. Ships `.env.example` and `README.md` with the funded-wallet + step spelled out. + +### 4. Docs + +- `doc/x402/USAGE.md`: `X402_*` env matrix, a single `curl` walkthrough (`402` → `X-PAYMENT` → + credit → read-back), the error-code table, the treasury-custody note for `X402_PAY_TO_ADDRESS`, and + an explicit **NO-REFUND** limitation statement. + +## Decisions + +- **Explicit `userId` filter over ability-only scoping.** The `X402Payment` CASL subject grants read + unconditionally (no `userId` condition in `ability.service.ts`), so relying on the ability alone + would leak all users' rows. The repository therefore filters by the authenticated user id + explicitly; the ability is still applied on top. +- **`PAYMENT_REQUIRED` vs `PAYMENT_INVALID`.** Both are `402`. The initial challenge (no `X-PAYMENT`) + is `PAYMENT_REQUIRED`; a `402` after a verify/settlement failure (payment header present) is + `PAYMENT_INVALID`, letting agents distinguish "I still need to pay" from "my payment was rejected". +- **Example built on the low-level client, not `@x402/fetch`.** `@x402/fetch` is not present in this + repo's dependency tree, whereas `@x402/core`/`@x402/evm`/`viem` are — building on them means the + example genuinely typechecks against installed packages while remaining a faithful, explicit + 402→pay→retry loop. +- **Codes centralised** in one module and referenced by controller, service, router, and docs so the + contract cannot drift. + +## Verification + +- `npx tsc -p tsconfig.build.json --noEmit` in `apps/api`: clean. +- `npx eslint` on all touched dirs (`--quiet`): clean. +- `npx vitest run --project=unit` on `x402.controller.spec.ts` + `x402.service.spec.ts`: 11 passing. +- `examples/x402`: `tsc --noEmit` clean (against the repo's installed `@x402/*` + `viem`). + +## Manual / left for a live run + +- **Funded-wallet dogf/E2E:** actually settling a payment needs a wallet holding Base Sepolia testnet + USDC + gas and a running Console API. The steps are documented in `examples/x402/README.md`; the + client and server both typecheck and unit-test green, but a real settlement was not executed here + (no network / funded testnet in this environment). +- No DB migration was needed — `GET /v1/x402/transactions` reads the existing `x402_transactions` + table (which already indexes `(user_id, created_at)`). diff --git a/doc/x402/RELEASE-NOTES-v1.1.md b/doc/x402/RELEASE-NOTES-v1.1.md new file mode 100644 index 0000000000..fde31ddf16 --- /dev/null +++ b/doc/x402/RELEASE-NOTES-v1.1.md @@ -0,0 +1,94 @@ +# x402 v1.1 — Money-integrity gate + +Guarantees that a settled x402 payment is credited to a user's Console wallet **exactly once**, and +that no USDC is ever left stranded (settled on-chain but never credited). Purely additive on top of +the v1 core top-up flow. + +## What shipped + +### 1. Double-credit race guard (conditional transition) + +`X402Service.creditSettledTransaction` now credits through an atomic DB transition instead of an +unconditional status write: + +- New repo method `X402TransactionRepository.markSettledAsSucceeded(id)` runs + `UPDATE x402_transactions SET status = 'succeeded', updated_at = now() WHERE id = $1 AND status = 'settled'` + and returns `true` only when it affected a row (rowCount 1). +- `creditSettledTransaction` calls `refillService.topUpWallet` **only** when the transition won + (returns `true`); it now returns a boolean so callers can tell a real credit from a no-op. +- Result: a concurrent request retry and the reconcile job can both drive the same `settled` row and + the wallet is topped up once. The row is still `SELECT ... FOR UPDATE` locked first, so the + conditional update is the primary gate with row-lock serialization as defense in depth. + +### 2. Reconciliation job (`x402-reconcile-settled`) + +A recurring pg-boss job that re-drives crediting for any transaction stranded in `settled` past a +configurable threshold (default 5 min), covering the crash-before-credit window. + +- `X402Service.reconcileStaleSettled()` scans stale `settled` rows via + `X402TransactionRepository.findStaleSettled(cutoff, batchSize)` (oldest first), logs an + `X402_RECONCILE_BACKLOG` count each run, re-drives each through the idempotent credit path, and + logs `X402_RECONCILE_COMPLETED` with `{ backlog, credited, failed }`. A single row's failure is + logged and counted without aborting the batch. +- `X402ReconcileSettledHandler` implements `JobHandler` (queue `x402-reconcile-settled`, + `policy: "singleton"`, `concurrency: 1`), mirroring `WalletBalanceReloadCheckHandler`. It runs a + pass then self-reschedules the next run in a `finally` so the loop survives a throwing pass. +- `X402ReconcileJobService` seeds/reschedules the job (cancel-created-then-enqueue with a + `singletonKey`, mirroring `WalletReloadJobService`). Registered + seeded in + `src/app/providers/jobs.provider.ts` next to the other handlers; seeding is a no-op when x402 is + disabled. + +### 3. `payment_hash` unique-violation handling + +Migration 0032's `x402_transactions_payment_hash_unique` is a real DB-level `UNIQUE INDEX` (verified +in the migration SQL and the drizzle model-schema). `processTopUp` now catches the unique violation +on insert (via `isUniqueViolation` from `base.repository`) instead of 500ing: + +- On conflict it re-reads the winning row by `payment_hash` and routes through the same + terminal/settled resolution: `succeeded` → 409 duplicate, `settled` → resume credit (success), + in-flight `pending`/`failed` → 409 duplicate (the concurrent request or the reconcile job will + credit; nothing settled means nothing stranded). +- Non-unique-violation insert errors still propagate. + +## Config added (`apps/api/src/billing/config/env.config.ts`) + +- `X402_RECONCILE_THRESHOLD_SECONDS` (default `300`) — age a `settled` row must reach before the job + reconciles it (keeps the job from racing in-request crediting of a fresh settlement). +- `X402_RECONCILE_INTERVAL_SECONDS` (default `300`) — reconcile scan cadence. +- `X402_RECONCILE_BATCH_SIZE` (default `100`) — max stale rows reconciled per run. + +## Tests (unit project, all green — 21 in `x402.service.spec.ts` + handler + scheduler specs) + +- `x402.service.spec.ts` + - crash-before-credit then reconcile credits exactly once (second reconcile run credits 0); + - same `settled` row driven twice concurrently → `topUpWallet` called once; + - `payment_hash` unique violation on insert → resume-from-settled (success) / in-flight → 409 + duplicate / non-unique errors rethrown; + - backlog reporting and per-row failure counting; + - existing v1 specs updated for the new `markSettledAsSucceeded` gate. +- `x402-reconcile-settled.handler.spec.ts` — queue name/policy/concurrency, drives a pass and + reschedules, reschedules even when the pass throws. +- `x402-reconcile-job.service.spec.ts` — seeds only when enabled, skips when disabled / no pay-to + address, cancel-then-enqueue on schedule. + +Run: + +``` +npx tsc -p tsconfig.build.json --noEmit # clean +npx eslint --quiet # clean +npx vitest run --project=unit \ + src/billing/services/x402/x402.service.spec.ts \ + src/billing/services/x402-reconcile-settled/x402-reconcile-settled.handler.spec.ts \ + src/billing/services/x402-reconcile-job/x402-reconcile-job.service.spec.ts # 21 passed +``` + +## Notes / left for manual follow-up + +- No DB migration was needed: the unique index already exists (migration 0032) and the reconcile job + reuses existing columns/indexes (`status_idx`); `updated_at` is not separately indexed, acceptable + at current volume. +- Live dogfooding against a funded testnet wallet (actually stranding a settlement and watching the + job credit it) is out of scope for this offline branch — the crash-before-credit path is covered + by unit tests instead. +- pg-boss cron is disabled repo-wide (`schedule: false`); the reconcile loop uses the repo's existing + self-rescheduling pattern (`startAfter` + `singletonKey`) rather than pg-boss `schedule()`. diff --git a/doc/x402/RELEASE-NOTES-v1.2.md b/doc/x402/RELEASE-NOTES-v1.2.md new file mode 100644 index 0000000000..fbc0fb9675 --- /dev/null +++ b/doc/x402/RELEASE-NOTES-v1.2.md @@ -0,0 +1,79 @@ +# x402 v1.2 — Testnet path, pre-settle guardrails, thin discovery endpoint + +Branch: `feat/x402-v1-2-sandbox-and-discovery` + +All work is additive on top of the v1 core top-up flow. No breaking changes to the existing +`POST /v1/x402/top-up` contract. + +## 1. Base Sepolia (testnet) support + sandbox firewall + +- `X402_NETWORK` may now be set to a testnet CAIP-2 id such as `eip155:84532` (Base Sepolia), + enabling the full flow against a facilitator without real USDC. +- **Firewall (fail-fast at config-parse time):** a testnet settlement network is only allowed when + the Akash `NETWORK` env is not `mainnet`. If `NETWORK=mainnet` and `X402_NETWORK` is a known + testnet, `envSchema` parsing throws, so the API refuses to boot. This guarantees a testnet + settlement (worthless funds) can never settle and credit a real mainnet balance. +- Testnet classification is a single source of truth: + `apps/api/src/billing/config/x402-networks.ts` (`X402_TESTNET_NETWORKS` / `isX402TestnetNetwork`). + The firewall lives in `apps/api/src/billing/config/env.config.ts` as a `superRefine` on the schema. +- The firewall only blocks the testnet→mainnet direction, so the default mainnet `X402_NETWORK` + (`eip155:8453`) continues to parse on sandbox/testnet deployments (x402 is off by default there). +- Spec: `apps/api/src/billing/config/env.config.spec.ts` — proves mainnet+testnet fails, and + sandbox/testnet configs accept the testnet network, plus the default-on-sandbox case. + +## 2. Pre-settle guardrails in `X402Service` + +After the facilitator verifies a payment (`processHTTPRequest`) but **before** `processSettlement`, +`validatePreSettle` re-validates the requirement about to be settled. On any mismatch the verified +payment is cancelled via the cancellation dispatcher and settlement is never invoked, so a bad +payment is never settled or credited. Explicit 402 codes are returned in the response body: + +- `WRONG_NETWORK` — settled requirement network differs from the configured `X402_NETWORK`, or the + payer's authorized network differs from the requirement. +- `WRONG_ASSET` — the payer authorized a different asset than the requirement. +- `AMOUNT_MISMATCH` — the payer's authorized amount differs from the requirement amount. + +The rejection surfaces as a new `X402TopUpProcessResult` variant `{ type: "payment-rejected", code }` +carried through the controller/router as an HTTP 402 with `{ x402Version, error: , accepts }`. + +- Spec: added cases in `apps/api/src/billing/services/x402/x402.service.spec.ts` proving each code is + returned, `processSettlement`/`topUpWallet` are never called, and the happy path still settles. + +## 3. `GET /v1/x402/discovery` (public) + +- New public (`SECURITY_NONE`, no `@Protected`) endpoint returning the x402 resource list and accepts + (route, scheme, network, asset/amount bounds in USD, `payTo`, `maxTimeoutSeconds`). +- **Single canonical source:** `X402Service.getCanonicalRoutes()` is the one place route + accepts are + declared. Both the 402 flow (`buildRoutesConfig()` → the http-server factory) and discovery + (`getDiscovery()`) derive from it, so the accepts a 402 returns and the accepts discovery advertises + can never drift. +- Spec: `x402.service.spec.ts` asserts the discovery content and that discovery accepts stay in sync + with the routes config passed to the 402 http-server factory (scheme/network/payTo/route). + +## Files touched + +- `apps/api/src/billing/config/x402-networks.ts` (new) +- `apps/api/src/billing/config/env.config.ts` +- `apps/api/src/billing/config/env.config.spec.ts` (new) +- `apps/api/src/billing/services/x402/x402.service.ts` +- `apps/api/src/billing/services/x402/x402.service.spec.ts` +- `apps/api/src/billing/controllers/x402/x402.controller.ts` +- `apps/api/src/billing/routes/x402/x402.router.ts` +- `apps/api/src/billing/http-schemas/x402.schema.ts` + +## Verification + +- `npx tsc -p tsconfig.build.json --noEmit` — clean (apps/api). +- `npx eslint` on touched dirs `--quiet` — clean. +- `npx vitest run --project=unit x402.service.spec.ts env.config.spec.ts` — 18 passed. + +## Notes / left for later + +- Real testnet dogfooding requires a funded testnet wallet + a facilitator that settles on Base + Sepolia; not exercised here (no network access). The code path and config are in place — the manual + step is: set `NETWORK=sandbox`, `X402_ENABLED=true`, `X402_NETWORK=eip155:84532`, + `X402_FACILITATOR_URL=`, and run a signed top-up. +- Testnet network set in `x402-networks.ts` covers common EVM testnets; extend it if a new + settlement testnet is added. +- Discovery expresses amount bounds in USD (`minAmountUsd`/`maxAmountUsd`) since the route price is a + USD amount resolved to an on-chain USDC amount by the scheme/facilitator at 402 time. diff --git a/doc/x402/RELEASE-NOTES-v2.0.md b/doc/x402/RELEASE-NOTES-v2.0.md new file mode 100644 index 0000000000..3b6fdacd40 --- /dev/null +++ b/doc/x402/RELEASE-NOTES-v2.0.md @@ -0,0 +1,93 @@ +# x402 v2.0 — Pay-per-deploy + +Branch: `feat/x402-v2-0-pay-per-deploy` (off `feat/x402-v1-1-money-integrity-gate`) + +## Goal + +One x402-paid call creates **and** funds an Akash deployment with zero prior Console balance. Funds +can never strand: settlement → credit runs through the same money-integrity gate as top-ups, and if +deployment creation fails the money stays spendable in the Console balance (no on-chain reversal). + +## What shipped + +### New endpoint: `POST /v1/x402/deploy` + +Added to the existing `x402Router` (`apps/api/src/billing/routes/x402/x402.router.ts`), operationId +`createUsdcDeployment`. Body `{ sdl, deposit }` — the same shape as `POST /v1/deployments`, where +`deposit` (USD) is both the amount paid via x402 and the deployment funding amount. + +Flow (`X402Service.processDeploy`, `apps/api/src/billing/services/x402/x402.service.ts`): + +1. `processHTTPRequest` verifies the payment (402 + requirements when no `X-PAYMENT`). The x402 price + for the route is derived from the request body's `deposit` (`parseDeposit`, reads `adapter.getBody`). +2. **Abuse controls** run on the verified request, before settlement (see below); an over-limit caller + has their verified payment cancelled and nothing is captured. +3. `settlePaymentAndCredit` — the settle-before-credit path extracted and now shared with `processTopUp`: + settle on-chain, persist `settled`, then credit via `creditSettledTransaction` → + `RefillService.topUpWallet`. This is the **only** funding path; there is no bespoke credit. +4. Drive the existing managed deployment creation service — `DeploymentWriterService.create`, the same + service `DeploymentController.create` uses — with the freshly credited balance. +5. On success: link `deploymentDseq` on the row, return `200` with `{ deploymentDseq, manifest, signTx, … }`. + +### Partial-failure fallback (item 3) + +If settlement + credit succeed but `DeploymentWriterService.create` throws, the funds remain credited. +The row is flagged (`deployFailed = true`, error note stored) and the endpoint returns **HTTP 502** +with code `DEPLOY_FAILED_FUNDS_CREDITED`, telling the caller the money is in their Console balance and +to retry via `POST /v1/deployments`. No on-chain reversal is ever attempted. + +### Schema + migration (item 2) + +`x402_transactions` gains two nullable/defaulted columns +(`apps/api/src/billing/model-schemas/x402-transaction/x402-transaction.schema.ts`): + +- `deployment_dseq varchar(100)` — dseq of the deployment funded by the payment (null for top-ups). +- `deploy_failed boolean NOT NULL DEFAULT false` — set when a paid+credited deployment failed. + +Migration: `apps/api/drizzle/0033_x402_deploy_columns.sql` (generated via `drizzle-kit generate`). + +### Abuse controls (item 4) + +Config-driven, per-user, evaluated against recent (non-failed) `x402_transactions` — no new infra +(`env.config.ts`, repository `countByUserSince` / `sumAmountByUserSince`): + +- `X402_ABUSE_WINDOW_SECONDS` (default 3600) — rolling window. +- `X402_ABUSE_MAX_REQUESTS` (default 10) — request-count rate limit → `429` + `Retry-After`. +- `X402_ABUSE_MAX_SPEND_USD` (default 2000) — cumulative-spend cost ceiling → `402`. +- `X402_MIN_DEPLOY_USD` / `X402_MAX_DEPLOY_USD` (default 1 / 1000) — per-request deposit bounds. + +Auth parity: `X402Controller.deploy` is `@Protected([{ sign UserWallet }, { create X402Payment }])`. +`sign UserWallet` is the exact ability `DeploymentController.create` requires, and the CASL ability set +is identical for bearer and api-key auth (`AbilityService` is role-based, not auth-method-based), so the +api-key path cannot bypass wallet-signing authorization. + +No first-purchase/trial bonus on x402 credits: `topUpWallet` is called with only +`{ payment: { currency, paymentMethodType: "x402", transactionId } }` — no bonus, asserted in specs. + +## Tests + +`apps/api/src/billing/services/x402/x402.service.spec.ts` (mocked facilitator + mocked +`DeploymentWriterService`), all green — 20 tests total, 6 new for deploy: + +- paid-create success links `deploymentDseq`, credits once through `topUpWallet`, drives deploy service. +- paid-but-deploy-failed leaves funds credited (topUpWallet called once) and flags the row via + `markDeployFailed`; returns `deploy-failed`. +- rate limit and cost ceiling each reject before settlement (no `processSettlement`, no `topUpWallet`, + verified payment cancelled). +- duplicate payment → `duplicate-payment`; no-payment → `payment-required`. + +## Verification run + +- `npx tsc -p tsconfig.build.json --noEmit` — clean. +- `npx eslint --quiet` — clean. +- `npx vitest run --project=unit src/billing/services/x402/x402.service.spec.ts` — 20 passed. + +Note: `npx tsc --noEmit` on the full (non-build) tsconfig reports two PRE-EXISTING errors in files this +branch did not touch (`x402-reconcile-settled.handler.spec.ts`, `github-archive.service.spec.ts`). + +## Left for manual/follow-up + +- Real dogfooding needs a funded testnet wallet + live facilitator; implemented and unit-tested here, + but an end-to-end paid deploy against Base/testnet is a manual step. +- Optional: apply the same abuse-control guard to `POST /v1/x402/top-up` (currently deploy-only; the + shared helper makes this a small addition if desired). diff --git a/doc/x402/STATE.md b/doc/x402/STATE.md new file mode 100644 index 0000000000..8d61d97bdb --- /dev/null +++ b/doc/x402/STATE.md @@ -0,0 +1,81 @@ +# x402 Integration — Live State (agent handoff) + +> Updated by whichever agent finishes a work item. Read this first, then [PLAN.md](./PLAN.md). + +## What this project is + +Add x402 (HTTP 402 crypto payment protocol, v2, `@x402/*` SDK 2.19.0) support to Akash Console so +users and AI agents can fund Console credits and deployments with USDC instead of a credit card. +The Console API docs list "Payment method: credit card only" as a limitation — this removes it. + +Repo layout facts that matter (verified against this codebase): + +- Console "credits" are an on-chain `DepositAuthorization` grant from a master funding wallet to a + per-user derived wallet. `RefillService.topUpWallet(amountUsdCents, userId)` + ([refill.service.ts](../../apps/api/src/billing/services/refill/refill.service.ts)) is the single + choke point that converts "money received" into spendable credits. Stripe's webhook is the only + existing caller. x402 slots in as a second payment source ending in the same call. +- `apps/api` is Hono + `@hono/zod-openapi` + tsyringe. New modules follow the `billing/` pattern: + model-schemas (drizzle) → repositories → services → controllers → routes, registered in + `src/routers/open-api-handlers.ts`. +- Auth: `Authorization: Bearer` or `x-api-key`; CASL abilities in + `src/auth/services/ability/ability.service.ts` (an `X402Payment` subject was added). +- Console Air (github.com/akash-network/console-air) is frontend-only (self-custody UI); the x402 + server integration lives here in `console`, which Console Air instances can also point at. + +## Current status + +- **Upstream PR: https://github.com/akash-network/console/pull/3460** (head `opencolin:feat/x402-integration`, base `akash-network:main`). + +- **Review candidate: `feat/x402-integration`** at + `/Users/colin/Code/console-worktrees/x402-integration` — the single integrated branch that merges + all four verified release branches (v1.0 readback/on-ramp, v1.1 money-integrity gate, v1.2 + sandbox/discovery, v2.0 pay-per-deploy) on top of the v1.0 core. This is the branch to review and, + once approved, land. Base of each release remains `feat/x402-usdc-topup` (v1.0 core). +- Merge order used: base `feat/x402-v2-0-pay-per-deploy` (already contains v1.1 + core), then + `git merge feat/x402-v1-0-readback-and-onramp`, then `git merge feat/x402-v1-2-sandbox-and-discovery`. + Conflicts were resolved semantically so every feature works together. +- Endpoints now available on the integrated branch (all on `x402Router`, registered via + `src/routers/open-api-handlers.ts`): + - `POST /v1/x402/top-up?amount=` (`createUsdcTopUp`) — x402-gated wallet top-up. + - `GET /v1/x402/transactions` (`listUsdcTopUps`) — paginated, IDOR-safe read-back of the caller's + own x402 transactions (v1.0). + - `GET /v1/x402/discovery` (`listPayableResources`, public `SECURITY_NONE`) — canonical discovery + document; derived from `X402Service.getCanonicalRoutes()`, the same single source the 402 flow + uses, so it advertises both `POST /v1/x402/top-up` **and** `POST /v1/x402/deploy` (v1.2 + v2.0). + - `POST /v1/x402/deploy` (`createUsdcDeployment`) — pay-per-deploy: one x402-paid call settles, + credits, then creates a funded deployment; partial-failure returns 502 + `DEPLOY_FAILED_FUNDS_CREDITED` with funds left spendable (v2.0). + - Reconcile job (`x402-reconcile-settled`, seeded in `src/app/providers/jobs.provider.ts`) — + re-drives crediting for any `settled`-but-uncredited transaction (v1.1). +- Money-integrity flow on `x402.service.ts`: settle-before-credit → v1.1 conditional + `settled→succeeded` transition (`markSettledAsSucceeded`, exactly-once) → `payment_hash` + unique-violation handling → v1.2 pre-settle guardrails (`validatePreSettle`: + `WRONG_NETWORK`/`WRONG_ASSET`/`AMOUNT_MISMATCH` before settlement) → v2.0 pay-per-deploy path + + per-user rate/cost abuse controls. All error codes unified in + `apps/api/src/billing/services/x402/x402-error-codes.ts` and used by controller, service, router. +- Migrations sequential: `0032_melodic_onslaught.sql` (v1.0 core table) + `0033_x402_deploy_columns.sql` + (v2.0 `deployment_dseq`/`deploy_failed` columns); `_journal.json` in sync (no renumber needed — + v1.0/v1.1/v1.2 added no migration). +- Verification on the integrated branch: `npx tsc -p tsconfig.build.json --noEmit` clean; eslint + `--quiet` clean on all touched x402/billing/config dirs; all x402 + `env.config` unit specs green. + +## Conventions the next agent must follow + +- Read `/Users/colin/Code/console/CLAUDE.md` before writing code (strict TS, tsyringe DI, + `createRoute` + `OpenApiHonoHandler`, `*.spec.ts` colocated, `mock()` from + vitest-mock-extended, `setup()` instead of `beforeEach`, LoggerService not console.log, + conventional commits, before push: `npm test`, `npm run lint -- --quiet`, `npx tsc --noEmit`). +- `@WithTransaction` methods resolve `TxService` from the global container — unit tests must + `container.registerInstance(TxService, mock)` (see `x402.service.spec.ts`). +- Worktrees for parallel release work: `git -C /Users/colin/Code/console worktree add + /Users/colin/Code/console-worktrees/ -b feat/x402-usdc-topup`. +- Do NOT push or open PRs without the user's say-so. + +## Key external references + +- x402 spec/SDK: github.com/x402-foundation/x402 (`@x402/core`, `@x402/evm`, `@x402/hono` — all 2.19.0) +- Server flow used: `x402HTTPResourceServer.processHTTPRequest` → `processSettlement` + (deliberately settle-before-credit, unlike middleware's settle-after-response, because crediting + is irreversible) +- Console API docs: https://akash.network/docs/api-documentation/console-api/getting-started/ diff --git a/doc/x402/USAGE.md b/doc/x402/USAGE.md new file mode 100644 index 0000000000..03151abbe4 --- /dev/null +++ b/doc/x402/USAGE.md @@ -0,0 +1,156 @@ +# x402 USDC top-up — Usage + +Fund your Akash Console balance with USDC over the [x402](https://github.com/x402-foundation/x402) +payment protocol instead of a credit card. This works for both humans and AI agents: any x402-capable +client can pay a `402` challenge and have the settled USDC credited to a Console balance. + +- Endpoints: `POST /v1/x402/top-up` (pay) and `GET /v1/x402/transactions` (read your history back). +- Auth: `Authorization: Bearer ` or `x-api-key: ` — the same auth the rest of the Console API uses. +- Runnable agent client: [`examples/x402/top-up.ts`](../../examples/x402/top-up.ts). + +## Server configuration (`X402_*` env matrix) + +Set these on the `apps/api` service. x402 is **off by default**; it turns on only when both +`X402_ENABLED=true` and a valid `X402_PAY_TO_ADDRESS` are present. + +| Variable | Required | Default | Description | +| ----------------------- | ------------------ | -------------------------------- | ---------------------------------------------------------------------------------------- | +| `X402_ENABLED` | yes (to enable) | `false` | Master switch. `"true"` / `"false"`. | +| `X402_PAY_TO_ADDRESS` | yes (to enable) | — | EVM address (`0x…`, 40 hex) that **receives** the USDC. See custody note below. | +| `X402_NETWORK` | no | `eip155:8453` (Base mainnet) | CAIP-2 network id payments settle on. Use `eip155:84532` (Base Sepolia) for testing. | +| `X402_FACILITATOR_URL` | no | `https://x402.org/facilitator` | x402 facilitator used to verify + settle payments. | +| `X402_MIN_TOP_UP_USD` | no | `1` | Minimum accepted top-up amount, USD. | +| `X402_MAX_TOP_UP_USD` | no | `1000` | Maximum accepted top-up amount, USD. | + +> When `X402_ENABLED=true` but `X402_PAY_TO_ADDRESS` is unset, x402 stays disabled and `POST +> /v1/x402/top-up` returns `404` with code `X402_DISABLED`. + +## The full loop with `curl` + +### 1. Request a top-up with no payment → `402` challenge + +```bash +curl -i -X POST "$CONSOLE_API_BASE/v1/x402/top-up?amount=5" \ + -H "Authorization: Bearer $CONSOLE_API_TOKEN" +``` + +```http +HTTP/1.1 402 Payment Required +Content-Type: application/json + +{ + "x402Version": 2, + "code": "PAYMENT_REQUIRED", + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "5000000", + "payTo": "0xYourTreasuryAddress", + "maxTimeoutSeconds": 300 + } + ] +} +``` + +### 2. Retry with a signed `X-PAYMENT` header → `200` and credit + +Your wallet signs the challenge and produces the base64 `X-PAYMENT` value (the SDK does this for you; +see the example client). Then repeat the request: + +```bash +curl -i -X POST "$CONSOLE_API_BASE/v1/x402/top-up?amount=5" \ + -H "Authorization: Bearer $CONSOLE_API_TOKEN" \ + -H "X-PAYMENT: " +``` + +```http +HTTP/1.1 200 OK +X-PAYMENT-RESPONSE: +Content-Type: application/json + +{ + "data": { + "transactionId": "1a2b3c4d-…", + "amountUsdCents": 500, + "network": "eip155:84532", + "settlementTxHash": "0x…", + "payerAddress": "0x…" + } +} +``` + +The payment is settled **on-chain before** the balance is credited, and crediting is idempotent per +signed payment (identified by a SHA-256 of the payload), so a retried or replayed `X-PAYMENT` never +double-credits. + +### 3. Read your own top-up history back + +```bash +curl -s "$CONSOLE_API_BASE/v1/x402/transactions?limit=25&offset=0" \ + -H "Authorization: Bearer $CONSOLE_API_TOKEN" +``` + +```json +{ + "data": [ + { + "transactionId": "1a2b3c4d-…", + "status": "succeeded", + "amountUsdCents": 500, + "currency": "usd", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "settlementTxHash": "0x…", + "payerAddress": "0x…", + "createdAt": "2026-07-17T12:00:00.000Z" + } + ], + "pagination": { "limit": 25, "offset": 0, "total": 1 } +} +``` + +`GET /v1/x402/transactions` only ever returns the authenticated caller's own transactions — the query +is scoped to your user id server-side, independent of any values you pass. + +## Stable error codes + +Error responses carry a machine-readable `code` field (in addition to the HTTP status) so agents can +branch on it without string-matching messages. These codes are stable API contract. + +| HTTP | `code` | Meaning | +| ---- | ---------------------- | ---------------------------------------------------------------------------- | +| 402 | `PAYMENT_REQUIRED` | No payment attached yet; the body's `accepts[]` describes what to pay. | +| 402 | `PAYMENT_INVALID` | The attached payment failed verification or on-chain settlement. | +| 400 | `AMOUNT_OUT_OF_BOUNDS` | `amount` is below `X402_MIN_TOP_UP_USD` or above `X402_MAX_TOP_UP_USD`. | +| 409 | `DUPLICATE_PAYMENT` | This signed payment was already used to credit a previous top-up. | +| 404 | `X402_DISABLED` | x402 payments are not enabled on this deployment. | + +Example 409 body: + +```json +{ + "error": "Conflict", + "code": "DUPLICATE_PAYMENT", + "message": "This payment was already used for a top-up", + "transactionId": "1a2b3c4d-…" +} +``` + +## Treasury custody note (`X402_PAY_TO_ADDRESS`) + +`X402_PAY_TO_ADDRESS` is the on-chain address that **receives every USDC payment**. Console does not +custody or manage the keys for this address — it is your treasury wallet and you are solely +responsible for its keys, security, and for sweeping/accounting the funds it accumulates. Point it at +an address you control (ideally a multisig or a dedicated treasury account), never at a facilitator, +exchange deposit address, or a wallet whose keys are shared. Payments settle directly to this address; +crediting the Console balance is a separate bookkeeping step performed after settlement is confirmed. + +## Limitation: NO REFUNDS + +**x402 top-ups are non-refundable.** Once a payment settles on-chain and the corresponding Console +balance is credited, the transaction is final and irreversible. There is no automated or manual refund +path for x402 payments: Console cannot return settled USDC, reverse an on-chain settlement, or convert +credited balance back to USDC. Verify the `amount` and the target account before signing. Testnet +defaults (Base Sepolia) exist precisely so you can rehearse the flow without risking real funds. diff --git a/examples/x402/.env.example b/examples/x402/.env.example new file mode 100644 index 0000000000..027b53ace4 --- /dev/null +++ b/examples/x402/.env.example @@ -0,0 +1,21 @@ +# Copy this file to .env and fill in the values, then: npm start +# +# Base URL of the Console API you are topping up against. +# Defaults to the sandbox. Point at your own deployment if self-hosting. +CONSOLE_API_BASE=https://console-api-sandbox.akash.network + +# Console API auth token (Bearer) or API key for the account being credited. +# Create one from the Console UI / API. This identifies WHICH Console balance gets the credit. +CONSOLE_API_TOKEN= + +# Private key of the wallet that PAYS the USDC. TESTNET wallet only. +# Fund it with Base Sepolia testnet USDC + a little Base Sepolia ETH for gas (see README). +# NEVER put a mainnet key with real funds here. +X402_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 + +# Top-up size in USD. Must fall within the server's X402_MIN_TOP_UP_USD / X402_MAX_TOP_UP_USD bounds. +AMOUNT_USD=5 + +# Safety guard: the example refuses to sign a payment whose challenge network is a known mainnet +# unless this is set to true. Leave unset for testnet-only copy-paste safety. +# X402_ALLOW_MAINNET=true diff --git a/examples/x402/README.md b/examples/x402/README.md new file mode 100644 index 0000000000..6e6d44d2e3 --- /dev/null +++ b/examples/x402/README.md @@ -0,0 +1,84 @@ +# Akash Console — x402 USDC top-up example + +A standalone, copy-paste agent client that funds an Akash Console balance with USDC over the +[x402](https://github.com/x402-foundation/x402) payment protocol — no credit card required. + +It runs the full loop: + +1. `POST /v1/x402/top-up?amount=` with no payment → **402 Payment Required** (the challenge). +2. Signs the challenge with your wallet → `X-PAYMENT` header. +3. `POST` again with `X-PAYMENT` → **200**, the payment settles on-chain and your Console balance is credited. +4. `GET /v1/x402/transactions` → reads your own history back and prints the `transactionId`. + +**Defaults are Base Sepolia testnet**, so a copy-paste run can never spend mainnet USDC. The client +also refuses to sign a payment whose challenge advertises a known mainnet network unless you set +`X402_ALLOW_MAINNET=true`. + +This directory is standalone — it has its own `package.json` and is **not** part of the Console +monorepo build. + +## Prerequisites (manual, funded-wallet step) + +You need a **testnet** wallet with a small amount of Base Sepolia USDC and ETH: + +1. Create/choose a throwaway EVM wallet and copy its private key. **Never use a mainnet key with real funds.** +2. Get Base Sepolia ETH (for gas) from a faucet, e.g. the Coinbase or Alchemy Base Sepolia faucet. +3. Get Base Sepolia test USDC from Circle's testnet faucet (https://faucet.circle.com, select Base Sepolia). +4. Create a Console API token/key for the account whose balance you want to credit. + +> This funding step is manual and cannot be automated here — the wallet must hold real testnet +> USDC before the payment can settle. + +## Setup + +```bash +cd examples/x402 +npm install +cp .env.example .env +# edit .env: set CONSOLE_API_TOKEN and X402_PRIVATE_KEY (testnet), adjust AMOUNT_USD +``` + +## Run + +```bash +npm start # node --env-file=.env top-up.ts +# or +npx tsx top-up.ts # if you load env another way +``` + +Expected output (abridged): + +``` +Wallet 0xabc… requesting a $5 top-up at https://console-api-sandbox.akash.network/v1/x402/top-up?amount=5 +Server accepts: 5000000 of 0x036CbD… on eip155:84532 -> 0x… +Payment settled. transactionId=1a2b3c… +Read-back from GET /v1/x402/transactions: +{ "transactionId": "1a2b3c…", "status": "succeeded", "amountUsdCents": 500, … } + +Done. transactionId=1a2b3c… status=succeeded +``` + +## Environment variables + +| Variable | Required | Default | Purpose | +| -------------------- | -------- | ---------------------------------------------- | ------------------------------------------------------------- | +| `CONSOLE_API_TOKEN` | yes | — | Bearer token / API key of the account being credited. | +| `X402_PRIVATE_KEY` | yes | — | **Testnet** private key of the wallet that pays the USDC. | +| `CONSOLE_API_BASE` | no | `https://console-api-sandbox.akash.network` | Console API base URL. | +| `AMOUNT_USD` | no | `5` | Top-up size in USD (must be within the server's min/max). | +| `X402_ALLOW_MAINNET` | no | unset | Set `true` to permit signing on mainnet networks (real USDC). | + +## How it maps to the SDK + +The client is built on the installed x402 v2 packages: + +- `@x402/core/client` — `x402Client` + `x402HTTPClient` (parse the 402, create the payment payload, encode the `X-PAYMENT` header). +- `@x402/evm/exact/client` — `registerExactEvmScheme` to sign `exact`-scheme EVM payments. +- `viem` — `privateKeyToAccount` provides the signer. + +If you prefer the one-liner wrapper, `@x402/fetch`'s `wrapFetchWithPayment(fetch, account)` composes +the same pieces into a drop-in `fetch`; this example uses the lower-level client so the 402 → sign → +retry steps are explicit. + +See [`../../doc/x402/USAGE.md`](../../doc/x402/USAGE.md) for the raw `curl` walkthrough, the full +`X402_*` server env matrix, custody notes, and the no-refund limitation. diff --git a/examples/x402/package.json b/examples/x402/package.json new file mode 100644 index 0000000000..78d733bc3c --- /dev/null +++ b/examples/x402/package.json @@ -0,0 +1,23 @@ +{ + "name": "akash-console-x402-example", + "version": "1.0.0", + "private": true, + "description": "Standalone agent client for the Akash Console x402 USDC top-up flow (402 -> pay -> credit -> read back).", + "type": "module", + "scripts": { + "start": "node --env-file=.env top-up.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@x402/core": "^2.19.0", + "@x402/evm": "^2.19.0", + "viem": "^2.21.0" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=24" + } +} diff --git a/examples/x402/top-up.ts b/examples/x402/top-up.ts new file mode 100644 index 0000000000..f652540d2e --- /dev/null +++ b/examples/x402/top-up.ts @@ -0,0 +1,149 @@ +/** + * Akash Console — x402 USDC top-up example (agent client). + * + * End-to-end loop: + * 1. POST /v1/x402/top-up?amount= -> 402 Payment Required (the challenge) + * 2. sign the challenge with your wallet -> X-PAYMENT header + * 3. POST again with X-PAYMENT -> 200, payment settles on-chain and credits Console + * 4. GET /v1/x402/transactions -> read your own history back, print the transactionId + * + * Defaults are Base Sepolia testnet so a copy-paste run can never spend mainnet USDC. + * Run: node --env-file=.env top-up.ts (Node >= 24 strips the TypeScript types natively) + * or: npx tsx top-up.ts + * + * You must fund the wallet at PRIVATE_KEY with Base Sepolia testnet USDC + a little ETH for gas first. + * See README.md for the faucet + funding steps. + */ +import { x402Client, x402HTTPClient } from "@x402/core/client"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; + +const API_BASE = process.env.CONSOLE_API_BASE ?? "https://console-api-sandbox.akash.network"; +const API_TOKEN = requireEnv("CONSOLE_API_TOKEN"); +const PRIVATE_KEY = requireEnv("X402_PRIVATE_KEY") as `0x${string}`; +const AMOUNT_USD = process.env.AMOUNT_USD ?? "5"; +// CAIP-2 chain ids that move real money. Refuse to sign for these unless explicitly opted in. +const MAINNET_NETWORKS = new Set(["eip155:8453", "eip155:1", "eip155:137", "eip155:42161", "eip155:10"]); +const ALLOW_MAINNET = process.env.X402_ALLOW_MAINNET === "true"; + +async function main(): Promise { + const account = privateKeyToAccount(PRIVATE_KEY); + const client = new x402Client(); + registerExactEvmScheme(client, { signer: account }); + const http = new x402HTTPClient(client); + + const url = `${API_BASE}/v1/x402/top-up?amount=${AMOUNT_USD}`; + const authHeaders = { authorization: `Bearer ${API_TOKEN}` }; + + console.log(`Wallet ${account.address} requesting a $${AMOUNT_USD} top-up at ${url}`); + + // 1. Trigger the 402 challenge (no X-PAYMENT header attached). + const challenge = await fetch(url, { method: "POST", headers: authHeaders }); + + if (challenge.status !== 402) { + throw new Error(`Expected 402 Payment Required, got ${challenge.status}: ${await challenge.text()}`); + } + + const challengeBody: unknown = await challenge.json(); + const paymentRequired = http.getPaymentRequiredResponse(name => challenge.headers.get(name), challengeBody); + + const [firstOption] = paymentRequired.accepts; + if (!firstOption) { + throw new Error("402 challenge advertised no accepted payment requirements"); + } + + console.log(`Server accepts: ${firstOption.amount} of ${firstOption.asset} on ${firstOption.network} -> ${firstOption.payTo}`); + + if (MAINNET_NETWORKS.has(firstOption.network) && !ALLOW_MAINNET) { + throw new Error( + `Refusing to sign a payment on mainnet network ${firstOption.network}. ` + + `This example defaults to testnet. Set X402_ALLOW_MAINNET=true to override (spends real USDC).` + ); + } + + // 2. Sign the payment authorization and encode it as the X-PAYMENT header. + const payload = await http.createPaymentPayload(paymentRequired); + const paymentHeaders = http.encodePaymentSignatureHeader(payload); + + // 3. Retry with the signed payment: this settles on-chain and credits the Console balance. + const paid = await fetch(url, { method: "POST", headers: { ...authHeaders, ...paymentHeaders } }); + const paidBody: unknown = await paid.json(); + + if (paid.status !== 200) { + const code = extractString(paidBody, "code"); + throw new Error(`Top-up failed (${paid.status}${code ? `, code=${code}` : ""}): ${JSON.stringify(paidBody)}`); + } + + const settledTransactionId = extractString(getData(paidBody), "transactionId"); + console.log(`Payment settled. transactionId=${settledTransactionId ?? "(unknown)"}`); + + // 4. Read your own top-up history back and confirm the transaction landed as "succeeded". + const transaction = await pollForTransaction(authHeaders, settledTransactionId); + + if (!transaction) { + console.warn("Payment settled but the transaction was not visible on /v1/x402/transactions yet; retry the poll later."); + return; + } + + console.log("Read-back from GET /v1/x402/transactions:"); + console.log(JSON.stringify(transaction, null, 2)); + console.log(`\nDone. transactionId=${transaction.transactionId} status=${transaction.status}`); +} + +interface TransactionRow { + transactionId: string; + status: string; + amountUsdCents: number; + network: string; + settlementTxHash: string | null; + createdAt: string; +} + +async function pollForTransaction(authHeaders: Record, transactionId: string | undefined): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const res = await fetch(`${API_BASE}/v1/x402/transactions?limit=25&offset=0`, { headers: authHeaders }); + + if (res.status === 200) { + const body = (await res.json()) as { data?: TransactionRow[] }; + const rows = body.data ?? []; + const match = transactionId ? rows.find(row => row.transactionId === transactionId) : rows[0]; + + if (match && (match.status === "succeeded" || match.status === "settled")) { + return match; + } + } + + await sleep(1500); + } + + return undefined; +} + +function getData(body: unknown): unknown { + return body && typeof body === "object" && "data" in body ? (body as { data: unknown }).data : undefined; +} + +function extractString(value: unknown, key: string): string | undefined { + if (value && typeof value === "object" && key in value) { + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; + } + return undefined; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable ${name}. Copy .env.example to .env and fill it in.`); + } + return value; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/examples/x402/tsconfig.json b/examples/x402/tsconfig.json new file mode 100644 index 0000000000..26858edbeb --- /dev/null +++ b/examples/x402/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023", "DOM"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "allowImportingTsExtensions": true + }, + "include": ["top-up.ts"] +} diff --git a/package-lock.json b/package-lock.json index a9bcae77fb..a54b9320bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,9 @@ "@typescript-eslint/parser": "^7.18.0", "@ucast/core": "^1.10.2", "@ucast/mongo2js": "^1.4.0", + "@x402/core": "^2.19.0", + "@x402/evm": "^2.19.0", + "@x402/hono": "^2.19.0", "async-sema": "^3.1.1", "auth0": "^4.16.0", "axios": "^1.7.2", @@ -486,6 +489,25 @@ "undici-types": "~7.16.0" } }, + "apps/api/node_modules/@x402/hono": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@x402/hono/-/hono-2.19.0.tgz", + "integrity": "sha512-t86qO58T4F1V0Rgne4fVukQ7DfK4FBZeM4nA/8D4j9ZJp6bfhq6TAGbe4p3IFGbtX205OxBwK6dj8KU/T7U/6A==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.19.0", + "@x402/extensions": "~2.19.0" + }, + "peerDependencies": { + "@x402/paywall": "^2.19.0", + "hono": "^4.0.0" + }, + "peerDependenciesMeta": { + "@x402/paywall": { + "optional": true + } + } + }, "apps/api/node_modules/cosmjs-types": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/cosmjs-types/-/cosmjs-types-0.11.0.tgz", @@ -1655,6 +1677,16 @@ "pg-types": "^2.2.0" } }, + "apps/deploy-web/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "apps/deploy-web/node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -3634,6 +3666,15 @@ "integrity": "sha512-wqp+0a9eeDj9Tjta0dpZGyQB0FKEVrM6R1h6hd/up04abE4UY/IhgmgEdKOazEV7BCECxWeXY3eI4tZA7SwwNA==", "license": "SEE LICENSE IN LICENSE" }, + "apps/provider-console/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "apps/provider-console/node_modules/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -4826,6 +4867,16 @@ "pg-types": "^2.2.0" } }, + "apps/stats-web/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "apps/stats-web/node_modules/chalk": { "version": "3.0.0", "license": "MIT", @@ -20202,6 +20253,37 @@ "version": "2.0.0", "license": "BSD-3-Clause" }, + "node_modules/@signinwithethereum/siwe": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe/-/siwe-4.2.0.tgz", + "integrity": "sha512-6W+oyKgMFUZRJI4o9P9mTMzrokkXfu3tq1/CfOJj9QrMqyPqujJqv1xnxUAqDQwWVyCA8TMg0Fxk/+gXrDg2Nw==", + "license": "Apache-2.0", + "dependencies": { + "@signinwithethereum/siwe-parser": "^4.2.0" + }, + "peerDependencies": { + "ethers": "^5.7.0 || ^6.13.0", + "viem": "^2.7.0" + }, + "peerDependenciesMeta": { + "ethers": { + "optional": true + }, + "viem": { + "optional": true + } + } + }, + "node_modules/@signinwithethereum/siwe-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe-parser/-/siwe-parser-4.2.0.tgz", + "integrity": "sha512-e3edh8XpZrEjbzVYc0BZ4ySFOa8RKTZOQTafSf1E6ejCB5XcBH93jEpYmjzry1EEocgMNq4KlB3YunsFNCXakQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.7.0", + "apg-js": "^4.4.0" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.1", "license": "MIT", @@ -20341,11 +20423,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -20357,11 +20441,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -20373,11 +20459,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -20389,11 +20477,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -20405,11 +20495,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -20455,11 +20547,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -20471,11 +20565,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -20487,11 +20583,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -20503,11 +20601,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -20519,11 +20619,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -23170,6 +23272,74 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@x402/core": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.19.0.tgz", + "integrity": "sha512-IPQIHzIrwvbri1339QWHvwtiTTDbGsrF5Yff0LZfufmVXNdOSYbjY8oz6vZg7+4W13f7/k81NUMAFoDOU4gswQ==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.19.0.tgz", + "integrity": "sha512-ycbYCjJmLC1I7tRQslAwB73FhJAVMPzsojYunWuR5hk+jKUwgC6djch1ZrKFC1f9lJi7IOiezZtRHj6TWyCCrg==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.19.0", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/extensions": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.19.0.tgz", + "integrity": "sha512-lPPBlABFjvgHxhtqm8pFZpW/mo/1wGT2JbhHSc1rXXFx+xEZZ6qHY8vehssNIRM5cMEP1Nx32j1A3+ME8SKeiA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.0", + "@scure/base": "^1.2.6", + "@signinwithethereum/siwe": "^4.1.0", + "@x402/core": "~2.19.0", + "ajv": "^8.17.1", + "jose": "^5.9.6", + "tweetnacl": "^1.0.3", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/extensions/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@x402/extensions/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@x402/extensions/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@xstate/fsm": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", @@ -23492,6 +23662,12 @@ "node": ">= 8" } }, + "node_modules/apg-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", + "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", + "license": "BSD-2-Clause" + }, "node_modules/append-field": { "version": "1.0.0", "license": "MIT" @@ -27529,6 +27705,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -30722,6 +30899,21 @@ "ws": "*" } }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "dev": true, @@ -36838,6 +37030,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -41929,6 +42122,12 @@ "win32" ] }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "license": "MIT", @@ -42941,6 +43140,105 @@ "node": ">=12" } }, + "node_modules/viem": { + "version": "2.55.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.2.tgz", + "integrity": "sha512-XlJeyNAZ96dQfOHlxLTK1FKgtWw/TtxENKNMBSBgxqALjiWiBWrFmSSzwwMivryKnBwkbt5E+90jSCLnVEilLA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.30", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/viem/node_modules/ox": { + "version": "0.14.30", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", + "integrity": "sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/virtua": { "version": "0.39.3", "license": "MIT", @@ -43967,7 +44265,9 @@ } }, "node_modules/ws": { - "version": "8.18.2", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -45291,6 +45591,16 @@ "vitest": "^4.1.5" } }, + "packages/ui/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "packages/ui/node_modules/agent-base": { "version": "7.1.4", "dev": true,