228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
import CashuDevKit, { CDKToken } from '../cashu-cdk';
|
|
|
|
export enum MintPaymentStatus {
|
|
IDLE = 'idle',
|
|
REQUESTING = 'requesting',
|
|
PAYING = 'paying',
|
|
SUCCESS = 'success',
|
|
FAILED = 'failed'
|
|
}
|
|
|
|
export interface MintProgressInfo {
|
|
mintUrl: string;
|
|
mintName?: string;
|
|
balance: number;
|
|
selected: boolean;
|
|
status: MintPaymentStatus;
|
|
allocatedAmount?: number;
|
|
feePaid?: number;
|
|
error?: string;
|
|
}
|
|
|
|
export enum MultinutPaymentStep {
|
|
SELECT = 'select',
|
|
PROCESSING = 'processing',
|
|
COMPLETE = 'complete',
|
|
FAILED = 'failed'
|
|
}
|
|
|
|
export type MultimintProgressCallback = (progress: {
|
|
step: MultinutPaymentStep;
|
|
mints: MintProgressInfo[];
|
|
totalSelectedBalance: number;
|
|
isProcessing: boolean;
|
|
}) => void;
|
|
|
|
export const cashuTokenPrefixes = [
|
|
'https://wallet.nutstash.app/#',
|
|
'https://wallet.cashu.me/?token=',
|
|
'web+cashu://',
|
|
'cashu://',
|
|
'cashu:'
|
|
];
|
|
|
|
// Limits to mitigate resource exhaustion from malicious P2PK secret payloads
|
|
const MAX_P2PK_SECRET_LENGTH = 2048;
|
|
const MAX_P2PK_SECRET_DEPTH = 10;
|
|
|
|
/**
|
|
* Safely parse JSON for P2PK secret. Rejects oversized or deeply nested
|
|
* payloads to prevent resource exhaustion (DoS) from attacker-controlled input.
|
|
*/
|
|
function safeParseP2PKSecret(secret: string): unknown | null {
|
|
if (typeof secret !== 'string') return null;
|
|
if (secret.length > MAX_P2PK_SECRET_LENGTH) return null;
|
|
|
|
let depth = 0;
|
|
let maxDepth = 0;
|
|
for (let i = 0; i < secret.length; i++) {
|
|
const c = secret[i];
|
|
if (c === '[' || c === '{') {
|
|
depth++;
|
|
maxDepth = Math.max(maxDepth, depth);
|
|
if (maxDepth > MAX_P2PK_SECRET_DEPTH) return null;
|
|
} else if (c === ']' || c === '}') {
|
|
depth--;
|
|
}
|
|
}
|
|
if (depth !== 0) return null; // unbalanced brackets
|
|
|
|
try {
|
|
return JSON.parse(secret);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class CashuUtils {
|
|
/**
|
|
* Extract raw token string from various URL formats.
|
|
* Supports both cashuA (v3 JSON) and cashuB (v4 CBOR) token prefixes.
|
|
* Accepts both base64url (`-`/`_`) and standard-base64 (`+`/`/`/`=`)
|
|
* alphabets, since real wallets emit either.
|
|
*/
|
|
extractTokenString = (token: string): string => {
|
|
if (!token || typeof token !== 'string') {
|
|
return '';
|
|
}
|
|
token = token.trim();
|
|
|
|
// Greedy-match the cashu prefix + base64 body. This naturally stops
|
|
// at the first non-base64 character, trimming any trailing URL noise
|
|
// like `&memo=foo` that may follow the token in a web wrapper.
|
|
const match = token.match(/cashu[AB][0-9A-Za-z+/_=-]+/);
|
|
if (match) {
|
|
return match[0];
|
|
}
|
|
|
|
// No embedded cashu prefix found — strip a known URL prefix if any.
|
|
for (const prefix of cashuTokenPrefixes) {
|
|
if (token.startsWith(prefix)) {
|
|
token = token.slice(prefix.length).trim();
|
|
break;
|
|
}
|
|
}
|
|
|
|
return token.trim();
|
|
};
|
|
|
|
isValidCashuToken = (token: string): boolean => {
|
|
if (!token || typeof token !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const cleanToken = this.extractTokenString(token);
|
|
if (!cleanToken) {
|
|
return false;
|
|
}
|
|
|
|
// Structural shape check: cashuA/cashuB prefix + base64 body.
|
|
// Cryptographic validation lives in isValidCashuTokenAsync (CDK).
|
|
return /^cashu[AB][0-9A-Za-z+/_=-]{10,}$/.test(cleanToken);
|
|
};
|
|
|
|
isValidCashuTokenAsync = async (token: string): Promise<boolean> => {
|
|
if (!token || typeof token !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const cleanToken = this.extractTokenString(token);
|
|
if (!cleanToken) {
|
|
return false;
|
|
}
|
|
|
|
return await CashuDevKit.isValidToken(cleanToken);
|
|
};
|
|
|
|
sumProofsValue = (proofs: any) => {
|
|
if (!proofs || !Array.isArray(proofs)) {
|
|
return 0;
|
|
}
|
|
return proofs.reduce((r: number, c: any) => {
|
|
const amount = Number(c?.amount) || 0;
|
|
return r + amount;
|
|
}, 0);
|
|
};
|
|
|
|
decodeCashuTokenAsync = async (token: string): Promise<CDKToken> => {
|
|
const cleanToken = this.extractTokenString(token);
|
|
return await CashuDevKit.decodeToken(cleanToken);
|
|
};
|
|
|
|
getTokenValue = async (token: string): Promise<number> => {
|
|
try {
|
|
const decoded = await CashuDevKit.decodeToken(
|
|
this.extractTokenString(token)
|
|
);
|
|
return decoded.value;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
getTokenMintUrl = async (token: string): Promise<string | null> => {
|
|
try {
|
|
const decoded = await CashuDevKit.decodeToken(
|
|
this.extractTokenString(token)
|
|
);
|
|
return decoded.mint_url;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
isTokenP2PKLocked = (token: { proofs?: Array<{ secret: string }> }) => {
|
|
if (!token.proofs || !Array.isArray(token.proofs)) {
|
|
return false;
|
|
}
|
|
for (const proof of token.proofs) {
|
|
if (typeof proof.secret !== 'string') continue;
|
|
if (this.getP2PKPubkeySecret(proof.secret)) {
|
|
const locktime = this.getP2PKLocktime(proof.secret);
|
|
const currentTime = Date.now() / 1000;
|
|
if (!locktime) {
|
|
return true;
|
|
} else if (locktime > currentTime) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
getP2PKPubkeySecret = (secret: string) => {
|
|
const secretObject = safeParseP2PKSecret(secret);
|
|
if (secretObject == null) return undefined;
|
|
if (
|
|
Array.isArray(secretObject) &&
|
|
secretObject[0] === 'P2PK' &&
|
|
secretObject[1] != null &&
|
|
typeof secretObject[1] === 'object' &&
|
|
secretObject[1]['data'] != undefined
|
|
) {
|
|
return secretObject[1]['data'];
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
getP2PKLocktime = (secret: string) => {
|
|
const secretObject = safeParseP2PKSecret(secret);
|
|
if (secretObject == null) return undefined;
|
|
if (
|
|
Array.isArray(secretObject) &&
|
|
secretObject[0] === 'P2PK' &&
|
|
secretObject[1] != null &&
|
|
typeof secretObject[1] === 'object' &&
|
|
Array.isArray(secretObject[1]['tags'])
|
|
) {
|
|
const tag = secretObject[1]['tags'].find(
|
|
([name]: [string, string]) => name === 'locktime'
|
|
);
|
|
return tag ? tag[1] : undefined;
|
|
}
|
|
return undefined;
|
|
};
|
|
}
|
|
|
|
const cashuUtils = new CashuUtils();
|
|
export default cashuUtils;
|