From def3157f9bcae11894176b9d9366da2ac6419c64 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Sat, 11 Apr 2026 05:35:36 -0700 Subject: [PATCH] Add Nostr ecash transaction history CLI - CLI tool to export NIP-60 (Cashu Wallet) and NIP-61 (Nutzap) transaction history as CSV - Fetches and decrypts kind:17375 wallet, kind:7375 token, kind:7376 history, kind:9321 nutzap, and kind:7377 redemption events - Accepts NOSTR_SECRET_KEY env var and optional --mints flag - Outputs CSV to stdout with columns: date,type,amount,unit,mint,token_id,memo - Progress and errors go to stderr --- .gitignore | 4 +- bun.lock | 185 +++++++++++++++++++++++++++++ package.json | 16 +++ src/csv.ts | 29 +++++ src/events.ts | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 86 ++++++++++++++ src/ndk.ts | 26 ++++ src/types.ts | 32 +++++ tsconfig.json | 15 +++ 9 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/csv.ts create mode 100644 src/events.ts create mode 100644 src/index.ts create mode 100644 src/ndk.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 5eec986..8c68c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.claude +node_modules/ +dist/ +bun.lockb diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8e30d7e --- /dev/null +++ b/bun.lock @@ -0,0 +1,185 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "nostr-nut-history", + "dependencies": { + "@nostr-dev-kit/ndk": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@codesandbox/nodebox": ["@codesandbox/nodebox@0.1.8", "", { "dependencies": { "outvariant": "^1.4.0", "strict-event-emitter": "^0.4.3" } }, "sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg=="], + + "@codesandbox/sandpack-client": ["@codesandbox/sandpack-client@2.19.8", "", { "dependencies": { "@codesandbox/nodebox": "0.1.8", "buffer": "^6.0.3", "dequal": "^2.0.2", "mime-db": "^1.52.0", "outvariant": "1.4.0", "static-browser-server": "1.0.3" } }, "sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ=="], + + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@noble/secp256k1": ["@noble/secp256k1@2.3.0", "", {}, "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="], + + "@nostr-dev-kit/ndk": ["@nostr-dev-kit/ndk@3.0.3", "", { "dependencies": { "@codesandbox/sandpack-client": "^2.19.8", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@noble/secp256k1": "^2.1.0", "@scure/base": "^1.1.9", "debug": "^4.3.7", "light-bolt11-decoder": "^3.2.0", "shiki": "^3.13.0", "tseep": "^1.3.1", "typescript-lru-cache": "^2.0.0" }, "peerDependencies": { "nostr-tools": "^2.17.2" } }, "sha512-wGQOnvpKjhkq/j0pnRGTiHyrujww7N/iJQKOBrjsIxqTvcy6iyqh/3TijTKlaapdvexx2E2xCn9X+q0dxxISug=="], + + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + + "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "light-bolt11-decoder": ["light-bolt11-decoder@3.2.0", "", { "dependencies": { "@scure/base": "1.1.1" } }, "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nostr-tools": ["nostr-tools@2.23.3", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + + "outvariant": ["outvariant@1.4.0", "", {}, "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "static-browser-server": ["static-browser-server@1.0.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.1.0", "dotenv": "^16.0.3", "mime-db": "^1.52.0", "outvariant": "^1.3.0" } }, "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA=="], + + "strict-event-emitter": ["strict-event-emitter@0.4.6", "", {}, "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "tseep": ["tseep@1.3.1", "", {}, "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "typescript-lru-cache": ["typescript-lru-cache@2.0.0", "", {}, "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@scure/bip32/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@scure/bip32/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@scure/bip32/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@scure/bip39/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "light-bolt11-decoder/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "nostr-tools/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "nostr-tools/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ae4bde7 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "nostr-nut-history", + "version": "0.1.0", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "type-check": "bunx tsc --noEmit" + }, + "dependencies": { + "@nostr-dev-kit/ndk": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest" + } +} diff --git a/src/csv.ts b/src/csv.ts new file mode 100644 index 0000000..05d981c --- /dev/null +++ b/src/csv.ts @@ -0,0 +1,29 @@ +import type { TransactionRecord } from "./types.js"; + +function escapeField(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function formatCSV(records: TransactionRecord[]): string { + const header = "date,type,amount,unit,mint,token_id,memo"; + const sorted = [...records].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + const rows = sorted.map((r) => + [ + escapeField(r.date), + escapeField(r.type), + String(r.amount), + escapeField(r.unit), + escapeField(r.mint), + escapeField(r.token_id), + escapeField(r.memo), + ].join(",") + ); + + return [header, ...rows].join("\n"); +} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..d94176c --- /dev/null +++ b/src/events.ts @@ -0,0 +1,323 @@ +import type NDK from "@nostr-dev-kit/ndk"; +import { + NDKKind, + type NDKUser, + type NDKSigner, + type NDKEvent, +} from "@nostr-dev-kit/ndk"; +import type { + WalletData, + TokenData, + HistoryEntry, + TransactionRecord, +} from "./types.js"; + +async function decryptEvent( + event: NDKEvent, + user: NDKUser, + signer: NDKSigner +): Promise { + try { + await event.decrypt(user, signer, "nip44"); + return event.content; + } catch (err) { + console.error( + `Warning: failed to decrypt event ${event.id}: ${err instanceof Error ? err.message : err}` + ); + return null; + } +} + +export async function fetchWalletData( + ndk: NDK, + user: NDKUser, + signer: NDKSigner +): Promise { + console.error("Fetching wallet events (kind:17375)..."); + const events = await ndk.fetchEvents({ + kinds: [NDKKind.CashuWallet as number], + authors: [user.pubkey], + }); + + const mints: string[] = []; + let privkey = ""; + + for (const event of events) { + const content = await decryptEvent(event, user, signer); + if (!content) continue; + + try { + const tuples: string[][] = JSON.parse(content); + for (const tuple of tuples) { + if (tuple[0] === "mint" && tuple[1]) { + mints.push(tuple[1]); + } + if (tuple[0] === "privkey" && tuple[1]) { + privkey = tuple[1]; + } + } + } catch (err) { + console.error( + `Warning: failed to parse wallet event ${event.id}: ${err instanceof Error ? err.message : err}` + ); + } + } + + console.error(`Found ${events.size} wallet event(s), ${mints.length} mint(s)`); + return { mints: [...new Set(mints)], privkey }; +} + +export async function fetchTokenEvents( + ndk: NDK, + user: NDKUser, + signer: NDKSigner +): Promise { + console.error("Fetching token events (kind:7375)..."); + const events = await ndk.fetchEvents({ + kinds: [NDKKind.CashuToken as number], + authors: [user.pubkey], + }); + + const tokens: TokenData[] = []; + + for (const event of events) { + const content = await decryptEvent(event, user, signer); + if (!content) continue; + + try { + const data = JSON.parse(content); + tokens.push({ + mint: data.mint ?? "", + unit: data.unit ?? "sat", + proofs: data.proofs ?? [], + del: data.del, + eventId: event.id, + createdAt: event.created_at ?? 0, + }); + } catch (err) { + console.error( + `Warning: failed to parse token event ${event.id}: ${err instanceof Error ? err.message : err}` + ); + } + } + + console.error(`Found ${tokens.length} token event(s)`); + return tokens; +} + +export async function fetchHistoryEvents( + ndk: NDK, + user: NDKUser, + signer: NDKSigner +): Promise { + console.error("Fetching history events (kind:7376)..."); + const events = await ndk.fetchEvents({ + kinds: [NDKKind.CashuWalletTx as number], + authors: [user.pubkey], + }); + + const entries: HistoryEntry[] = []; + + for (const event of events) { + const content = await decryptEvent(event, user, signer); + if (!content) continue; + + try { + const tuples: string[][] = JSON.parse(content); + const entry: HistoryEntry = { + direction: "in", + amount: "0", + unit: "sat", + eventId: event.id, + createdAt: event.created_at ?? 0, + referencedEvents: [], + }; + + for (const tuple of tuples) { + if (tuple[0] === "direction") entry.direction = tuple[1] as "in" | "out"; + if (tuple[0] === "amount") entry.amount = tuple[1]; + if (tuple[0] === "unit") entry.unit = tuple[1]; + } + + // Extract e-tag references + const eTags = event.getMatchingTags("e"); + entry.referencedEvents = eTags.map((t) => t[1]); + + entries.push(entry); + } catch (err) { + console.error( + `Warning: failed to parse history event ${event.id}: ${err instanceof Error ? err.message : err}` + ); + } + } + + console.error(`Found ${entries.length} history event(s)`); + return entries; +} + +export async function fetchNutzapEvents( + ndk: NDK, + user: NDKUser +): Promise { + console.error("Fetching nutzap events (kind:9321)..."); + const events = await ndk.fetchEvents({ + kinds: [NDKKind.Nutzap as number], + "#p": [user.pubkey], + }); + + const records: TransactionRecord[] = []; + + for (const event of events) { + try { + const proofTags = event.getMatchingTags("proof"); + let totalAmount = 0; + for (const tag of proofTags) { + try { + const proof = JSON.parse(tag[1]); + totalAmount += proof.amount ?? 0; + } catch { + // skip malformed proof tag + } + } + + const uTag = event.getMatchingTags("u"); + const mint = uTag.length > 0 ? uTag[0][1] : ""; + + records.push({ + date: new Date((event.created_at ?? 0) * 1000).toISOString(), + type: "zap", + amount: totalAmount, + unit: "sat", + mint, + token_id: event.id, + memo: event.content ?? "", + }); + } catch (err) { + console.error( + `Warning: failed to parse nutzap event ${event.id}: ${err instanceof Error ? err.message : err}` + ); + } + } + + console.error(`Found ${records.length} nutzap event(s)`); + return records; +} + +export async function fetchNutzapRedemptions( + ndk: NDK, + user: NDKUser, + signer: NDKSigner +): Promise { + console.error("Fetching nutzap redemptions (kind:7377)..."); + const events = await ndk.fetchEvents({ + kinds: [7377 as number], + authors: [user.pubkey], + }); + + const records: TransactionRecord[] = []; + + for (const event of events) { + const content = await decryptEvent(event, user, signer); + if (!content) continue; + + try { + const data = JSON.parse(content); + // Redemption events track the redeemed nutzap + const eTags = event.getMatchingTags("e"); + const redeemId = eTags.length > 0 ? eTags[0][1] : event.id; + + if (data.proofs && Array.isArray(data.proofs)) { + const amount = data.proofs.reduce( + (sum: number, p: { amount?: number }) => sum + (p.amount ?? 0), + 0 + ); + records.push({ + date: new Date((event.created_at ?? 0) * 1000).toISOString(), + type: "receive", + amount, + unit: data.unit ?? "sat", + mint: data.mint ?? "", + token_id: redeemId, + memo: "nutzap redemption", + }); + } + } catch (err) { + console.error( + `Warning: failed to parse redemption event ${event.id}: ${err instanceof Error ? err.message : err}` + ); + } + } + + console.error(`Found ${records.length} nutzap redemption(s)`); + return records; +} + +export function buildTransactionHistory( + _walletData: WalletData, + tokenEvents: TokenData[], + historyEvents: HistoryEntry[], + nutzaps: TransactionRecord[], + redemptions: TransactionRecord[] +): TransactionRecord[] { + const records: TransactionRecord[] = []; + const seen = new Set(); + + // Primary: history events (kind:7376) + for (const entry of historyEvents) { + if (seen.has(entry.eventId)) continue; + seen.add(entry.eventId); + + records.push({ + date: new Date(entry.createdAt * 1000).toISOString(), + type: entry.direction === "in" ? "receive" : "send", + amount: parseInt(entry.amount, 10) || 0, + unit: entry.unit, + mint: "", + token_id: entry.eventId, + memo: "", + }); + } + + // If no history events, fall back to token events + if (historyEvents.length === 0) { + console.error( + "No history events found, deriving from token events..." + ); + for (const token of tokenEvents) { + if (seen.has(token.eventId)) continue; + seen.add(token.eventId); + + const amount = token.proofs.reduce((sum, p) => sum + p.amount, 0); + records.push({ + date: new Date(token.createdAt * 1000).toISOString(), + type: "receive", + amount, + unit: token.unit, + mint: token.mint, + token_id: token.eventId, + memo: "", + }); + } + } + + // Nutzap events (kind:9321) + for (const zap of nutzaps) { + if (seen.has(zap.token_id)) continue; + seen.add(zap.token_id); + records.push(zap); + } + + // Nutzap redemptions (kind:7377) + for (const redemption of redemptions) { + if (seen.has(redemption.token_id)) continue; + seen.add(redemption.token_id); + records.push(redemption); + } + + // Sort by date ascending + records.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + return records; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e2fa41f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,86 @@ +import { initNDK } from "./ndk.js"; +import { + fetchWalletData, + fetchTokenEvents, + fetchHistoryEvents, + fetchNutzapEvents, + fetchNutzapRedemptions, + buildTransactionHistory, +} from "./events.js"; +import { formatCSV } from "./csv.js"; + +function usage(): never { + console.error(`Usage: NOSTR_SECRET_KEY= bun run src/index.ts [--mints ] + +Options: + --mints Comma-separated list of additional mint URLs to include + +Environment: + NOSTR_SECRET_KEY Required. Nostr private key in hex or nsec format.`); + process.exit(1); +} + +async function main() { + const secretKey = process.env.NOSTR_SECRET_KEY; + if (!secretKey) { + console.error("Error: NOSTR_SECRET_KEY environment variable is required"); + usage(); + } + + // Parse --mints flag + const args = process.argv.slice(2); + const mintsIdx = args.indexOf("--mints"); + const extraMints = + mintsIdx !== -1 && args[mintsIdx + 1] + ? args[mintsIdx + 1].split(",").filter(Boolean) + : []; + + // Initialize NDK + const { ndk, signer, user } = await initNDK(secretKey, extraMints); + + // Fetch wallet data first (need mints for context) + const walletData = await fetchWalletData(ndk, user, signer); + const allMints = [...new Set([...walletData.mints, ...extraMints])]; + if (allMints.length > 0) { + console.error(`Discovered mints: ${allMints.join(", ")}`); + } + + // Fetch all events in parallel + const [tokens, history, nutzaps, redemptions] = await Promise.all([ + fetchTokenEvents(ndk, user, signer), + fetchHistoryEvents(ndk, user, signer), + fetchNutzapEvents(ndk, user), + fetchNutzapRedemptions(ndk, user, signer), + ]); + + // Build unified transaction history + const records = buildTransactionHistory( + walletData, + tokens, + history, + nutzaps, + redemptions + ); + + console.error(`Total transactions: ${records.length}`); + + // Output CSV to stdout + const csv = formatCSV(records); + process.stdout.write(csv + "\n"); + + // Clean shutdown + // NDK doesn't have a disconnect method that returns a promise reliably, + // so we just exit after output + process.exit(0); +} + +// Handle SIGINT +process.on("SIGINT", () => { + console.error("\nInterrupted."); + process.exit(1); +}); + +main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); +}); diff --git a/src/ndk.ts b/src/ndk.ts new file mode 100644 index 0000000..ff6a8bf --- /dev/null +++ b/src/ndk.ts @@ -0,0 +1,26 @@ +import NDK, { NDKPrivateKeySigner, type NDKUser } from "@nostr-dev-kit/ndk"; + +const DEFAULT_RELAYS = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nos.lol", + "wss://relay.nostr.band", +]; + +export async function initNDK(secretKey: string, extraRelays: string[] = []) { + const signer = new NDKPrivateKeySigner(secretKey); + const relays = [...new Set([...DEFAULT_RELAYS, ...extraRelays])]; + + const ndk = new NDK({ + explicitRelayUrls: relays, + signer, + }); + + console.error(`Connecting to ${relays.length} relays...`); + await ndk.connect(); + console.error("Connected."); + + const user: NDKUser = await signer.user(); + + return { ndk, signer, user }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d657be1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,32 @@ +export interface TransactionRecord { + date: string; + type: "send" | "receive" | "zap"; + amount: number; + unit: string; + mint: string; + token_id: string; + memo: string; +} + +export interface WalletData { + mints: string[]; + privkey: string; +} + +export interface TokenData { + mint: string; + unit: string; + proofs: Array<{ id: string; amount: number; secret: string; C: string }>; + del?: string[]; + eventId: string; + createdAt: number; +} + +export interface HistoryEntry { + direction: "in" | "out"; + amount: string; + unit: string; + eventId: string; + createdAt: number; + referencedEvents: string[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d26f0cd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"] +}