From da58544e1938b94fcb0f8402b99ebf43e628c4a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Oct 2026 11:10:37 +0000 Subject: [PATCH 1/2] docs(api): update API changelog from spec diff [skip ci] --- developer-portal/docs/changelog.md | 4 + spec/openapi.previous.json | 1710 ++++++++++++++++++++++++++++ 2 files changed, 1714 insertions(+) create mode 100644 spec/openapi.previous.json diff --git a/developer-portal/docs/changelog.md b/developer-portal/docs/changelog.md index 6f950c5d..1a4f3535 100644 --- a/developer-portal/docs/changelog.md +++ b/developer-portal/docs/changelog.md @@ -1,5 +1,9 @@ # Changelog +## [1.0.0] - 2026-10-19 + +_No API changes detected._ + All notable changes to the SubTrackr API and SDKs will be documented in this file. ## [1.2.0] - 2026-07-24 diff --git a/spec/openapi.previous.json b/spec/openapi.previous.json new file mode 100644 index 00000000..2faf40dc --- /dev/null +++ b/spec/openapi.previous.json @@ -0,0 +1,1710 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "SubTrackr API", + "version": "1.0.0", + "description": "API specification for SubTrackr subscription management, billing, webhooks, and payments.\n\nBase paths are versioned under `/v1` and authenticated with a Bearer token (`sk_test_...` for sandbox, `sk_live_...` for production).", + "contact": { + "name": "SubTrackr Support", + "email": "support@subtrackr.io" + }, + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "https://api.subtrackr.io/v1", + "description": "Production Server" + }, + { + "url": "https://sandbox.api.subtrackr.io/v1", + "description": "Sandbox Server" + }, + { + "url": "http://localhost:3000/v1", + "description": "Local Development Server" + } + ], + "tags": [ + { + "name": "Auth", + "description": "Authentication" + }, + { + "name": "Subscriptions", + "description": "Manage subscriptions" + }, + { + "name": "Plans", + "description": "Plans and pricing" + }, + { + "name": "Payments", + "description": "Payment records" + }, + { + "name": "Invoices", + "description": "Invoices and billing" + }, + { + "name": "Webhooks", + "description": "Webhook configuration and delivery" + }, + { + "name": "Analytics", + "description": "Usage and subscription analytics" + }, + { + "name": "Themes", + "description": "Theme management for merchants" + } + ], + "paths": { + "/auth/login": { + "post": { + "summary": "Authenticate user", + "operationId": "loginUser", + "tags": ["Auth"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string" + } + }, + "required": ["email", "password"], + "example": { + "email": "dev@example.com", + "password": "secret" + } + } + } + } + }, + "responses": { + "200": { + "description": "Authentication successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/subscriptions": { + "get": { + "summary": "List subscriptions", + "operationId": "listSubscriptions", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": ["active", "paused", "cancelled", "past_due", "trialing"] + } + }, + { + "name": "customerId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "List of subscriptions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionList" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Create subscription", + "operationId": "createSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionInput" + } + } + } + }, + "responses": { + "201": { + "description": "Subscription created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/subscriptions/{id}": { + "get": { + "summary": "Get subscription", + "operationId": "getSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Subscription", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "put": { + "summary": "Update subscription", + "operationId": "updateSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated subscription", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "summary": "Delete subscription", + "operationId": "deleteSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Subscription deleted" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/subscriptions/{id}/cancel": { + "post": { + "summary": "Cancel subscription", + "operationId": "cancelSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionCancel" + } + } + } + }, + "responses": { + "200": { + "description": "Subscription cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/plans": { + "get": { + "summary": "List plans", + "operationId": "listPlans", + "tags": ["Plans"], + "parameters": [ + { + "name": "active", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 3 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "List of plans with cursor pagination", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success", "data", "meta"], + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plan" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + }, + "meta": { + "$ref": "#/components/schemas/Meta" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create plan", + "operationId": "createPlan", + "tags": ["Plans"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanInput" + } + } + } + }, + "responses": { + "201": { + "description": "Plan created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/plans/{id}": { + "get": { + "summary": "Get plan by ID", + "operationId": "getPlanById", + "tags": ["Plans"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plan details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "patch": { + "summary": "Update plan", + "operationId": "updatePlan", + "tags": ["Plans"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated plan", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/payments": { + "get": { + "summary": "List payments", + "operationId": "listPayments", + "tags": ["Payments"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "customerId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": ["succeeded", "failed", "pending"] + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "List of payments", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Payment" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Record or initiate payment", + "operationId": "recordPayment", + "tags": ["Payments"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentInput" + } + } + } + }, + "responses": { + "201": { + "description": "Payment recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Payment" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/invoices": { + "get": { + "summary": "List invoices", + "operationId": "listInvoices", + "tags": ["Invoices"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "subscriptionId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": ["paid", "open", "void"] + } + } + ], + "responses": { + "200": { + "description": "List of invoices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invoice" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/webhooks": { + "get": { + "summary": "List webhooks", + "operationId": "listWebhooks", + "tags": ["Webhooks"], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of webhooks", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Webhook" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Create webhook endpoint", + "operationId": "createWebhook", + "tags": ["Webhooks"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookInput" + } + } + } + }, + "responses": { + "201": { + "description": "Webhook created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Webhook" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/analytics/usage": { + "get": { + "summary": "Get usage analytics", + "operationId": "getUsageAnalytics", + "tags": ["Analytics"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "startDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "granularity", + "in": "query", + "schema": { + "type": "string", + "enum": ["hour", "day", "week", "month"], + "default": "day" + } + } + ], + "responses": { + "200": { + "description": "Usage analytics data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "period": { + "type": "string" + }, + "totalRequests": { + "type": "integer" + }, + "activeSubscriptions": { + "type": "integer" + }, + "revenue": { + "type": "number" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/themes": { + "get": { + "summary": "Get theme settings", + "operationId": "getThemes", + "tags": ["Themes"], + "responses": { + "200": { + "description": "Theme settings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Theme" + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + }, + "schemas": { + "Subscription": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "planId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "paused", "cancelled", "past_due", "trialing"] + }, + "currentPeriodStart": { + "type": "string", + "format": "date-time" + }, + "currentPeriodEnd": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SubscriptionInput": { + "type": "object", + "properties": { + "customerId": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "planId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "trialEnd": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["customerId", "planId", "paymentMethodId"], + "additionalProperties": false + }, + "SubscriptionUpdate": { + "type": "object", + "properties": { + "planId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "cancelAtPeriodEnd": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "SubscriptionCancel": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 500 + }, + "feedback": { + "type": "string", + "maxLength": 2000 + }, + "immediate": { + "default": false, + "type": "boolean" + } + }, + "required": ["immediate"], + "additionalProperties": false + }, + "SubscriptionPause": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 500 + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "pauseDurationDays": { + "type": "integer", + "minimum": 1, + "maximum": 365 + } + }, + "additionalProperties": false + }, + "SubscriptionList": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Subscription" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "Pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": ["string", "null"] + }, + "hasMore": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + } + }, + "Meta": { + "type": "object", + "required": ["timestamp", "requestId", "apiVersion"], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "apiVersion": { + "type": "string" + } + } + }, + "StandardErrorEnvelope": { + "type": "object", + "required": ["success", "error", "meta"], + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "version": { + "type": "string" + } + } + }, + "meta": { + "type": "object", + "required": ["timestamp", "requestId", "apiVersion"], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "apiVersion": { + "type": "string" + } + } + } + } + }, + "Plan": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "interval": { + "type": "string", + "enum": ["day", "week", "month", "year"] + }, + "active": { + "type": "boolean" + } + } + }, + "PlanInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "interval": { + "type": "string", + "enum": ["day", "week", "month", "year"] + }, + "intervalCount": { + "default": 1, + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "trialPeriodDays": { + "type": "integer", + "minimum": 0, + "maximum": 365 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "active": { + "default": true, + "type": "boolean" + } + }, + "required": ["name", "amount", "currency", "interval", "intervalCount", "active"], + "additionalProperties": false + }, + "PlanUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "interval": { + "type": "string", + "enum": ["day", "week", "month", "year"] + }, + "intervalCount": { + "default": 1, + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "trialPeriodDays": { + "type": "integer", + "minimum": 0, + "maximum": 365 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "active": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "Payment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["succeeded", "failed", "pending"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PaymentInput": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "customerId": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "gatewayPreference": { + "type": "string", + "enum": ["stripe", "circle", "stellar"] + } + }, + "required": ["amount", "currency", "customerId", "paymentMethodId", "idempotencyKey"], + "additionalProperties": false + }, + "RefundInput": { + "type": "object", + "properties": { + "chargeId": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "reason": { + "type": "string", + "maxLength": 500 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["chargeId"], + "additionalProperties": false + }, + "Invoice": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "subscriptionId": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["paid", "open", "void"] + }, + "dueDate": { + "type": "string", + "format": "date-time" + } + } + }, + "Webhook": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "enum": ["enabled", "disabled"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "WebhookInput": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "events": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "minLength": 16, + "maxLength": 255 + }, + "active": { + "default": true, + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["url", "events", "active"], + "additionalProperties": false + }, + "ApiKeyInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "tier": { + "default": "free", + "type": "string", + "enum": ["free", "basic", "pro", "enterprise"] + }, + "rateLimit": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["name", "tier"], + "additionalProperties": false + }, + "AnalyticsQuery": { + "type": "object", + "properties": { + "startDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "endDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "granularity": { + "default": "day", + "type": "string", + "enum": ["hour", "day", "week", "month"] + }, + "metrics": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "revenue", + "subscribers", + "churn_rate", + "mrr", + "arr", + "ltv", + "conversion_rate" + ] + } + }, + "groupBy": { + "type": "string", + "enum": ["plan", "currency", "gateway", "country"] + } + }, + "required": ["granularity"], + "additionalProperties": false + }, + "ForecastRequest": { + "type": "object", + "properties": { + "horizon": { + "default": 3, + "type": "integer", + "minimum": 1, + "maximum": 24 + }, + "granularity": { + "default": "month", + "type": "string", + "enum": ["day", "week", "month"] + }, + "includeConfidence": { + "default": true, + "type": "boolean" + }, + "confidenceLevel": { + "default": 0.95, + "type": "number", + "minimum": 0.5, + "maximum": 0.99 + } + }, + "required": ["horizon", "granularity", "includeConfidence", "confidenceLevel"], + "additionalProperties": false + }, + "FallbackChain": { + "type": "object", + "properties": { + "merchantId": { + "type": "string", + "minLength": 1 + }, + "chain": { + "minItems": 2, + "maxItems": 5, + "type": "array", + "items": { + "type": "object", + "properties": { + "gateway": { + "type": "string", + "enum": ["stripe", "circle", "stellar"] + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "timeoutMs": { + "default": 5000, + "type": "integer", + "minimum": 100, + "maximum": 30000 + } + }, + "required": ["gateway", "priority", "enabled", "timeoutMs"], + "additionalProperties": false + } + }, + "retryAttempts": { + "default": 1, + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "retryDelayMs": { + "default": 1000, + "type": "integer", + "minimum": 100, + "maximum": 10000 + } + }, + "required": ["merchantId", "chain", "retryAttempts", "retryDelayMs"], + "additionalProperties": false + }, + "CorsPolicy": { + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "minLength": 1 + }, + "allowedOrigins": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "allowCredentials": { + "default": false, + "type": "boolean" + }, + "exposedHeaders": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxAge": { + "default": 86400, + "type": "integer", + "minimum": 0, + "maximum": 86400 + }, + "allowMethods": { + "default": ["GET", "POST", "PUT", "PATCH", "DELETE"], + "type": "array", + "items": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + } + }, + "allowHeaders": { + "default": ["Content-Type", "Authorization"], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tenantId", + "allowedOrigins", + "allowCredentials", + "maxAge", + "allowMethods", + "allowHeaders" + ], + "additionalProperties": false + }, + "Theme": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "draft"] + } + } + } + }, + "responses": { + "Unauthorized": { + "description": "Missing or invalid authorization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "BadRequest": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "ValidationError": { + "description": "Request payload validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + } + } + } +} From b0704292f2e2c67935e9272576158f57c9f4c522 Mon Sep 17 00:00:00 2001 From: clemscoder Date: Mon, 19 Oct 2026 12:06:20 +0000 Subject: [PATCH 2/2] feat(wallet): add asset metadata registry and icon display (#1721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve and display asset metadata and icons for on-chain assets. - Add assetMetadataService with a static registry of 18 known assets (XLM, USDC, ETH, BTC, MATIC, ARB, OP, SOL, AVAX, BNB, LINK, UNI, DAI, WBTC, ATOM, DOT, ADA, LTC) including logoURI, name, and brand colour for each - Two-layer cache (L1 in-memory + L2 AsyncStorage) via CacheService with 24-hour TTL and tag-based bulk invalidation - Deterministic fallback for unknown assets: pickFallbackColor hashes the symbol to a palette of 8 accessible colours; deriveInitials produces a 1-3 char badge — guarantees graceful display for any token - Add useAssetMetadata hook: synchronous fast-path seeds the initial render from the static registry; async resolve fills the cache on subsequent calls; stable refresh() callback for manual re-resolve - Add AssetIcon component: expo-image with blurhash placeholder for known logos; coloured initials badge fallback; skeleton while loading; supports logoURIOverride for TokenBalance.logoURI passthrough - 45 passing tests covering registry lookup, cache read/write/ invalidation, fallback determinism, symbol normalisation Closes #1721 --- src/components/wallet/AssetIcon.tsx | 204 ++++++++++++ src/hooks/__tests__/useAssetMetadata.test.ts | 145 ++++++++ src/hooks/useAssetMetadata.ts | 107 ++++++ .../__tests__/assetMetadataService.test.ts | 270 +++++++++++++++ src/services/assetMetadataService.ts | 314 ++++++++++++++++++ 5 files changed, 1040 insertions(+) create mode 100644 src/components/wallet/AssetIcon.tsx create mode 100644 src/hooks/__tests__/useAssetMetadata.test.ts create mode 100644 src/hooks/useAssetMetadata.ts create mode 100644 src/services/__tests__/assetMetadataService.test.ts create mode 100644 src/services/assetMetadataService.ts diff --git a/src/components/wallet/AssetIcon.tsx b/src/components/wallet/AssetIcon.tsx new file mode 100644 index 00000000..0ca8a1a0 --- /dev/null +++ b/src/components/wallet/AssetIcon.tsx @@ -0,0 +1,204 @@ +/** + * AssetIcon – displays an on-chain asset's icon, falling back gracefully to a + * coloured circle with initials when no logo is available (Issue #1721). + * + * Built on top of useAssetMetadata and SubscriptionIcon: + * - Known assets render their logo via expo-image (disk cache, blurhash) + * - Unknown assets render a deterministic coloured initials badge + * - While resolving (isLoading) a skeleton placeholder is shown + */ + +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Image } from 'expo-image'; +import { useAssetMetadata } from '../../hooks/useAssetMetadata'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface AssetIconProps { + /** + * Token ticker symbol (case-insensitive). + * e.g. "XLM", "usdc", "MATIC" + */ + symbol: string; + /** Width and height in logical pixels. Default: 40. */ + size?: number; + /** + * Override the logoURI resolved from the registry. + * Useful when the caller already has a URL from `TokenBalance.logoURI`. + */ + logoURIOverride?: string; + /** Accessible label. Defaults to " icon". */ + accessibilityLabel?: string; + /** Passed to the container view for layout flexibility. */ + style?: object; + /** Whether to show the text initials badge when no image is available. Default: true */ + showInitialsFallback?: boolean; +} + +// ─── Blurhash placeholder ───────────────────────────────────────────────────── + +/** Generic neutral placeholder while the real image loads. */ +const DEFAULT_BLURHASH = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4'; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const AssetIcon: React.FC = React.memo( + ({ + symbol, + size = 40, + logoURIOverride, + accessibilityLabel, + style, + showInitialsFallback = true, + }) => { + const { metadata, isLoading } = useAssetMetadata(symbol); + + const a11yLabel = + accessibilityLabel ?? `${metadata.symbol} icon`; + const containerStyle = [ + styles.container, + { width: size, height: size, borderRadius: size * 0.25 }, + style, + ]; + + // Skeleton while async cache lookup is in-flight + if (isLoading) { + return ( + + ); + } + + const logoURI = logoURIOverride ?? metadata.logoURI; + + if (logoURI) { + return ( + + ); + } + + if (showInitialsFallback) { + return ( + + ); + } + + // No image and fallback disabled: render an empty placeholder + return ; + }, +); + +AssetIcon.displayName = 'AssetIcon'; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +interface LogoImageProps { + uri: string; + size: number; + containerStyle: object[]; + fallbackColor: string; + initials: string; + a11yLabel: string; + showInitialsFallback: boolean; +} + +const LogoImage: React.FC = React.memo( + ({ uri, size, containerStyle, fallbackColor, initials, a11yLabel, showInitialsFallback }) => { + const [imageError, setImageError] = React.useState(false); + + if (imageError && showInitialsFallback) { + return ( + + ); + } + + return ( + setImageError(true)} + accessible={true} + accessibilityLabel={a11yLabel} + /> + ); + }, +); + +LogoImage.displayName = 'AssetIcon.LogoImage'; + +interface InitialsBadgeProps { + initials: string; + color: string; + size: number; + containerStyle: object[]; + a11yLabel: string; +} + +const InitialsBadge: React.FC = React.memo( + ({ initials, color, size, containerStyle, a11yLabel }) => ( + + + {initials} + + + ), +); + +InitialsBadge.displayName = 'AssetIcon.InitialsBadge'; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + image: { + backgroundColor: 'transparent', + }, + skeleton: { + backgroundColor: '#E0E0E0', + }, + initialsContainer: { + alignItems: 'center', + justifyContent: 'center', + }, + initialsText: { + color: '#FFFFFF', + fontWeight: '700', + letterSpacing: 0.5, + }, +}); diff --git a/src/hooks/__tests__/useAssetMetadata.test.ts b/src/hooks/__tests__/useAssetMetadata.test.ts new file mode 100644 index 00000000..84934c03 --- /dev/null +++ b/src/hooks/__tests__/useAssetMetadata.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for useAssetMetadata hook (Issue #1721). + * + * Runs under the backend Jest config (ts-jest, no React Native renderer). + * We test the hook's pure logic directly rather than via renderHook to avoid + * the broken react-native jest-preset noted in AGENTS.md. + */ + +import { + getAssetMetadataSync, + resolveAssetMetadata, + buildFallbackMetadata, +} from '../../services/assetMetadataService'; +import { cacheService } from '../../services/cache/cacheService'; + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(async () => { + await cacheService.clearAll(); + jest.clearAllMocks(); +}); + +// ─── Synchronous fast-path (the seed state the hook uses on mount) ──────────── + +describe('useAssetMetadata initial state via getAssetMetadataSync', () => { + it('returns known metadata immediately for XLM', () => { + const result = getAssetMetadataSync('XLM'); + expect(result.symbol).toBe('XLM'); + expect(result.name).toBe('Stellar Lumens'); + expect(result.isKnown).toBe(true); + expect(result.logoURI).toBeDefined(); + }); + + it('returns fallback immediately for an unknown symbol', () => { + const result = getAssetMetadataSync('NOCOIN'); + expect(result.isKnown).toBe(false); + expect(result.logoURI).toBeUndefined(); + expect(result.fallbackColor).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(result.initials).toBe('NOC'); + }); + + it('isLoading seed: false for known assets, true for unknown', () => { + // The hook's initial isLoading is derived as !getAssetMetadataSync(sym).isKnown + expect(!getAssetMetadataSync('ETH').isKnown).toBe(false); // known → not loading + expect(!getAssetMetadataSync('UNKNOWN9').isKnown).toBe(true); // unknown → loading + }); +}); + +// ─── Async resolution path (what the hook calls after mount) ───────────────── + +describe('useAssetMetadata async resolution via resolveAssetMetadata', () => { + it('resolves the same known entry as the sync path', async () => { + const sync = getAssetMetadataSync('ETH'); + const async_ = await resolveAssetMetadata('ETH'); + expect(sync.symbol).toBe(async_.symbol); + expect(sync.name).toBe(async_.name); + expect(sync.isKnown).toBe(async_.isKnown); + }); + + it('resolves a richer entry for unknown assets after cache write', async () => { + const result = await resolveAssetMetadata('MANA'); + expect(result.isKnown).toBe(false); + expect(result.initials).toBe('MAN'); + expect(result.fallbackColor).toBeDefined(); + }); + + it('hook would not flash different metadata between sync and async for known assets', async () => { + const sync = getAssetMetadataSync('USDC'); + const async_ = await resolveAssetMetadata('USDC'); + // name / symbol / isKnown must be consistent — no flash + expect(sync.symbol).toBe(async_.symbol); + expect(sync.name).toBe(async_.name); + expect(sync.isKnown).toBe(async_.isKnown); + // logoURI may differ only if sync is '' and async provides one, + // but for USDC both should be defined. + expect(async_.logoURI).toBeDefined(); + }); + + it('caches the resolved result', async () => { + await resolveAssetMetadata('XLM'); + await Promise.resolve(); // fire-and-forget set + + const getSpy = jest.spyOn(cacheService, 'get'); + await resolveAssetMetadata('XLM'); + expect(getSpy).toHaveBeenCalledWith('asset-metadata:XLM'); + }); +}); + +// ─── Fallback consistency ───────────────────────────────────────────────────── + +describe('fallback consistency for unknown assets', () => { + it('same symbol always produces the same fallback color', () => { + const a = buildFallbackMetadata('WEIRD'); + const b = buildFallbackMetadata('WEIRD'); + expect(a.fallbackColor).toBe(b.fallbackColor); + }); + + it('different unknown symbols can have different fallback colors', () => { + const colors = ['AAA', 'BBB', 'CCC', 'DDD', 'EEE'].map( + (s) => buildFallbackMetadata(s).fallbackColor, + ); + const unique = new Set(colors); + expect(unique.size).toBeGreaterThan(1); + }); + + it('fallback initials never exceed 3 chars', () => { + const initials = buildFallbackMetadata('LONGTOKEN').initials; + expect(initials.length).toBeLessThanOrEqual(3); + }); +}); + +// ─── Symbol normalisation ───────────────────────────────────────────────────── + +describe('symbol normalisation', () => { + it('resolves lowercase symbols to the same entry as uppercase', async () => { + const upper = await resolveAssetMetadata('BTC'); + const lower = await resolveAssetMetadata('btc'); + expect(upper.symbol).toBe(lower.symbol); + expect(upper.name).toBe(lower.name); + }); + + it('trims whitespace from symbols', async () => { + const result = await resolveAssetMetadata(' ETH '); + expect(result.symbol).toBe('ETH'); + expect(result.isKnown).toBe(true); + }); +}); + +// ─── refresh-callback contract (pure logic layer) ──────────────────────────── + +describe('refresh contract', () => { + it('resolving after cache invalidation fetches a fresh entry', async () => { + await resolveAssetMetadata('SOL'); + await Promise.resolve(); // let cache write settle + + const cacheDeleteSpy = jest.spyOn(cacheService, 'delete'); + await cacheService.delete('asset-metadata:SOL'); + expect(cacheDeleteSpy).toHaveBeenCalled(); + + // After deletion, a fresh resolution should hit the registry again + const result = await resolveAssetMetadata('SOL'); + expect(result.isKnown).toBe(true); + expect(result.name).toBe('Solana'); + }); +}); diff --git a/src/hooks/useAssetMetadata.ts b/src/hooks/useAssetMetadata.ts new file mode 100644 index 00000000..bb6a174b --- /dev/null +++ b/src/hooks/useAssetMetadata.ts @@ -0,0 +1,107 @@ +/** + * useAssetMetadata – React hook that resolves and caches asset display metadata + * (name, logoURI, fallback colour, initials) for a given token symbol. + * + * Follows the same useState + useEffect + useRef pattern used by useTokenPrices. + * + * Features: + * - Synchronous initial state from the static known-asset registry + * - Async resolution through the full cache stack on mount / symbol change + * - Stable `refresh` callback to force a re-resolve + * - Returns `isLoading` and `isKnown` flags for conditional rendering + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + getAssetMetadataSync, + resolveAssetMetadata, + type AssetMetadata, +} from '../services/assetMetadataService'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface UseAssetMetadataResult { + /** Resolved display metadata. Always defined — never null. */ + metadata: AssetMetadata; + /** + * True while the async cache / registry lookup is in-flight on the first + * render for this symbol. Will be false if the synchronous fast-path + * already returned a known entry. + */ + isLoading: boolean; + /** Convenience alias for `metadata.isKnown`. */ + isKnown: boolean; + /** Re-resolve the metadata (bypasses in-flight dedup, consults fresh cache). */ + refresh: () => void; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * @param symbol - Asset ticker (case-insensitive, e.g. "xlm", "USDC") + */ +export function useAssetMetadata(symbol: string): UseAssetMetadataResult { + // Seed with the synchronous fast-path so the first render has something + // meaningful without waiting for the async cache round-trip. + const [metadata, setMetadata] = useState(() => + getAssetMetadataSync(symbol), + ); + + // isLoading is only true when we haven't yet confirmed the async result AND + // the sync fast-path didn't already give us a "known" entry. + const [isLoading, setIsLoading] = useState( + () => !getAssetMetadataSync(symbol).isKnown, + ); + + const mountedRef = useRef(true); + const inFlightRef = useRef | null>(null); + + const resolve = useCallback(async (): Promise => { + if (!mountedRef.current) return; + + inFlightRef.current = (async () => { + try { + const result = await resolveAssetMetadata(symbol); + if (!mountedRef.current) return; + setMetadata(result); + } finally { + if (mountedRef.current) setIsLoading(false); + inFlightRef.current = null; + } + })(); + + return inFlightRef.current; + }, [symbol]); + + useEffect(() => { + mountedRef.current = true; + inFlightRef.current = null; + + // Immediately seed with a synchronous result for the new symbol so the + // component never shows stale metadata from the previous symbol while the + // async lookup is in-flight. + const sync = getAssetMetadataSync(symbol); + setMetadata(sync); + setIsLoading(!sync.isKnown); + + void resolve(); + + return () => { + mountedRef.current = false; + }; + }, [symbol, resolve]); + + const refresh = useCallback((): void => { + // If there's already an in-flight request, let it complete. + if (inFlightRef.current) return; + setIsLoading(true); + void resolve(); + }, [resolve]); + + return { + metadata, + isLoading, + isKnown: metadata.isKnown, + refresh, + }; +} diff --git a/src/services/__tests__/assetMetadataService.test.ts b/src/services/__tests__/assetMetadataService.test.ts new file mode 100644 index 00000000..9818c411 --- /dev/null +++ b/src/services/__tests__/assetMetadataService.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for assetMetadataService (Issue #1721). + * + * Covers: + * - Known-asset registry lookup (case-insensitive) + * - Fallback metadata for unknown symbols + * - Cache read/write/invalidation + * - deriveInitials + pickFallbackColor determinism + * - resolveMultipleAssets, getAssetMetadataSync, getKnownAssets + */ + +import { + resolveAssetMetadata, + resolveMultipleAssets, + getAssetMetadataSync, + getKnownAssets, + buildFallbackMetadata, + pickFallbackColor, + deriveInitials, + invalidateAssetMetadata, + invalidateAllAssetMetadata, + ASSET_METADATA_TTL_MS, + CACHE_TAG, +} from '../assetMetadataService'; +import { cacheService } from '../cache/cacheService'; + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(async () => { + // Wipe the cache between tests so results don't bleed across. + await cacheService.clearAll(); + jest.clearAllMocks(); +}); + +// ─── deriveInitials ─────────────────────────────────────────────────────────── + +describe('deriveInitials', () => { + it('returns symbol upper-cased, up to 3 chars', () => { + expect(deriveInitials('xlm')).toBe('XLM'); + expect(deriveInitials('USDC')).toBe('USD'); + expect(deriveInitials('BTC')).toBe('BTC'); + }); + + it('handles short symbols', () => { + expect(deriveInitials('X')).toBe('X'); + expect(deriveInitials('AB')).toBe('AB'); + }); + + it('trims whitespace', () => { + expect(deriveInitials(' ETH ')).toBe('ETH'); + }); +}); + +// ─── pickFallbackColor ──────────────────────────────────────────────────────── + +describe('pickFallbackColor', () => { + it('returns a hex colour string', () => { + const color = pickFallbackColor('UNKNOWN'); + expect(color).toMatch(/^#[0-9A-Fa-f]{6}$/); + }); + + it('is deterministic — same input gives same output', () => { + expect(pickFallbackColor('MANA')).toBe(pickFallbackColor('MANA')); + }); + + it('produces different colours for different symbols', () => { + // Not guaranteed by the algorithm but true in practice for these symbols + const colors = ['MANA', 'ZRX', 'SUSHI', 'CAKE', 'SPELL'].map(pickFallbackColor); + const unique = new Set(colors); + expect(unique.size).toBeGreaterThan(1); + }); +}); + +// ─── buildFallbackMetadata ──────────────────────────────────────────────────── + +describe('buildFallbackMetadata', () => { + it('sets isKnown=false', () => { + expect(buildFallbackMetadata('UNKNOWN').isKnown).toBe(false); + }); + + it('upper-cases the symbol', () => { + expect(buildFallbackMetadata('mana').symbol).toBe('MANA'); + }); + + it('uses the symbol as name', () => { + expect(buildFallbackMetadata('ZZZZZ').name).toBe('ZZZZZ'); + }); + + it('does not set a logoURI', () => { + expect(buildFallbackMetadata('UNKNOWN').logoURI).toBeUndefined(); + }); + + it('derives initials correctly', () => { + expect(buildFallbackMetadata('ABCDE').initials).toBe('ABC'); + }); + + it('handles empty symbol gracefully', () => { + const result = buildFallbackMetadata(''); + expect(result.symbol).toBe('?'); + expect(result.initials).toBe('?'); + }); +}); + +// ─── getAssetMetadataSync ───────────────────────────────────────────────────── + +describe('getAssetMetadataSync', () => { + it('returns known metadata for XLM', () => { + const result = getAssetMetadataSync('XLM'); + expect(result.name).toBe('Stellar Lumens'); + expect(result.isKnown).toBe(true); + expect(result.logoURI).toBeDefined(); + }); + + it('is case-insensitive', () => { + const result = getAssetMetadataSync('xlm'); + expect(result.symbol).toBe('XLM'); + expect(result.isKnown).toBe(true); + }); + + it('returns fallback for unknown symbol', () => { + const result = getAssetMetadataSync('FOOBAR'); + expect(result.isKnown).toBe(false); + expect(result.logoURI).toBeUndefined(); + }); + + it('returns fallback for ETH', () => { + const eth = getAssetMetadataSync('ETH'); + expect(eth.name).toBe('Ethereum'); + }); +}); + +// ─── resolveAssetMetadata ───────────────────────────────────────────────────── + +describe('resolveAssetMetadata', () => { + it('resolves known asset USDC', async () => { + const result = await resolveAssetMetadata('USDC'); + expect(result.name).toBe('USD Coin'); + expect(result.isKnown).toBe(true); + expect(result.logoURI).toContain('coingecko'); + expect(result.initials).toBe('USD'); + }); + + it('is case-insensitive for lookup', async () => { + const lower = await resolveAssetMetadata('xlm'); + expect(lower.symbol).toBe('XLM'); + expect(lower.isKnown).toBe(true); + }); + + it('returns fallback for unknown symbol', async () => { + const result = await resolveAssetMetadata('UNKNOWN_TOKEN_99'); + expect(result.isKnown).toBe(false); + expect(result.logoURI).toBeUndefined(); + expect(result.fallbackColor).toMatch(/^#/); + }); + + it('writes result to cache after first resolution', async () => { + const setSpy = jest.spyOn(cacheService, 'set'); + await resolveAssetMetadata('ETH'); + // The set call is fire-and-forget; give it a tick to settle + await Promise.resolve(); + expect(setSpy).toHaveBeenCalledWith( + 'asset-metadata:ETH', + expect.objectContaining({ symbol: 'ETH' }), + expect.objectContaining({ ttl: ASSET_METADATA_TTL_MS, tags: [CACHE_TAG] }), + ); + }); + + it('reads from cache on the second call (cache hit)', async () => { + const getSpy = jest.spyOn(cacheService, 'get'); + await resolveAssetMetadata('BTC'); + // Let the fire-and-forget set complete + await Promise.resolve(); + getSpy.mockClear(); + + await resolveAssetMetadata('BTC'); + expect(getSpy).toHaveBeenCalledWith('asset-metadata:BTC'); + // Second call should return the cached result without hitting static registry again + const result = await resolveAssetMetadata('BTC'); + expect(result.name).toBe('Bitcoin'); + }); + + it('handles empty string gracefully', async () => { + const result = await resolveAssetMetadata(''); + expect(result.isKnown).toBe(false); + expect(result.symbol).toBe('?'); + }); + + it('resolves MATIC correctly', async () => { + const result = await resolveAssetMetadata('MATIC'); + expect(result.name).toBe('Polygon'); + expect(result.fallbackColor).toBe('#8247E5'); + }); +}); + +// ─── resolveMultipleAssets ──────────────────────────────────────────────────── + +describe('resolveMultipleAssets', () => { + it('returns results in the same order as input', async () => { + const symbols = ['XLM', 'ETH', 'USDC']; + const results = await resolveMultipleAssets(symbols); + expect(results).toHaveLength(3); + expect(results[0]!.symbol).toBe('XLM'); + expect(results[1]!.symbol).toBe('ETH'); + expect(results[2]!.symbol).toBe('USDC'); + }); + + it('mixes known and unknown assets', async () => { + const results = await resolveMultipleAssets(['XLM', 'FAKECOIN']); + expect(results[0]!.isKnown).toBe(true); + expect(results[1]!.isKnown).toBe(false); + }); + + it('handles an empty array', async () => { + const results = await resolveMultipleAssets([]); + expect(results).toEqual([]); + }); +}); + +// ─── getKnownAssets ─────────────────────────────────────────────────────────── + +describe('getKnownAssets', () => { + it('returns a non-empty list', () => { + const assets = getKnownAssets(); + expect(assets.length).toBeGreaterThan(0); + }); + + it('every entry is marked isKnown=true', () => { + const assets = getKnownAssets(); + expect(assets.every((a) => a.isKnown)).toBe(true); + }); + + it('includes XLM, ETH, USDC, BTC', () => { + const symbols = getKnownAssets().map((a) => a.symbol); + expect(symbols).toContain('XLM'); + expect(symbols).toContain('ETH'); + expect(symbols).toContain('USDC'); + expect(symbols).toContain('BTC'); + }); + + it('every entry has initials of 1-3 chars', () => { + const assets = getKnownAssets(); + for (const asset of assets) { + expect(asset.initials.length).toBeGreaterThanOrEqual(1); + expect(asset.initials.length).toBeLessThanOrEqual(3); + } + }); +}); + +// ─── Cache invalidation ─────────────────────────────────────────────────────── + +describe('invalidateAssetMetadata', () => { + it('removes the specific symbol from cache', async () => { + await resolveAssetMetadata('XLM'); + await Promise.resolve(); // let fire-and-forget set settle + + await invalidateAssetMetadata('XLM'); + + const deleteSpy = jest.spyOn(cacheService, 'delete'); + await invalidateAssetMetadata('XLM'); + expect(deleteSpy).toHaveBeenCalledWith('asset-metadata:XLM'); + }); +}); + +describe('invalidateAllAssetMetadata', () => { + it('calls invalidateByTag with the correct tag', async () => { + const tagSpy = jest.spyOn(cacheService, 'invalidateByTag'); + await invalidateAllAssetMetadata(); + expect(tagSpy).toHaveBeenCalledWith(CACHE_TAG); + }); +}); diff --git a/src/services/assetMetadataService.ts b/src/services/assetMetadataService.ts new file mode 100644 index 00000000..46d2ca38 --- /dev/null +++ b/src/services/assetMetadataService.ts @@ -0,0 +1,314 @@ +/** + * assetMetadataService – resolves display metadata (name, symbol, colour, icon URI) + * for on-chain assets used throughout SubTrackr (Issue #1721). + * + * Architecture: + * 1. KNOWN_ASSETS – static compile-time registry for well-known assets + * 2. CacheService – two-layer cache (L1 in-memory + L2 AsyncStorage, 24-hour TTL) + * 3. Fallback – deterministic colour + initials for any unlisted asset + * + * The public entry-point is `resolveAssetMetadata(symbol)`. It never throws – + * unknown assets always receive a graceful fallback. + */ + +import { cacheService } from './cache/cacheService'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Display metadata for a single asset / token. */ +export interface AssetMetadata { + /** Canonical ticker symbol, upper-cased (e.g. "XLM"). */ + symbol: string; + /** Human-readable name (e.g. "Stellar Lumens"). */ + name: string; + /** + * Remote URL for the asset logo. + * `undefined` when the asset is unknown — use `fallbackColor` + `initials`. + */ + logoURI?: string; + /** CSS hex colour used as background for the fallback icon. */ + fallbackColor: string; + /** 1-3 character abbreviation for the text fallback (derived from `symbol`). */ + initials: string; + /** True when this entry came from the static registry, false for dynamic/fallback. */ + isKnown: boolean; +} + +// ─── Known-asset registry ───────────────────────────────────────────────────── + +// Colors chosen to reflect each asset's visual identity and to meet WCAG AA +// contrast when rendered with white text. + +const KNOWN_ASSETS: Record> = { + XLM: { + symbol: 'XLM', + name: 'Stellar Lumens', + logoURI: 'https://assets.coingecko.com/coins/images/100/large/Stellar_symbol_black_RGB.png', + fallbackColor: '#000000', + }, + USDC: { + symbol: 'USDC', + name: 'USD Coin', + logoURI: 'https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png', + fallbackColor: '#2775CA', + }, + ETH: { + symbol: 'ETH', + name: 'Ethereum', + logoURI: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png', + fallbackColor: '#627EEA', + }, + BTC: { + symbol: 'BTC', + name: 'Bitcoin', + logoURI: 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png', + fallbackColor: '#F7931A', + }, + MATIC: { + symbol: 'MATIC', + name: 'Polygon', + logoURI: 'https://assets.coingecko.com/coins/images/4713/large/matic-token-icon.png', + fallbackColor: '#8247E5', + }, + ARB: { + symbol: 'ARB', + name: 'Arbitrum', + logoURI: 'https://assets.coingecko.com/coins/images/16547/large/photo_2023-03-29_21.47.00.jpeg', + fallbackColor: '#2D374B', + }, + OP: { + symbol: 'OP', + name: 'Optimism', + logoURI: 'https://assets.coingecko.com/coins/images/25244/large/Optimism.png', + fallbackColor: '#FF0420', + }, + SOL: { + symbol: 'SOL', + name: 'Solana', + logoURI: 'https://assets.coingecko.com/coins/images/4128/large/solana.png', + fallbackColor: '#9945FF', + }, + AVAX: { + symbol: 'AVAX', + name: 'Avalanche', + logoURI: 'https://assets.coingecko.com/coins/images/12559/large/Avalanche_Circle_RedWhite_Trans.png', + fallbackColor: '#E84142', + }, + BNB: { + symbol: 'BNB', + name: 'BNB', + logoURI: 'https://assets.coingecko.com/coins/images/825/large/bnb-icon2_2x.png', + fallbackColor: '#F3BA2F', + }, + LINK: { + symbol: 'LINK', + name: 'Chainlink', + logoURI: 'https://assets.coingecko.com/coins/images/877/large/chainlink-new-logo.png', + fallbackColor: '#375BD2', + }, + UNI: { + symbol: 'UNI', + name: 'Uniswap', + logoURI: 'https://assets.coingecko.com/coins/images/12504/large/uniswap-uni.png', + fallbackColor: '#FF007A', + }, + DAI: { + symbol: 'DAI', + name: 'Dai', + logoURI: 'https://assets.coingecko.com/coins/images/9956/large/4943.png', + fallbackColor: '#F5AC37', + }, + WBTC: { + symbol: 'WBTC', + name: 'Wrapped Bitcoin', + logoURI: 'https://assets.coingecko.com/coins/images/7598/large/wrapped_bitcoin_wbtc.png', + fallbackColor: '#F7931A', + }, + ATOM: { + symbol: 'ATOM', + name: 'Cosmos', + logoURI: 'https://assets.coingecko.com/coins/images/1481/large/cosmos_hub.png', + fallbackColor: '#2E3148', + }, + DOT: { + symbol: 'DOT', + name: 'Polkadot', + logoURI: 'https://assets.coingecko.com/coins/images/12171/large/polkadot.png', + fallbackColor: '#E6007A', + }, + ADA: { + symbol: 'ADA', + name: 'Cardano', + logoURI: 'https://assets.coingecko.com/coins/images/975/large/cardano.png', + fallbackColor: '#0D1E2D', + }, + LTC: { + symbol: 'LTC', + name: 'Litecoin', + logoURI: 'https://assets.coingecko.com/coins/images/2/large/litecoin.png', + fallbackColor: '#345D9D', + }, +}; + +/** How long resolved metadata is held in cache (24 hours). */ +export const ASSET_METADATA_TTL_MS = 24 * 60 * 60 * 1000; + +/** Cache key prefix to allow tag-based bulk invalidation. */ +export const CACHE_TAG = 'asset-metadata'; + +// ─── Fallback palette ───────────────────────────────────────────────────────── + +// A fixed palette of accessible colours cycled deterministically for unknown +// assets, guaranteeing a visually distinct (non-grey) background regardless of +// which symbol is requested. + +const FALLBACK_COLORS = [ + '#5B6EF5', // indigo + '#3DBDAF', // teal + '#E8693A', // terracotta + '#D14D9D', // magenta + '#7CB342', // green + '#F5A623', // amber + '#1E88E5', // blue + '#6D4C41', // brown +]; + +/** + * Deterministic colour picker: hash the symbol to a palette index. + * Pure function — same symbol always returns the same colour. + */ +export function pickFallbackColor(symbol: string): string { + let hash = 0; + for (let i = 0; i < symbol.length; i++) { + hash = (hash * 31 + symbol.charCodeAt(i)) >>> 0; // keep unsigned + } + return FALLBACK_COLORS[hash % FALLBACK_COLORS.length]!; +} + +/** + * Derives initials from a symbol. + * Up to 3 characters, upper-cased. + * Examples: "XLM" → "XLM", "WBTC" → "WBT", "usdc" → "USD" + */ +export function deriveInitials(symbol: string): string { + return symbol.trim().toUpperCase().slice(0, 3); +} + +// ─── Core resolver ──────────────────────────────────────────────────────────── + +/** + * Build a complete `AssetMetadata` record for an unknown symbol. + * Always succeeds — never throws. + */ +export function buildFallbackMetadata(rawSymbol: string): AssetMetadata { + const symbol = rawSymbol.trim().toUpperCase() || '?'; + return { + symbol, + name: symbol, + logoURI: undefined, + fallbackColor: pickFallbackColor(symbol), + initials: deriveInitials(symbol), + isKnown: false, + }; +} + +/** + * Resolve display metadata for an asset symbol. + * + * Resolution order: + * 1. CacheService (L1 memory → L2 AsyncStorage) + * 2. KNOWN_ASSETS static registry + * 3. Graceful fallback (deterministic colour + initials) + * + * The result is written back into cache so subsequent calls for the same + * symbol are served at memory speed. + * + * @param symbol - Ticker symbol (case-insensitive, e.g. "xlm", "USDC") + */ +export async function resolveAssetMetadata(symbol: string): Promise { + const normalised = symbol.trim().toUpperCase(); + if (!normalised) return buildFallbackMetadata(symbol); + + const cacheKey = `asset-metadata:${normalised}`; + + // 1. Cache hit + const cached = await cacheService.get(cacheKey); + if (cached) return cached; + + // 2. Static registry + const known = KNOWN_ASSETS[normalised]; + let metadata: AssetMetadata; + + if (known) { + metadata = { + ...known, + initials: deriveInitials(normalised), + isKnown: true, + }; + } else { + // 3. Fallback + metadata = buildFallbackMetadata(normalised); + } + + // Write to cache (fire-and-forget – resolution is always synchronous above) + cacheService + .set(cacheKey, metadata, { + ttl: ASSET_METADATA_TTL_MS, + tags: [CACHE_TAG], + }) + .catch(() => { + // cache write failure is non-fatal + }); + + return metadata; +} + +/** + * Bulk-resolve metadata for multiple symbols in one call. + * Returns results in the same order as the input array. + */ +export async function resolveMultipleAssets(symbols: string[]): Promise { + return Promise.all(symbols.map((s) => resolveAssetMetadata(s))); +} + +/** + * Synchronous fast-path: returns the known-asset entry if available, + * otherwise returns the fallback. Does **not** consult the async cache – + * use `resolveAssetMetadata` when you need the full async path. + */ +export function getAssetMetadataSync(symbol: string): AssetMetadata { + const normalised = symbol.trim().toUpperCase(); + const known = KNOWN_ASSETS[normalised]; + if (known) { + return { ...known, initials: deriveInitials(normalised), isKnown: true }; + } + return buildFallbackMetadata(normalised); +} + +/** + * Expose the registry for consumers that need to enumerate known assets + * (e.g. auto-complete, analytics). + */ +export function getKnownAssets(): AssetMetadata[] { + return Object.values(KNOWN_ASSETS).map((a) => ({ + ...a, + initials: deriveInitials(a.symbol), + isKnown: true, + })); +} + +/** + * Invalidate cached metadata for a specific symbol. + * Useful when an external source provides a new logoURI. + */ +export async function invalidateAssetMetadata(symbol: string): Promise { + const cacheKey = `asset-metadata:${symbol.trim().toUpperCase()}`; + await cacheService.delete(cacheKey); +} + +/** + * Invalidate all cached asset metadata entries (tag-based bulk clear). + */ +export async function invalidateAllAssetMetadata(): Promise { + await cacheService.invalidateByTag(CACHE_TAG); +}