stacks-pay
Version:
A Payment Request Standard for Stacks Blockchain Payments
281 lines (280 loc) • 11.2 kB
JavaScript
// 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://";