Merge pull request #1 from Michilis/dev

Fee calculation fix
This commit is contained in:
Michilis
2025-07-15 20:05:42 +02:00
committed by GitHub
9 changed files with 870 additions and 102 deletions

View File

@@ -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,

View File

@@ -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

497
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}

128
server.js
View File

@@ -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'
});

View File

@@ -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}`);
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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'