UNPKG

stacks-pay

Version:

A Payment Request Standard for Stacks Blockchain Payments

281 lines (280 loc) 11.2 kB
// src/stacksPay.ts import bech32m from "bech32"; import { base58 } from "@scure/base"; import { generateRandomBytes } from "./utils"; export class StacksPay { /** * Encode a URL into Bech32m format with HRP 'stx' * @param url - The URL to encode * @returns Bech32m-encoded string */ static encode(url) { try { const encoder = new TextEncoder(); const urlBytes = encoder.encode(url); // Uint8Array const words = bech32m.toWords(urlBytes); return bech32m.encode("stx", words); } catch (error) { throw new Error("Error in encoding"); } } /** * Decode a Bech32m-encoded URL back to original form * @param bech32mUrl - The Bech32m-encoded URL * @returns The original URL string */ static decode(bech32mUrl) { try { const { prefix, words } = bech32m.decode(bech32mUrl); if (prefix !== "stx") { throw new Error("Invalid Bech32m encoding or HRP"); } const urlBytes = bech32m.fromWords(words); // number[] const uint8Array = new Uint8Array(urlBytes); const decoder = new TextDecoder(); return decoder.decode(uint8Array); } catch (error) { throw new Error("Invalid Bech32m encoding or HRP"); } } /** * Generates a unique spId using cryptographic random bytes * @returns Base58-encoded spId */ static generateSpId() { const idBuffer = generateRandomBytes(16); // 128-bit ID return base58.encode(idBuffer); } /** * Validate a Stacks address format (basic validation) * @param address - The address to validate * @returns True if valid, else false */ static isValidStacksAddress(address) { return /^SP[0-9A-HJ-NP-Z]{38}$/.test(address); // Simple regex for basic validation of STX address format } /** * Validate the operation type, supporting both standard and custom operations * @param operation - The operation type to validate * @returns True if valid, else false */ static isValidOperation(operation) { const standardOperations = ["pay", "donate", "subscribe", "invoice"]; return (standardOperations.includes(operation.toLowerCase()) || operation.startsWith("custom-")); } /** * Validate a token. It must be 'STX' or a valid SIP-10 contract name. * @param token - The token to validate * @returns True if valid, else false */ static isValidToken(token) { if (token === "STX") { return true; } return this.isValidContractName(token); } /** * Validate contract name format * @param contractName - The contract name to validate * @returns True if valid, else false */ static isValidContractName(contractName) { // Contract name should be in the format 'SP...ADDRESS.CONTRACT_NAME' return /^SP[0-9A-HJ-NP-Z]{38}\.[a-zA-Z0-9_]+$/.test(contractName); } /** * Validate STXTransferParams * @param params - The STXTransferParams to validate */ static validateSTXTransferParams(params) { if (params.amount <= BigInt(0)) { throw new Error("Amount must be greater than zero for STX transfers"); } // Additional validations can be added here } /** * Validate SIP10ContractCallParams * @param params - The SIP10ContractCallParams to validate */ static validateSIP10ContractCallParams(params) { if (!params.functionArgs || params.functionArgs.length === 0) { throw new Error("Function arguments are required for SIP-10 contract calls"); } // Specific validation based on functionName switch (params.functionName) { case "transfer": // Validate TransferFunctionArgs structure // Example: Check if functionArgs match expected types break; case "mint": // Validate MintFunctionArgs structure break; // Add cases for other functions as needed default: throw new Error(`Unsupported function name: ${params.functionName}`); } // Additional validations based on the contract's ABI can be added here } /** * Validate BaseStacksPayParams * @param params - The BaseStacksPayParams to validate */ static validateBaseParams(params) { // Common validations can be placed here if needed // For example, ensure 'recipient', 'description', 'spId', and 'token' are present params.recipient = params.recipient.trim(); params.description = params.description.trim(); params.spId = params.spId.trim(); if (!this.isValidStacksAddress(params.recipient)) { throw new Error("Invalid recipient address"); } } /** * Generates a Bech32m-encoded Stacks Pay URL based on the provided parameters and operation type. * @param params - The transaction parameters * @param operation - The type of operation ('pay', 'donate', etc.) * @returns Bech32m-encoded URL string */ static generate(params, operation) { // Common validations this.validateBaseParams(params); if (params.token === "STX") { this.validateSTXTransferParams(params); } else if (params.token === "SIP10") { this.validateSIP10ContractCallParams(params); } // Construct the URL based on the operation and params const url = new URL(`${this.BASE_URL}${operation}`); url.searchParams.append("recipient", params.recipient); url.searchParams.append("description", params.description); url.searchParams.append("spId", params.spId); url.searchParams.append("token", params.token); if (params.token === "STX") { const stxParams = params; url.searchParams.append("amount", stxParams.amount.toString()); if (stxParams.memo) { url.searchParams.append("memo", stxParams.memo); } } else if (params.token === "SIP10") { const sip10Params = params; url.searchParams.append("contractAddress", sip10Params.contractAddress); url.searchParams.append("contractName", sip10Params.contractName); url.searchParams.append("functionName", sip10Params.functionName); // Serialize functionArgs as JSON url.searchParams.append("functionArgs", JSON.stringify(sip10Params.functionArgs)); if (sip10Params.validateWithAbi !== undefined) { url.searchParams.append("validateWithAbi", sip10Params.validateWithAbi.toString()); } if (sip10Params.postConditions) { url.searchParams.append("postConditions", JSON.stringify(sip10Params.postConditions)); } } // Encode the URL using Bech32m return this.encode(url.toString()); } /** * Parses a Bech32m-encoded Stacks Pay URL and extracts the transaction parameters. * @param bech32mUrl - The Bech32m-encoded URL * @returns The decoded transaction parameters */ static parse(bech32mUrl) { const decodedUrl = this.decode(bech32mUrl); if (!decodedUrl.startsWith(this.BASE_URL)) { throw new Error("Invalid STXPay URL: URL must start with web+stxpay://"); } const url = new URL(decodedUrl); const operation = url.pathname.replace("/", ""); // Validate operation type if (!this.isValidOperation(operation)) { throw new Error("Invalid operation type in the URL"); } const recipient = url.searchParams.get("recipient"); const description = url.searchParams.get("description"); const spId = url.searchParams.get("spId"); const token = url.searchParams.get("token"); if (!recipient || !this.isValidStacksAddress(recipient)) { throw new Error("Invalid or missing recipient address"); } if (!description) { throw new Error("Missing description"); } if (!spId) { throw new Error("Invalid or missing spId"); } if (!token || !this.isValidToken(token)) { throw new Error("Invalid or missing token"); } if (token === "STX") { const amountStr = url.searchParams.get("amount") || "0"; const amount = BigInt(amountStr); const memo = url.searchParams.get("memo") || undefined; return { recipient, description, spId, token: "STX", amount, memo, }; } else if (token === "SIP10") { const contractAddress = url.searchParams.get("contractAddress"); const contractName = url.searchParams.get("contractName"); const functionName = url.searchParams.get("functionName"); const functionArgsStr = url.searchParams.get("functionArgs"); const validateWithAbiStr = url.searchParams.get("validateWithAbi"); const postConditionsStr = url.searchParams.get("postConditions"); if (!contractAddress || !this.isValidContractName(contractAddress)) { throw new Error("Missing or invalid contractAddress for SIP-10 token"); } if (!contractName) { throw new Error("Missing contractName for SIP-10 token"); } if (!functionName) { throw new Error("Missing functionName for SIP-10 token"); } if (!functionArgsStr) { throw new Error("Missing functionArgs for SIP-10 token"); } let functionArgs = []; try { functionArgs = JSON.parse(functionArgsStr); } catch (_a) { throw new Error("Invalid functionArgs format"); } let validateWithAbi = undefined; if (validateWithAbiStr) { validateWithAbi = validateWithAbiStr.toLowerCase() === "true"; } let postConditions = undefined; if (postConditionsStr) { try { postConditions = JSON.parse(postConditionsStr); } catch (_b) { throw new Error("Invalid postConditions format"); } } return { recipient, description, spId, token: "SIP10", contractAddress, contractName, functionName, functionArgs, validateWithAbi, postConditions, }; } throw new Error("Unsupported token type"); } } StacksPay.BASE_URL = "web+stxpay://";