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" + } + } + } + } + } + } +} 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); +}