cpay-kit
Version:
Composable Pay kit for merchants and users (Avalanche Fuji USDC payments)
380 lines (372 loc) • 9.58 kB
JavaScript
// src/qr.ts
import { Html5Qrcode } from "html5-qrcode";
import QRCode from "qrcode";
async function scanQr(elementId, onScan, { fps = 10, qrbox = { width: 250, height: 250 } } = {}) {
const html5QrCode = new Html5Qrcode(elementId);
let isRunning = false;
try {
const devices = await Html5Qrcode.getCameras();
if (!devices || devices.length === 0) {
throw new Error("No cameras found");
}
const cameraId = devices[0]?.id;
if (!cameraId) {
throw new Error("Camera device ID not found");
}
await html5QrCode.start(
cameraId,
{ fps, qrbox },
(decodedText) => onScan(decodedText),
(error) => console.log("Scan error:", error)
);
isRunning = true;
} catch (err) {
console.error("QR Scanner failed:", err);
}
return {
stop: async () => {
if (isRunning) {
await html5QrCode.stop();
isRunning = false;
}
}
};
}
async function generateQrFromPayload(payload) {
try {
const encoded = btoa(JSON.stringify(payload));
const dataUrl = await QRCode.toDataURL(encoded, {
errorCorrectionLevel: "H",
width: 256,
margin: 4
});
return dataUrl;
} catch (err) {
throw new Error(
`Failed to generate QR: ${err instanceof Error ? err.message : "Unknown"}`
);
}
}
// src/merchant.ts
import { ethers } from "ethers";
// src/abi/StablecoinMerchant.json
var StablecoinMerchant_default = [
{
inputs: [
{
internalType: "address",
name: "_merchant",
type: "address"
},
{
internalType: "address[]",
name: "allowedTokens",
type: "address[]"
}
],
stateMutability: "nonpayable",
type: "constructor"
},
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "bytes32",
name: "paymentId",
type: "bytes32"
},
{
indexed: true,
internalType: "address",
name: "payer",
type: "address"
},
{
indexed: true,
internalType: "address",
name: "token",
type: "address"
},
{
indexed: false,
internalType: "uint256",
name: "amount",
type: "uint256"
}
],
name: "StablecoinPaymentReceived",
type: "event"
},
{
inputs: [],
name: "merchant",
outputs: [
{
internalType: "address",
name: "",
type: "address"
}
],
stateMutability: "view",
type: "function"
},
{
inputs: [
{
internalType: "bytes32",
name: "paymentId",
type: "bytes32"
},
{
internalType: "address",
name: "token",
type: "address"
},
{
internalType: "uint256",
name: "amount",
type: "uint256"
}
],
name: "pay",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{
internalType: "address",
name: "newMerchant",
type: "address"
}
],
name: "setMerchant",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{
internalType: "address",
name: "token",
type: "address"
},
{
internalType: "bool",
name: "allowed",
type: "bool"
}
],
name: "setStablecoin",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{
internalType: "address",
name: "",
type: "address"
}
],
name: "stablecoins",
outputs: [
{
internalType: "bool",
name: "",
type: "bool"
}
],
stateMutability: "view",
type: "function"
}
];
// src/merchant.ts
var CONTRACT_ADDRESS = "0x113efaC740bCDF8BfD033a1C6784a16Fa91Db01e";
async function pollForReceipt(provider, txHash, maxAttempts = 30, interval = 2e3) {
let attempts = 0;
while (attempts < maxAttempts) {
const receipt = await provider.getTransactionReceipt(txHash);
if (receipt) return receipt;
attempts++;
await new Promise((res) => setTimeout(res, interval));
}
throw new Error("Transaction receipt not found after polling");
}
async function generateQrAndListen(payload, provider) {
const qrDataUrl = await generateQrFromPayload(payload);
const contract = new ethers.Contract(
CONTRACT_ADDRESS,
StablecoinMerchant_default,
provider
);
return new Promise((resolve, reject) => {
let timeout;
if (!contract.filters || !contract.filters.StablecoinPaymentReceived) {
reject(new Error("StablecoinPaymentReceived event not found in contract ABI"));
return;
}
const listener = async (...args) => {
try {
const event = args[args.length - 1];
const eventArgs = event.args;
console.log("Event received:", {
paymentId: eventArgs[0],
payer: eventArgs[11],
token: eventArgs[12],
amount: eventArgs[13]
});
if (eventArgs && eventArgs === payload.pid) {
console.log("Payment ID matched!", payload.pid);
clearTimeout(timeout);
contract.off("StablecoinPaymentReceived", listener);
try {
const receipt = await pollForReceipt(provider, event.transactionHash);
resolve({ qrDataUrl, event, eventArgs, receipt });
} catch (err) {
reject(err);
}
} else {
console.log("Payment ID mismatch:", {
received: eventArgs[0],
expected: payload.pid
});
}
} catch (error) {
console.error("Error processing event:", error);
}
};
contract.on?.("StablecoinPaymentReceived", listener);
console.log(`Listening for StablecoinPaymentReceived events on contract: ${CONTRACT_ADDRESS}`);
console.log(`Waiting for payment ID: ${payload.pid}`);
timeout = setTimeout(() => {
contract.off?.("StablecoinPaymentReceived", listener);
reject(new Error("Payment not received within timeout (5 minutes)"));
}, 5 * 60 * 1e3);
});
}
// src/utils.ts
import { isAddress } from "ethers";
function encodePayment(payload) {
const compressedStr = `${payload.paymentId}|${payload.merchantAddress}|${payload.amount}`;
return btoa(compressedStr);
}
function decodePayment(base64String) {
try {
const decoded = Buffer.from(base64String, "base64").toString();
const parts = decoded.split("|");
if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
throw new Error("Invalid compressed payment format");
}
return {
pid: parts[0],
ma: parts[1],
a: parts[2]
};
} catch (error) {
throw new Error("Invalid compressed payment string: " + error.message);
}
}
function validateEVMAddress(address) {
return isAddress(address);
}
function validatePaymentId(paymentId) {
return /^0x[a-fA-F0-9]{64}$/.test(paymentId);
}
function formatAmount(amount, decimals = 18) {
const num = BigInt(amount);
const divisor = BigInt(10 ** decimals);
const quotient = num / divisor;
const remainder = num % divisor;
if (remainder === 0n) {
return quotient.toString();
}
const remainderStr = remainder.toString().padStart(decimals, "0");
return `${quotient}.${remainderStr.replace(/0+$/, "")}`;
}
// src/payment.ts
import { ethers as ethers2 } from "ethers";
async function payFromQR(payload, provider, account) {
const signer = await provider.getSigner();
const contract = new ethers2.Contract(
payload.ma,
StablecoinMerchant_default,
signer
);
let paymentId = payload.pid;
if (typeof paymentId === "string") {
paymentId = ethers2.encodeBytes32String(paymentId);
}
const tx = await contract.pay(paymentId, payload.a);
return await tx.wait();
}
// src/user.ts
async function scanDecodeAndPay(provider, account) {
return new Promise(async (resolve, reject) => {
let scanner;
try {
scanner = await scanQr("reader", async (scannedString) => {
try {
const payload = decodePayment(scannedString);
console.log("Decoded PaymentPayload:", payload);
const receipt = await payFromQR(payload, provider, account);
console.log("Payment successful. Tx receipt:", receipt);
scanner.stop();
resolve(payload);
} catch (err) {
console.error("Failed to decode QR or execute payment:", err);
scanner?.stop();
reject(err);
}
});
} catch (err) {
console.error("Failed to start QR scanner:", err);
reject(err);
}
});
}
// src/qrInput.ts
function parseQRInput(embedding) {
try {
const rawString = extractStringFromEmbedding(embedding);
if (!rawString) {
return {
success: false,
error: "Could not extract string from QR embedding"
};
}
const payload = decodePayment(rawString);
return {
success: true,
payload,
rawString
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to parse QR input"
};
}
}
function extractStringFromEmbedding(embedding) {
if (embedding.startsWith("data:") || embedding.length > 100) {
return embedding;
}
return null;
}
export {
decodePayment,
encodePayment,
formatAmount,
generateQrAndListen,
generateQrFromPayload,
parseQRInput,
scanDecodeAndPay,
scanQr,
validateEVMAddress,
validatePaymentId
};