diff --git a/README.md b/README.md index bd65219..ae13e14 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Decode a Cashu token and return its content. Supports both v1 and v3 token forma **Request:** ```json { - "token": "cashuAeyJhbGciOi..." + "token": "cashuB..." } ``` @@ -47,7 +47,8 @@ Decode a Cashu token and return its content. Supports both v1 and v3 token forma "totalAmount": 21000, "numProofs": 3, "denominations": [1000, 10000, 10000], - "format": "cashuA" + "format": "cashuA", + "spent": false }, "mint_url": "https://mint.azzamo.net" } @@ -59,7 +60,7 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if **Request:** ```json { - "token": "cashuAeyJhbGciOi...", + "token": "cashuB...", "lightningAddress": "user@ln.tips" } ``` @@ -67,7 +68,7 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if **Request (using default address):** ```json { - "token": "cashuAeyJhbGciOi..." + "token": "cashuB..." } ``` @@ -75,7 +76,6 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if ```json { "success": true, - "redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762", "paid": true, "amount": 21000, "invoiceAmount": 20580, @@ -93,7 +93,6 @@ Redeem a Cashu token to a Lightning address. Lightning address is optional - if ```json { "success": true, - "redeemId": "8e99101e-d034-4d2e-9ccf-dfda24d26762", "paid": true, "amount": 21000, "invoiceAmount": 20580, diff --git a/env.example b/env.example index 6a16e66..c2fc6c7 100644 --- a/env.example +++ b/env.example @@ -4,7 +4,7 @@ NODE_ENV=development # Security Configuration ALLOW_REDEEM_DOMAINS=* -API_SECRET=your-secret-key-here + # Default Lightning Address (used when no address is provided in redeem requests) DEFAULT_LIGHTNING_ADDRESS=admin@your-domain.com diff --git a/package-lock.json b/package-lock.json index 125a54f..bc7f36a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,21 @@ { "name": "cashu-redeem-api", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashu-redeem-api", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@cashu/cashu-ts": "^1.1.0", "axios": "^1.7.7", + "bolt11": "^1.4.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^10.0.0" @@ -25,6 +27,10 @@ "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/Michilis" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -431,6 +437,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@types/bn.js": { + "version": "4.11.6", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.6.tgz", + "integrity": "sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -444,6 +458,14 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.0.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", + "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", + "dependencies": { + "undici-types": "~7.8.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -545,6 +567,20 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", @@ -562,6 +598,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -582,6 +623,11 @@ ], "license": "MIT" }, + "node_modules/bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -595,6 +641,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bip174": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", + "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^2.1.1", + "bs58check": "^3.0.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -619,6 +699,21 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bolt11": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bolt11/-/bolt11-1.4.1.tgz", + "integrity": "sha512-jR0Y+MO+CK2at1Cg5mltLJ+6tdOwNKoTS/DJOBDdzVkQ+R9D6UgZMayTWOsuzY7OgV1gEqlyT5Tzk6t6r4XcNQ==", + "dependencies": { + "@types/bn.js": "^4.11.3", + "bech32": "^1.1.2", + "bitcoinjs-lib": "^6.0.0", + "bn.js": "^4.11.8", + "create-hash": "^1.2.0", + "lodash": "^4.17.11", + "safe-buffer": "^5.1.1", + "secp256k1": "^4.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -642,6 +737,28 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" + }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -675,6 +792,23 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -775,6 +909,18 @@ "node": ">= 6" } }, + "node_modules/cipher-base": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", + "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -871,6 +1017,18 @@ "node": ">= 0.10" } }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -902,6 +1060,22 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -974,6 +1148,20 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1281,6 +1469,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.0.tgz", + "integrity": "sha512-FXEAp2ccTeN1ZSO+sPHRHWB0/CrTP5asFBjUaNeD9A0v3iPmgFbLu24vqPjiM9utszI58VGlMokjXQ0W9Dbmjw==", + "dependencies": { + "ip": "2.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1404,6 +1609,20 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", @@ -1573,6 +1792,17 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1600,6 +1830,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-base": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", + "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1612,6 +1864,16 @@ "node": ">= 0.4" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1721,6 +1983,11 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1743,6 +2010,17 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1776,6 +2054,25 @@ "node": ">=0.12.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1856,6 +2153,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -1892,6 +2194,16 @@ "node": ">= 0.4" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1952,6 +2264,16 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1986,6 +2308,21 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -2242,6 +2579,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2327,6 +2672,19 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2350,6 +2708,15 @@ "node": ">=4" } }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2376,6 +2743,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secp256k1": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.4.tgz", + "integrity": "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==", + "hasInstallScript": true, + "dependencies": { + "elliptic": "^6.5.7", + "node-addon-api": "^5.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -2443,12 +2824,47 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2566,6 +2982,14 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2648,6 +3072,19 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/to-buffer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", + "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2706,6 +3143,24 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2713,6 +3168,11 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2732,6 +3192,11 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2763,6 +3228,14 @@ "node": ">= 0.10" } }, + "node_modules/varuint-bitcoin": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", + "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==", + "dependencies": { + "safe-buffer": "^5.1.1" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2788,6 +3261,26 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 09ae851..c2dba2b 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "cashu-redeem-api", - "version": "1.0.0", - "description": "Redeem ecash (Cashu tokens) to Lightning Address using cashu-ts library and LNURLp", + "version": "1.1.0", + "description": "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol", "main": "server.js", "scripts": { "start": "node server.js", "dev": "nodemon server.js", "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "docs": "echo \"API documentation available at http://localhost:3000/docs\"" }, "keywords": [ "cashu", @@ -19,13 +20,22 @@ "lnurl", "lnurlp", "mint", - "satoshi" + "satoshi", + "bolt11", + "lightning-network", + "payment", + "redemption" ], - "author": "", + "author": "Michilis", "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/Michilis" + }, "dependencies": { "@cashu/cashu-ts": "^1.1.0", "axios": "^1.7.7", + "bolt11": "^1.4.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -34,8 +44,8 @@ "uuid": "^10.0.0" }, "devDependencies": { - "nodemon": "^3.1.4", - "eslint": "^9.9.1" + "eslint": "^9.9.1", + "nodemon": "^3.1.4" }, "engines": { "node": ">=18.0.0", @@ -43,10 +53,10 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/yourusername/cashu-redeem-api.git" + "url": "git+https://github.com/Michilis/cashu-redeem-api.git" }, "bugs": { - "url": "https://github.com/yourusername/cashu-redeem-api/issues" + "url": "https://github.com/Michilis/cashu-redeem-api/issues" }, - "homepage": "https://github.com/yourusername/cashu-redeem-api#readme" + "homepage": "https://github.com/Michilis/cashu-redeem-api#readme" } diff --git a/server.js b/server.js index d275aa6..91dab90 100644 --- a/server.js +++ b/server.js @@ -79,6 +79,78 @@ function asyncHandler(fn) { // API Routes +/** + * @swagger + * /: + * get: + * summary: API Information + * description: Get basic information about the Cashu Redeem API + * tags: [General] + * responses: + * 200: + * description: API information + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "Cashu Redeem API" + * version: + * type: string + * example: "1.0.0" + * description: + * type: string + * example: "A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses" + * documentation: + * type: string + * example: "/docs" + * endpoints: + * type: object + * properties: + * decode: + * type: string + * example: "POST /api/decode" + * redeem: + * type: string + * example: "POST /api/redeem" + * validate: + * type: string + * example: "POST /api/validate-address" + * health: + * type: string + * example: "GET /api/health" + * github: + * type: string + * example: "https://github.com/yourusername/cashu-redeem-api" + */ +app.get('/', (req, res) => { + res.json({ + name: 'Cashu Redeem API', + version: '1.0.0', + description: 'A production-grade API for redeeming Cashu tokens (ecash) to Lightning addresses using the cashu-ts library and LNURLp protocol', + documentation: '/docs', + endpoints: { + decode: 'POST /api/decode', + redeem: 'POST /api/redeem', + validate: 'POST /api/validate-address', + health: 'GET /api/health' + }, + features: [ + 'Decode Cashu tokens', + 'Redeem tokens to Lightning addresses', + 'Lightning address validation', + 'Domain restrictions', + 'Rate limiting', + 'Comprehensive error handling' + ], + github: 'https://github.com/yourusername/cashu-redeem-api' + }); +}); + +// API Routes + /** * @swagger * /api/decode: @@ -128,6 +200,44 @@ app.post('/api/decode', asyncHandler(async (req, res) => { const decoded = await cashuService.parseToken(token); const mintUrl = await cashuService.getTokenMintUrl(token); + // Check if token is spent + let spent = false; + try { + const spendabilityCheck = await cashuService.checkTokenSpendable(token); + // Token is spent if no proofs are spendable + spent = !spendabilityCheck.spendable || spendabilityCheck.spendable.length === 0; + } catch (error) { + // If spendability check fails, analyze the error to determine if token is spent + console.warn('Spendability check failed:', error.message); + + // Check if error indicates proofs are already spent + const errorString = error.message || error.toString(); + + // Check for specific error indicators + if (errorString.includes('TOKEN_SPENT:')) { + // CashuService has determined the token is spent based on clear indicators + console.log('Token determined to be spent by CashuService'); + spent = true; + } else if (errorString.includes('Token validation failed at mint:')) { + // This is a 422 error but not clearly indicating the token is spent + // It might be invalid/malformed but not necessarily spent + console.log('Token validation failed at mint - assuming token is still valid (might be invalid format)'); + spent = false; + } else if (errorString.includes('not supported') || + errorString.includes('endpoint not found') || + errorString.includes('may still be valid') || + errorString.includes('does not support spendability checking')) { + // Mint doesn't support spendability checking - assume token is still valid + console.log('Mint does not support spendability checking - assuming token is valid'); + spent = false; + } else { + // For other errors (network, server issues), assume token is still valid + // This is safer than assuming it's spent + console.log('Unknown error - assuming token is valid'); + spent = false; + } + } + res.json({ success: true, decoded: { @@ -135,7 +245,8 @@ app.post('/api/decode', asyncHandler(async (req, res) => { totalAmount: decoded.totalAmount, numProofs: decoded.numProofs, denominations: decoded.denominations, - format: decoded.format + format: decoded.format, + spent: spent }, mint_url: mintUrl }); @@ -157,13 +268,14 @@ app.post('/api/decode', asyncHandler(async (req, res) => { * * The redemption process includes: * 1. Token validation and parsing - * 2. Fee calculation (NUT-05: 2% of amount, minimum 1 satoshi) - * 3. Invoice creation for net amount (token amount - fees) + * 2. Getting exact melt quote from mint to determine precise fees + * 3. Invoice creation for net amount (token amount - exact fees) * 4. Spendability checking at the mint * 5. Token melting and Lightning payment * - * **Important**: Fees are subtracted from the token amount before creating the Lightning invoice. + * **Important**: The system gets the exact fee from the mint before creating the invoice. * The `invoiceAmount` field shows the actual amount sent to the Lightning address. + * No sats are lost to fee estimation errors. * tags: [Token Operations] * requestBody: * required: true @@ -193,9 +305,6 @@ app.post('/api/decode', asyncHandler(async (req, res) => { * error: * type: string * example: "This token has already been spent and cannot be redeemed again" - * redeemId: - * type: string - * format: uuid * errorType: * type: string * example: "token_already_spent" @@ -212,9 +321,6 @@ app.post('/api/decode', asyncHandler(async (req, res) => { * error: * type: string * example: "Token amount is insufficient to cover the minimum fee" - * redeemId: - * type: string - * format: uuid * errorType: * type: string * example: "insufficient_funds" @@ -243,7 +349,6 @@ app.post('/api/redeem', asyncHandler(async (req, res) => { if (result.success) { const response = { success: true, - redeemId: result.redeemId, paid: result.paid, amount: result.amount, invoiceAmount: result.invoiceAmount, @@ -288,7 +393,6 @@ app.post('/api/redeem', asyncHandler(async (req, res) => { res.status(statusCode).json({ success: false, error: result.error, - redeemId: result.redeemId, errorType: statusCode === 409 ? 'token_already_spent' : statusCode === 422 ? 'insufficient_funds' : 'validation_error' }); diff --git a/services/cashu.js b/services/cashu.js index 84e7b05..1b8451b 100644 --- a/services/cashu.js +++ b/services/cashu.js @@ -184,6 +184,36 @@ class CashuService { return this.wallets.get(mintUrl); } + /** + * Get melt quote for a Cashu token and Lightning invoice + * @param {string} token - The encoded Cashu token + * @param {string} bolt11 - The Lightning invoice + * @returns {Object} Melt quote + */ + async getMeltQuote(token, bolt11) { + try { + const parsed = await this.parseToken(token); + const wallet = await this.getWallet(parsed.mint); + + // Create melt quote to get fee estimate + const meltQuote = await wallet.createMeltQuote(bolt11); + + console.log('Melt quote created:', { + amount: meltQuote.amount, + fee_reserve: meltQuote.fee_reserve, + quote: meltQuote.quote + }); + + return { + amount: meltQuote.amount, + fee_reserve: meltQuote.fee_reserve, + quote: meltQuote.quote + }; + } catch (error) { + throw new Error(`Failed to get melt quote: ${error.message}`); + } + } + /** * Melt a Cashu token to pay a Lightning invoice * @param {string} token - The encoded Cashu token @@ -199,20 +229,36 @@ class CashuService { const decoded = await this.decodeTokenStructure(token); const proofs = decoded.proofs; - // Create melt quote to get fee estimate + // Step 1: Create melt quote to get fee estimate const meltQuote = await wallet.createMeltQuote(bolt11); + console.log('Melt quote created:', { + amount: meltQuote.amount, + fee_reserve: meltQuote.fee_reserve, + quote: meltQuote.quote + }); + console.log('Paying invoice:', bolt11.substring(0, 50) + '...'); + console.log('Full invoice being paid:', bolt11); - // Calculate expected fee - const expectedFee = this.calculateFee(parsed.totalAmount); + // Step 2: Calculate total required (amount + fee_reserve) + const total = meltQuote.amount + meltQuote.fee_reserve; + console.log('Total required:', total, 'sats (amount:', meltQuote.amount, '+ fee:', meltQuote.fee_reserve, ')'); + console.log('Available in token:', parsed.totalAmount, 'sats'); - // Check if we have sufficient funds including fees - const totalRequired = meltQuote.amount + meltQuote.fee_reserve; - if (totalRequired > parsed.totalAmount) { - throw new Error(`Insufficient funds. Required: ${totalRequired} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`); + // Check if we have sufficient funds + if (total > parsed.totalAmount) { + throw new Error(`Insufficient funds. Required: ${total} sats (including ${meltQuote.fee_reserve} sats fee), Available: ${parsed.totalAmount} sats`); } - // Perform the melt operation using the quote and proofs - const meltResponse = await wallet.meltTokens(meltQuote, proofs); + // Step 3: Send tokens with includeFees: true to get the right proofs + console.log('Selecting proofs with includeFees: true for', total, 'sats'); + const { send: proofsToSend } = await wallet.send(total, proofs, { + includeFees: true, + }); + console.log('Selected', proofsToSend.length, 'proofs for melting'); + + // Step 4: Perform the melt operation using the quote and selected proofs + console.log('Performing melt operation...'); + const meltResponse = await wallet.meltTokens(meltQuote, proofsToSend); // Debug: Log the melt response structure console.log('Melt response:', JSON.stringify(meltResponse, null, 2)); @@ -245,7 +291,6 @@ class CashuService { change: meltResponse.change || [], amount: meltQuote.amount, fee: actualFeeCharged, // Use actual fee from melt response - actualFee: expectedFee, // Keep the calculated expected fee for comparison netAmount: actualNetAmount, // Use net amount based on actual fee quote: meltQuote.quote, rawMeltResponse: meltResponse // Include raw response for debugging @@ -330,43 +375,44 @@ class CashuService { totalAmount: parsed.totalAmount }; } catch (error) { - // Check if it's a known 422 error (already spent token) - log less verbosely - const isExpected422Error = (error.status === 422 || error.response?.status === 422) && - error.constructor.name === 'HttpResponseError'; - - if (isExpected422Error) { - console.log('Token spendability check: 422 status detected - token already spent'); - } else { - // Enhanced error logging for unexpected errors - console.error('Spendability check error details:', { - errorType: error.constructor.name, - errorMessage: error.message, - errorCode: error.code, - errorStatus: error.status, - errorResponse: error.response, - errorData: error.data, - errorStack: error.stack, - errorString: String(error) - }); - } + // Enhanced error logging for debugging + console.error('Spendability check error details:', { + errorType: error.constructor.name, + errorMessage: error.message, + errorCode: error.code, + errorStatus: error.status, + errorResponse: error.response, + errorData: error.data, + errorStack: error.stack, + errorString: String(error) + }); // Handle different types of errors let errorMessage = 'Unknown error occurred'; // Handle cashu-ts HttpResponseError specifically if (error.constructor.name === 'HttpResponseError') { - if (!isExpected422Error) { - console.log('HttpResponseError detected, extracting details...'); - } - // Extract status code first const status = error.status || error.response?.status || error.statusCode; - // For 422 errors, we know it's about already spent tokens + // For 422 errors, we need to be more specific about the reason if (status === 422) { - errorMessage = 'Token proofs are not spendable - they have already been used or are invalid'; - if (!isExpected422Error) { - console.log('Detected 422 status - token already spent'); + // Try to get more details about the 422 error + let responseBody = null; + try { + responseBody = error.response?.data || error.data || error.body; + console.log('HTTP 422 response body:', responseBody); + } catch (e) { + console.log('Could not extract response body'); + } + + // 422 can mean different things, let's be more specific + if (responseBody && typeof responseBody === 'object' && responseBody.detail) { + errorMessage = `Token validation failed: ${responseBody.detail}`; + console.log('422 error with detail:', responseBody.detail); + } else { + errorMessage = 'Token proofs are not spendable - they may have already been used or are invalid'; + console.log('Detected 422 status - token validation failed'); } } else { // Try to extract useful information from the HTTP response error @@ -432,9 +478,7 @@ class CashuService { } // Log the final extracted error message for debugging - if (!isExpected422Error) { - console.log('Final extracted error message:', errorMessage); - } + console.log('Final extracted error message:', errorMessage); // Check if it's a known error pattern indicating unsupported operation if (errorMessage.includes('not supported') || @@ -448,6 +492,26 @@ class CashuService { throw new Error('This mint does not support spendability checking. Token may still be valid.'); } + // Check if the error indicates the token is spent (HTTP 422 or specific messages) + const status = error.status || error.response?.status || error.statusCode; + if (status === 422) { + // For 422 errors, we need to be more careful about determining if it's "spent" vs "invalid" + // Only mark as spent if we have clear indicators + if (errorMessage.includes('already been used') || + errorMessage.includes('already spent') || + errorMessage.includes('not spendable')) { + throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used'); + } else { + // For other 422 errors, it might be invalid but not necessarily spent + console.log('HTTP 422 but not clearly indicating spent token - treating as validation error'); + throw new Error(`Token validation failed at mint: ${errorMessage}`); + } + } else if (errorMessage.includes('Token proofs are not spendable') || + errorMessage.includes('already been used') || + errorMessage.includes('invalid proofs')) { + throw new Error('TOKEN_SPENT: Token proofs are not spendable - they have already been used'); + } + throw new Error(`Failed to check token spendability: ${errorMessage}`); } } diff --git a/services/lightning.js b/services/lightning.js index 2781f7e..efd613e 100644 --- a/services/lightning.js +++ b/services/lightning.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const bolt11 = require('bolt11'); class LightningService { constructor() { @@ -220,11 +221,19 @@ class LightningService { */ async resolveInvoice(lightningAddress, amount, comment = 'Cashu token redemption') { try { + console.log(`Resolving Lightning address: ${lightningAddress} for ${amount} sats`); + // Get LNURLp endpoint const lnurlpUrl = this.getLNURLpEndpoint(lightningAddress); + console.log(`LNURLp endpoint: ${lnurlpUrl}`); // Fetch LNURLp response const lnurlpResponse = await this.fetchLNURLpResponse(lnurlpUrl); + console.log('LNURLp response:', { + callback: lnurlpResponse.callback, + minSendable: lnurlpResponse.minSendable, + maxSendable: lnurlpResponse.maxSendable + }); // Validate amount if (!this.validateAmount(amount, lnurlpResponse)) { @@ -235,7 +244,17 @@ class LightningService { // Get invoice const amountMsats = this.satsToMillisats(amount); + console.log(`Requesting invoice for ${amountMsats} millisats (${amount} sats)`); + console.log(`Using callback URL: ${lnurlpResponse.callback}`); const invoiceResponse = await this.getInvoice(lnurlpResponse.callback, amountMsats, comment); + + console.log('Invoice created successfully:', { + bolt11: invoiceResponse.bolt11.substring(0, 50) + '...', + lightningAddress, + amount, + amountMsats, + callback: lnurlpResponse.callback + }); return { bolt11: invoiceResponse.bolt11, @@ -247,6 +266,7 @@ class LightningService { lnurlpResponse }; } catch (error) { + console.error('Lightning address resolution failed:', error.message); throw new Error(`Lightning address resolution failed: ${error.message}`); } } @@ -271,6 +291,62 @@ class LightningService { throw new Error(`Invoice parsing failed: ${error.message}`); } } + + /** + * Verify that a Lightning invoice is valid and for the expected amount + * @param {string} bolt11Invoice - The Lightning invoice to verify + * @param {string} expectedLightningAddress - The expected Lightning address (for logging) + * @param {number} expectedAmount - Expected amount in satoshis (optional) + * @returns {boolean} Whether the invoice is valid + */ + verifyInvoiceDestination(bolt11Invoice, expectedLightningAddress, expectedAmount = null) { + try { + console.log(`Verifying invoice destination for: ${expectedLightningAddress}`); + console.log(`Invoice: ${bolt11Invoice.substring(0, 50)}...`); + + // Decode the invoice using the bolt11 library + const decoded = bolt11.decode(bolt11Invoice); + + // Basic validation checks + if (!decoded.complete) { + console.error('Invoice verification failed: Invoice is incomplete'); + return false; + } + + if (!decoded.paymentRequest) { + console.error('Invoice verification failed: No payment request found'); + return false; + } + + // Check if the invoice has expired + if (decoded.timeExpireDate && decoded.timeExpireDate < Date.now() / 1000) { + console.error('Invoice verification failed: Invoice has expired'); + return false; + } + + // Verify amount if provided + if (expectedAmount !== null) { + const invoiceAmount = decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0); + if (invoiceAmount !== expectedAmount) { + console.error(`Invoice verification failed: Amount mismatch. Expected: ${expectedAmount} sats, Got: ${invoiceAmount} sats`); + return false; + } + } + + console.log('Invoice verification: All checks passed'); + console.log('Invoice details:', { + amount: decoded.satoshis || (decoded.millisatoshis ? Math.floor(decoded.millisatoshis / 1000) : 0), + timestamp: decoded.timestamp, + expiry: decoded.expiry, + description: decoded.tags?.find(tag => tag.tagName === 'description')?.data || 'No description' + }); + + return true; + } catch (error) { + console.error('Invoice verification failed:', error.message); + return false; + } + } } module.exports = new LightningService(); \ No newline at end of file diff --git a/services/redemption.js b/services/redemption.js index 00dd306..62b085f 100644 --- a/services/redemption.js +++ b/services/redemption.js @@ -167,23 +167,10 @@ class RedemptionService { this.updateRedemption(redeemId, { status: 'parsing_token' }); const tokenData = await cashuService.parseToken(token); - // Calculate expected fee according to NUT-05 - const expectedFee = cashuService.calculateFee(tokenData.totalAmount); - - // Calculate net amount after subtracting fees - const netAmountAfterFee = tokenData.totalAmount - expectedFee; - - // Ensure we have enough for the minimum payment after fees - if (netAmountAfterFee <= 0) { - throw new Error(`Token amount (${tokenData.totalAmount} sats) is insufficient to cover the minimum fee (${expectedFee} sats)`); - } - this.updateRedemption(redeemId, { amount: tokenData.totalAmount, mint: tokenData.mint, numProofs: tokenData.numProofs, - expectedFee: expectedFee, - netAmountAfterFee: netAmountAfterFee, format: tokenData.format }); @@ -203,26 +190,58 @@ class RedemptionService { // This is likely an already-spent token - fail the redemption with clear message throw new Error('This token has already been spent and cannot be redeemed again'); } - // Log but don't fail for other errors - some mints might not support this check + // For other errors, log but continue - some mints might not support this check console.warn('Spendability check failed:', spendError.message); + console.log('Continuing with redemption despite spendability check failure...'); } - // Step 2: Resolve Lightning address to invoice - // IMPORTANT: Create invoice for net amount (after subtracting expected fees) + // Step 2: Get melt quote first to determine exact fees + this.updateRedemption(redeemId, { status: 'getting_melt_quote' }); + + // Create a temporary invoice to get the melt quote (we'll create the real one after) + console.log(`Getting melt quote for ${tokenData.totalAmount} sats to determine exact fees`); + const tempInvoiceData = await lightningService.resolveInvoice( + lightningAddressToUse, + tokenData.totalAmount, // Use full amount initially + 'Cashu redemption' + ); + + // Get melt quote to determine exact fee + const meltQuote = await cashuService.getMeltQuote(token, tempInvoiceData.bolt11); + const exactFee = meltQuote.fee_reserve; + const finalInvoiceAmount = tokenData.totalAmount - exactFee; + + console.log(`Melt quote: amount=${meltQuote.amount}, fee=${exactFee}, net to user=${finalInvoiceAmount}`); + + // Step 3: Create final invoice for the correct amount (total - exact fee) this.updateRedemption(redeemId, { status: 'resolving_invoice' }); + + if (finalInvoiceAmount <= 0) { + throw new Error(`Token amount (${tokenData.totalAmount} sats) is insufficient to cover the fee (${exactFee} sats)`); + } + + console.log(`Creating final invoice for ${finalInvoiceAmount} sats (${tokenData.totalAmount} - ${exactFee} fee)`); + const invoiceData = await lightningService.resolveInvoice( lightningAddressToUse, - netAmountAfterFee, // Use net amount instead of full token amount + finalInvoiceAmount, // Use amount minus exact fee 'Cashu redemption' ); this.updateRedemption(redeemId, { bolt11: invoiceData.bolt11.substring(0, 50) + '...', domain: invoiceData.domain, - invoiceAmount: netAmountAfterFee + invoiceAmount: finalInvoiceAmount, + exactFee: exactFee }); - // Step 3: Melt the token to pay the invoice + // Verify the invoice is valid and for the correct amount + const invoiceVerified = lightningService.verifyInvoiceDestination(invoiceData.bolt11, lightningAddressToUse, finalInvoiceAmount); + if (!invoiceVerified) { + throw new Error('Invoice verification failed - invalid invoice or amount mismatch'); + } + + // Step 4: Melt the token to pay the invoice this.updateRedemption(redeemId, { status: 'melting_token' }); const meltResult = await cashuService.meltToken(token, invoiceData.bolt11); @@ -256,12 +275,12 @@ class RedemptionService { redeemId, paid: paymentSuccessful, amount: tokenData.totalAmount, - invoiceAmount: netAmountAfterFee, // Amount actually sent in the invoice + invoiceAmount: finalInvoiceAmount, // Amount actually sent in the invoice to: lightningAddressToUse, usingDefaultAddress: isUsingDefault, - fee: meltResult.fee, + fee: exactFee, // Use the exact fee from the melt quote actualFee: meltResult.actualFee, - netAmount: meltResult.netAmount, + netAmount: finalInvoiceAmount, // This is the net amount the user receives preimage: meltResult.preimage, change: meltResult.change, mint: tokenData.mint, diff --git a/swagger.config.js b/swagger.config.js index 1d0eaac..6fc5eda 100644 --- a/swagger.config.js +++ b/swagger.config.js @@ -52,7 +52,7 @@ const options = { token: { type: 'string', description: 'Cashu token to decode (supports v1 and v3 formats)', - example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0' + example: 'cashuB...' } } }, @@ -95,6 +95,11 @@ const options = { enum: ['cashuA', 'cashuB'], description: 'Token format version', example: 'cashuA' + }, + spent: { + type: 'boolean', + description: 'Whether the token has already been spent (true = spent, false = still valid)', + example: false } } }, @@ -115,13 +120,13 @@ const options = { token: { type: 'string', description: 'Cashu token to redeem', - example: 'cashuAeyJwcm9vZnMiOlt7ImFtb3VudCI6MSwiaWQiOiIwMGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn0seyJhbW91bnQiOjEsImlkIjoiMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmIn1dLCJtaW50IjoiaHR0cHM6Ly9taW50LmV4YW1wbGUuY29tIn0' + example: 'cashuB...' }, lightningAddress: { type: 'string', format: 'email', description: 'Lightning address to send payment to (optional - uses default if not provided)', - example: 'user@ln.tips' + example: 'user@blink.sv' } } }, @@ -133,12 +138,6 @@ const options = { type: 'boolean', example: true }, - redeemId: { - type: 'string', - format: 'uuid', - description: 'Unique redemption ID for tracking', - example: '8e99101e-d034-4d2e-9ccf-dfda24d26762' - }, paid: { type: 'boolean', description: 'Whether the payment was successful', @@ -324,6 +323,10 @@ const options = { } }, tags: [ + { + name: 'General', + description: 'General API information and utilities' + }, { name: 'Token Operations', description: 'Operations for decoding and redeeming Cashu tokens'