diff --git a/package-lock.json b/package-lock.json index 59a19db..d1a41a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "4.1.9", "eslint": "^8.57.1", "eslint-config-next": "15.5.18", "jsdom": "^29.1.1", @@ -248,6 +249,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3680,6 +3691,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -4150,6 +4192,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -8102,6 +8163,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -8927,6 +8995,61 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9676,6 +9799,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/package.json b/package.json index b936303..bb19870 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "next start", "lint": "next lint", "test": "vitest run", + "test:coverage": "vitest run --coverage --coverage.include src/lib/soroban.ts", "playwright": "playwright test --config e2e/playwright.config.ts", "contributors:sync": "node scripts/sync-lernza-contributors.mjs", "contributors:github-insights": "node scripts/record-lernza-github-coauthors.mjs" @@ -43,6 +44,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "4.1.9", "eslint": "^8.57.1", "eslint-config-next": "15.5.18", "jsdom": "^29.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5123d1..1302726 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.3 version: 6.0.3(vite@8.1.0(@types/node@20.19.43)) + '@vitest/coverage-v8': + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.9) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -98,7 +101,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@20.19.43)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)) + version: 4.1.9(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)) packages: @@ -167,6 +170,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -481,89 +488,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -630,24 +653,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.18': resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.18': resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.18': resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.18': resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} @@ -776,36 +803,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -1150,51 +1183,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -1234,6 +1277,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -1374,6 +1426,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2583,6 +2638,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -2843,6 +2901,18 @@ packages: isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2859,6 +2929,9 @@ packages: js-sha3@0.8.0: resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3001,24 +3074,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3088,10 +3165,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4839,6 +4923,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -5975,6 +6061,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.0(@types/node@20.19.43) + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)) + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -6160,6 +6260,12 @@ snapshots: ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} async-limiter@1.0.1: {} @@ -7032,7 +7138,7 @@ snapshots: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -7052,7 +7158,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -7067,14 +7173,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -7089,7 +7195,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7736,6 +7842,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -7983,6 +8091,19 @@ snapshots: isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -8006,6 +8127,8 @@ snapshots: js-sha3@0.8.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -8230,10 +8353,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + math-intrinsics@1.1.0: {} md5.js@1.3.5: @@ -9774,7 +9907,7 @@ snapshots: '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@4.1.9(@types/node@20.19.43)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)): + vitest@4.1.9(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.43)) @@ -9798,6 +9931,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts new file mode 100644 index 0000000..fd9208c --- /dev/null +++ b/src/lib/soroban.service.test.ts @@ -0,0 +1,1640 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { assembleTransactionMock } = vi.hoisted(() => ({ + assembleTransactionMock: vi.fn(), +})); + +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + rpc: { + ...actual.rpc, + assembleTransaction: assembleTransactionMock, + }, + }; +}); + +import { + Account, + Address, + Contract, + type FeeBumpTransaction, + Networks, + StrKey, + type Transaction, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { + SorobanService, + amountToStroops, + buildFeeBumpTransaction, + computePartialUnlockPreview, + formatAssetAmount, + formatCredits, + formatLockTime, + getContractErrorMessage, + getStellarBalance, + getUserTransactionHistory, + lockAssets as lockAssetsWrapper, + parseCreditsFromXdrResult, + parsePoolsFromXdrResult, + parseUserPositionFromXdrResult, + sorobanService, + stellarExpertTxUrl, + unlockAssets, + validateSimulationAuth, +} from "./soroban"; + +const POOL_CONTRACT_ID = + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const USER_PUBLIC_KEY = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7)); +const POOL_ID = "pool-xlm"; + +type MockRpcServer = { + getAccount: ReturnType; + simulateTransaction: ReturnType; + sendTransaction: ReturnType; + getTransaction: ReturnType; + getLatestLedger: ReturnType; + getEvents: ReturnType; +}; + +function makePoolNative(overrides: Record = {}) { + return { + id: "pool-xlm", + contract_address: POOL_CONTRACT_ID, + asset_code: "XLM", + is_native: true, + daily_rate: BigInt(5_000_000), + min_lock_period: BigInt(86_400), + total_locked: BigInt(100_000_000), + total_users: BigInt(3), + is_active: true, + created_at: BigInt(1_700_000_000), + ...overrides, + }; +} + +function makeContractEvent({ + action, + address = USER_PUBLIC_KEY, + value, + ledgerClosedAt = "2026-06-29T12:00:00.000Z", + inSuccessfulContractCall = true, + contractId = POOL_CONTRACT_ID, + txHash = `${action}-tx`, +}: { + action: string; + address?: string; + value: unknown; + ledgerClosedAt?: string; + inSuccessfulContractCall?: boolean; + contractId?: string; + txHash?: string; +}) { + return { + inSuccessfulContractCall, + topic: [xdr.ScVal.scvSymbol(action), nativeToScVal(address)], + value: nativeToScVal(value), + ledgerClosedAt, + contractId, + txHash, + }; +} + +function invokeContractFromOperation(op: xdr.Operation) { + return op.body().invokeHostFunctionOp().hostFunction().invokeContract(); +} + +function makeAuthEntry(functionName: string, contractId = POOL_CONTRACT_ID) { + const contractFn = new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(contractId).toScAddress(), + functionName, + args: [], + }); + + return new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + contractFn, + ), + subInvocations: [], + }), + }); +} + +function makeMockRpcServer( + overrides: Partial = {}, +): MockRpcServer { + return { + getAccount: vi.fn().mockResolvedValue(new Account(USER_PUBLIC_KEY, "0")), + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn().mockResolvedValue({ status: "SUCCESS" }), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 200_000 }), + getEvents: vi.fn().mockResolvedValue({ events: [] }), + ...overrides, + }; +} + +function makeService({ + factory = false, + pool = true, + rpcServer = makeMockRpcServer(), +}: { + factory?: boolean; + pool?: boolean; + rpcServer?: MockRpcServer; +} = {}) { + const service = new SorobanService(); + const svc = service as unknown as { + factoryContract?: Contract; + poolContracts: Map; + rpcServer: MockRpcServer; + }; + + svc.rpcServer = rpcServer; + if (factory) svc.factoryContract = new Contract(POOL_CONTRACT_ID); + else svc.factoryContract = undefined; + if (pool) svc.poolContracts.set(POOL_ID, new Contract(POOL_CONTRACT_ID)); + + return { service, rpcServer }; +} + +function mockAssembleTransactionPassthrough() { + assembleTransactionMock.mockImplementation( + (transaction: Transaction | FeeBumpTransaction) => ({ + build: () => transaction, + }), + ); + return assembleTransactionMock; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + assembleTransactionMock.mockReset(); +}); + +describe("soroban formatters", () => { + it("formats credits below 1000, thousands, and millions", () => { + expect(formatCredits("999.4")).toBe("999"); + expect(formatCredits("1500")).toBe("1.5K"); + expect(formatCredits("2500000")).toBe("2.5M"); + }); + + it("formats lock time with fake timers for past, day, hour, and sub-hour cases", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-27T12:00:00.000Z")); + + const now = Date.now(); + + expect(formatLockTime(now - 1)).toBe("Unlockable now"); + expect(formatLockTime(now + 2 * 24 * 60 * 60 * 1000)).toBe( + "2 days remaining", + ); + expect(formatLockTime(now + 3 * 60 * 60 * 1000)).toBe("3 hours remaining"); + expect(formatLockTime(now + 30 * 60 * 1000)).toBe("Less than 1 hour"); + }); + + it("formats native and issued asset amounts with the asset code suffix", () => { + expect(formatAssetAmount("12.5", { code: "XLM", isNative: true })).toBe( + "12.5 XLM", + ); + + const issued = formatAssetAmount("1234.5", { + code: "USDC", + issuer: USER_PUBLIC_KEY, + isNative: false, + }); + + expect(issued).toMatch(/^(1,234\.5|1234\.5) USDC$/); + }); +}); + +describe("soroban amount and Horizon helpers", () => { + it("amountToStroops trims and pads decimal amounts", () => { + expect(amountToStroops(" 12.345 ")).toBe(123_450_000n); + expect(amountToStroops("1.2", 2)).toBe(120n); + expect(amountToStroops("5", 0)).toBe(5n); + }); + + it("amountToStroops rejects invalid decimal precision and amounts", () => { + expect(() => amountToStroops("1", -1)).toThrow( + "Decimal precision must be a non-negative integer.", + ); + expect(() => amountToStroops("abc")).toThrow( + "Enter a valid positive decimal amount.", + ); + expect(() => amountToStroops("1.234", 2)).toThrow( + "Amount supports at most 2 decimal places.", + ); + expect(() => amountToStroops("0")).toThrow( + "Amount must be greater than 0.", + ); + }); + + it("getStellarBalance returns the native balance from a realistic Horizon response", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + balances: [ + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + balance: "50.0000000", + }, + { asset_type: "native", balance: "123.4567890" }, + ], + }), + } as Response); + + await expect(getStellarBalance(USER_PUBLIC_KEY)).resolves.toBe(123.456789); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`/accounts/${USER_PUBLIC_KEY}$`)), + ); + }); + + it("getStellarBalance throws for Horizon non-OK and missing native balances", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + await expect(getStellarBalance(USER_PUBLIC_KEY)).rejects.toThrow( + "Unable to fetch Stellar balance from Horizon (404).", + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + balances: [{ asset_type: "credit_alphanum4", balance: "1" }], + }), + } as Response); + + await expect(getStellarBalance(USER_PUBLIC_KEY)).rejects.toThrow( + "Horizon account response did not include a native XLM balance.", + ); + }); + + it("getContractErrorMessage normalizes decimal and hex contract error codes", () => { + expect(getContractErrorMessage("1")).toBe("Assets are still locked"); + expect(getContractErrorMessage("contract code: 1")).toBe( + "Assets are still locked", + ); + expect(getContractErrorMessage("0x01")).toBe("Assets are still locked"); + expect(getContractErrorMessage("99")).toBeUndefined(); + }); +}); + +describe("soroban XDR wrappers", () => { + it("parses a valid ScVec of pool maps into PoolInfo entries", () => { + const scVal = nativeToScVal([makePoolNative()]); + + const pools = parsePoolsFromXdrResult(scVal); + + expect(pools).toHaveLength(1); + expect(pools[0]).toMatchObject({ + id: "pool-xlm", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0.5000000", + minLockPeriod: 86_400, + totalLocked: "10.0000000", + totalUsers: 3, + isActive: true, + createdAt: 1_700_000_000, + }); + }); + + it("returns an empty array for an empty ScVec", () => { + expect(parsePoolsFromXdrResult(nativeToScVal([]))).toEqual([]); + }); + + it("returns empty pools and warns when the XDR result is not a Vec", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + + expect(parsePoolsFromXdrResult(nativeToScVal({ id: "not-a-vec" }))).toEqual( + [], + ); + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] parsePoolsFromXdr: expected Vec (array), got", + "object", + ); + }); + + it("skips malformed entries and warns while keeping valid pools", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const scVal = nativeToScVal([ + makePoolNative({ id: "valid-1" }), + "malformed-entry", + makePoolNative({ id: "valid-2" }), + ]); + + const pools = parsePoolsFromXdrResult(scVal); + + expect(pools.map((pool) => pool.id)).toEqual(["valid-1", "valid-2"]); + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] parsePoolsFromXdr: skipping malformed pool at index 1:", + expect.any(TypeError), + ); + }); + + it("parses i128 credit stroops into display units", () => { + const credits = nativeToScVal(BigInt(25_000_000), { type: "i128" }); + + expect(parseCreditsFromXdrResult(credits)).toBe("2.5000000"); + }); + + it("returns null for empty and non-map user position XDR results", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + + expect( + parseUserPositionFromXdrResult( + xdr.ScVal.scvVoid(), + POOL_ID, + USER_PUBLIC_KEY, + ), + ).toBeNull(); + expect( + parseUserPositionFromXdrResult( + nativeToScVal("not-a-map"), + POOL_ID, + USER_PUBLIC_KEY, + ), + ).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] parseUserPositionFromXdr: expected Map (object), got", + "string", + ); + }); + + it("returns zero credits when credit XDR parsing fails", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + + expect(parseCreditsFromXdrResult(undefined as unknown as xdr.ScVal)).toBe( + "0", + ); + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] parseCreditsFromXdr: failed to parse:", + expect.any(Error), + ); + }); +}); + +describe("soroban transaction builders", () => { + it("builds fee-bump transactions from transaction objects and XDR strings", () => { + const sponsorPublicKey = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 9)); + const account = new Account(USER_PUBLIC_KEY, "0"); + const innerTransaction = new TransactionBuilder(account, { + fee: "321", + networkPassphrase: Networks.TESTNET, + }) + .addOperation(new Contract(POOL_CONTRACT_ID).call("noop")) + .setTimeout(30) + .build(); + + const objectFeeBump = buildFeeBumpTransaction( + innerTransaction, + sponsorPublicKey, + Networks.TESTNET, + ); + const xdrFeeBump = buildFeeBumpTransaction( + innerTransaction.toXDR(), + sponsorPublicKey, + Networks.TESTNET, + ); + + expect(objectFeeBump.feeSource).toBe(sponsorPublicKey); + expect(objectFeeBump.fee).toBe("642"); + expect(objectFeeBump.innerTransaction.operations).toHaveLength(1); + expect(xdrFeeBump.feeSource).toBe(sponsorPublicKey); + expect(xdrFeeBump.innerTransaction.source).toBe(USER_PUBLIC_KEY); + expect(xdrFeeBump.innerTransaction.operations).toHaveLength(1); + }); + + it("builds lock_assets with the user ScAddress and amount as i128", async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + error: "stop before signing", + }); + const callSpy = vi.spyOn(Contract.prototype, "call"); + + const result = await service.lockAssets( + POOL_ID, + USER_PUBLIC_KEY, + "123456789", + { + signTransaction: vi.fn(), + }, + ); + + expect(result).toEqual({ + success: false, + status: "FAILED", + error: "Simulation failed: stop before signing", + }); + expect(callSpy).toHaveBeenCalledWith( + "lock_assets", + expect.any(xdr.ScVal), + expect.any(xdr.ScVal), + ); + + const op = callSpy.mock.results[0].value as xdr.Operation; + const invokeContract = invokeContractFromOperation(op); + const [addressArg, amountArg] = invokeContract.args(); + + expect(invokeContract.functionName().toString()).toBe("lock_assets"); + expect(addressArg.switch()).toBe(xdr.ScValType.scvAddress()); + expect(addressArg.address().switch()).toBe( + xdr.ScAddressType.scAddressTypeAccount(), + ); + expect(scValToNative(addressArg)).toBe(USER_PUBLIC_KEY); + expect(amountArg.switch()).toBe(xdr.ScValType.scvI128()); + expect(scValToNative(amountArg)).toBe( + BigInt(123_456_789) * BigInt(10_000_000), + ); + }); + + it("converts unlock display units to stroops before delegating", async () => { + const walletApi = { signTransaction: vi.fn() }; + const unlockSpy = vi + .spyOn(sorobanService, "unlockAssets") + .mockResolvedValue({ success: true, transactionHash: "abc123" }); + + await expect( + unlockAssets({ + poolContractId: "pool-xlm", + publicKey: USER_PUBLIC_KEY, + amount: "1.2345678", + walletApi, + }), + ).resolves.toEqual({ success: true, transactionHash: "abc123" }); + + expect(unlockSpy).toHaveBeenCalledWith( + "pool-xlm", + USER_PUBLIC_KEY, + "12345678", + walletApi, + { onHash: undefined, onStep: undefined }, + ); + }); + + it("delegates lockAssets wrapper callbacks to the singleton service", async () => { + const walletApi = { signTransaction: vi.fn() }; + const onHash = vi.fn(); + const onStep = vi.fn(); + const lockSpy = vi + .spyOn(sorobanService, "lockAssets") + .mockResolvedValue({ + success: true, + transactionHash: "lock-wrapper-hash", + }); + + await expect( + lockAssetsWrapper({ + poolContractId: "pool-xlm", + publicKey: USER_PUBLIC_KEY, + amount: "1.5000000", + walletApi, + onHash, + onStep, + }), + ).resolves.toEqual({ success: true, transactionHash: "lock-wrapper-hash" }); + + expect(lockSpy).toHaveBeenCalledWith( + "pool-xlm", + USER_PUBLIC_KEY, + "1.5000000", + walletApi, + { onHash, onStep }, + ); + }); +}); + +describe("soroban authorization validation", () => { + it("rejects missing, mismatched, and unexpected simulation auth entries", () => { + expect(() => + validateSimulationAuth({ result: { auth: null } }, []), + ).toThrow( + "Transaction signing was blocked because the simulation did not return authorization entries.", + ); + expect(() => + validateSimulationAuth({ result: { auth: [] } }, [ + { contractId: POOL_CONTRACT_ID, functionName: "lock_assets" }, + ]), + ).toThrow( + "Transaction signing was blocked because the simulation returned 0 authorization entries, but SmartDrop expected 1.", + ); + expect(() => + validateSimulationAuth( + { result: { auth: [makeAuthEntry("set_boost")] } }, + [{ contractId: POOL_CONTRACT_ID, functionName: "lock_assets" }], + ), + ).toThrow( + `Transaction signing was blocked because the simulated authorization targets ${POOL_CONTRACT_ID}.set_boost, which is not expected for this SmartDrop action.`, + ); + }); +}); + +describe("SorobanService RPC reads", () => { + it("getFactoryPools returns parsed pools from a simulated factory call", async () => { + const { service, rpcServer } = makeService({ factory: true, pool: false }); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal([makePoolNative({ id: "factory-pool" })]), + }, + }); + + const pools = await service.getFactoryPools(); + + expect(pools).toHaveLength(1); + expect(pools[0]).toMatchObject({ + id: "factory-pool", + contractAddress: POOL_CONTRACT_ID, + totalLocked: "10.0000000", + }); + expect(rpcServer.getAccount).toHaveBeenCalledWith( + "GBQ3WPTHKJ5XKWLOKUZJLZL2GVXR6RWQCXUVDQZWM7Q2YNLDRVGM5ZWJ", + ); + expect(rpcServer.simulateTransaction).toHaveBeenCalledTimes(1); + }); + + it("getFactoryPools returns an empty list when the factory is not initialized", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ factory: false, pool: false }); + + await expect(service.getFactoryPools()).resolves.toEqual([]); + + expect(warnSpy).toHaveBeenCalledWith( + "Factory contract not initialized; returning empty pool list", + ); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it("getFactoryPools returns an empty list when simulation reports an error", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ factory: true, pool: false }); + rpcServer.simulateTransaction.mockResolvedValue({ + error: "factory unavailable", + }); + + await expect(service.getFactoryPools()).resolves.toEqual([]); + }); + + it("getUserPosition returns a parsed user position from a simulated pool call", async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal({ + amount: BigInt(75_000_000), + locked_at: BigInt(1_700_000_000), + credits: BigInt(15_000_000), + is_locked: true, + unlockable_at: BigInt(1_700_086_400), + boost_allocation: 25, + }), + }, + }); + + const position = await service.getUserPosition(POOL_ID, USER_PUBLIC_KEY); + + expect(position).toMatchObject({ + user: USER_PUBLIC_KEY, + poolId: POOL_ID, + amount: "7.5000000", + lockedAt: 1_700_000_000, + credits: "1.5000000", + isLocked: true, + unlockableAt: 1_700_086_400, + boostAllocation: 25, + }); + }); + + it("getUserPosition returns null when the pool is not registered", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + + await expect( + service.getUserPosition("missing-pool", USER_PUBLIC_KEY), + ).resolves.toBeNull(); + + expect(warnSpy).toHaveBeenCalledWith( + "Pool contract not found for ID: missing-pool", + ); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it("getUserPosition returns null when simulation reports an error", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + error: "position failed", + }); + + await expect( + service.getUserPosition(POOL_ID, USER_PUBLIC_KEY), + ).resolves.toBeNull(); + }); + + it("calculateUserCredits returns parsed credit display units", async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { retval: nativeToScVal(BigInt(12_345_678), { type: "i128" }) }, + }); + + await expect( + service.calculateUserCredits(POOL_ID, USER_PUBLIC_KEY), + ).resolves.toBe("1.2345678"); + }); + + it("calculateUserCredits returns zero when the pool is not registered", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + + await expect( + service.calculateUserCredits("missing-pool", USER_PUBLIC_KEY), + ).resolves.toBe("0"); + + expect(warnSpy).toHaveBeenCalledWith( + "Pool contract not found for ID: missing-pool", + ); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it("calculateUserCredits returns zero when simulation reports an error", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ error: "credit failed" }); + + await expect( + service.calculateUserCredits(POOL_ID, USER_PUBLIC_KEY), + ).resolves.toBe("0"); + }); +}); + +describe("SorobanService RPC writes", () => { + it("lockAssets signs and submits an assembled transaction on success", async () => { + const { service, rpcServer } = makeService(); + const assembleSpy = mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("lock_assets")] }, + minResourceFee: "321", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "PENDING", + hash: "lock-hash", + }); + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; + + const result = await service.lockAssets( + POOL_ID, + USER_PUBLIC_KEY, + "50000000", + walletApi, + ); + + expect(result).toEqual({ + success: true, + transactionHash: "lock-hash", + hash: "lock-hash", + status: "SUCCESS", + resultXdr: undefined, + gasUsed: "321", + }); + expect(assembleSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ minResourceFee: "321" }), + ); + expect(walletApi.signTransaction).toHaveBeenCalledWith(expect.any(String), { + networkPassphrase: expect.any(String), + }); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("lockAssets returns decoded contract error details when confirmation fails", async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("lock_assets")] }, + minResourceFee: "321", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "PENDING", + hash: "failed-lock-hash", + }); + rpcServer.getTransaction.mockResolvedValue({ + status: "FAILED", + errorResult: "Host function failed with contract code: 1", + }); + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; + + const result = await service.lockAssets( + POOL_ID, + USER_PUBLIC_KEY, + "50000000", + walletApi, + ); + + expect(result).toMatchObject({ + success: false, + transactionHash: "failed-lock-hash", + hash: "failed-lock-hash", + status: "FAILED", + errorCode: "1", + error: "Assets are still locked", + }); + expect(rpcServer.getTransaction).toHaveBeenCalledWith("failed-lock-hash"); + }); + + it("unlockAssets signs and submits an assembled transaction on success", async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("unlock_assets")] }, + minResourceFee: "654", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "PENDING", + hash: "unlock-hash", + }); + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; + + const result = await service.unlockAssets( + POOL_ID, + USER_PUBLIC_KEY, + "25000000", + walletApi, + ); + + expect(result).toEqual({ + success: true, + transactionHash: "unlock-hash", + hash: "unlock-hash", + status: "SUCCESS", + resultXdr: undefined, + gasUsed: "654", + }); + expect(walletApi.signTransaction).toHaveBeenCalledTimes(1); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("unlockAssets throws when the pool is not registered", async () => { + const { service, rpcServer } = makeService({ pool: false }); + + await expect( + service.unlockAssets("missing-pool", USER_PUBLIC_KEY, "10000000", { + signTransaction: vi.fn(), + }), + ).rejects.toThrow("Pool contract not found for ID: missing-pool"); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it("setBoost signs and submits an assembled transaction on success", async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("set_boost")] }, + minResourceFee: "777", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "PENDING", + hash: "boost-hash", + }); + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; + + const result = await service.setBoost( + POOL_ID, + USER_PUBLIC_KEY, + 40, + walletApi, + ); + + expect(result).toEqual({ + success: true, + transactionHash: "boost-hash", + hash: "boost-hash", + gasUsed: "777", + }); + expect(walletApi.signTransaction).toHaveBeenCalledTimes(1); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("setBoost rejects invalid allocation percentages before RPC calls", async () => { + const { service, rpcServer } = makeService(); + + await expect( + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 101, { + signTransaction: vi.fn(), + }), + ).resolves.toEqual({ + success: false, + error: "Allocation percentage must be between 0 and 100", + }); + expect(rpcServer.getAccount).not.toHaveBeenCalled(); + }); + + it("setBoost returns a failure result when simulation reports an error", async () => { + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + error: "boost simulation failed", + }); + + await expect( + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, { + signTransaction: vi.fn(), + }), + ).resolves.toEqual({ + success: false, + error: "Simulation failed: boost simulation failed", + }); + expect(rpcServer.sendTransaction).not.toHaveBeenCalled(); + }); + + it("setBoost returns a failure result when signing fails", async () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("set_boost")] }, + minResourceFee: "777", + }); + + await expect( + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, { + signTransaction: vi + .fn() + .mockRejectedValue(new Error("wallet rejected")), + }), + ).resolves.toEqual({ + success: false, + error: "wallet rejected", + }); + expect(errorSpy).toHaveBeenCalledWith( + "Error setting boost:", + expect.any(Error), + ); + expect(rpcServer.sendTransaction).not.toHaveBeenCalled(); + }); + + it("setBoost returns a failure result when submission returns ERROR", async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("set_boost")] }, + minResourceFee: "777", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "ERROR", + errorResult: "tx rejected", + }); + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; + + await expect( + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, walletApi), + ).resolves.toEqual({ + success: false, + error: "Transaction failed: tx rejected", + }); + expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("setBoost rethrows security validation failures", async () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService(); + rpcServer.simulateTransaction.mockResolvedValue({ + result: { auth: [makeAuthEntry("lock_assets")] }, + }); + + await expect( + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, { + signTransaction: vi.fn(), + }), + ).rejects.toThrow("not expected for this SmartDrop action"); + expect(errorSpy).toHaveBeenCalledWith( + "Error setting boost:", + expect.any(Error), + ); + expect(rpcServer.sendTransaction).not.toHaveBeenCalled(); + }); +}); + +describe("SorobanService platform stats", () => { + it("getPlatformStats aggregates pool totals from getFactoryPools", async () => { + const { service } = makeService({ pool: false }); + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ + { + id: "pool-1", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0.5000000", + minLockPeriod: 86_400, + totalLocked: "1000", + totalUsers: 25, + isActive: true, + createdAt: 1, + }, + { + id: "pool-2", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "USDC", issuer: USER_PUBLIC_KEY, isNative: false }, + dailyRate: "0.2500000", + minLockPeriod: 43_200, + totalLocked: "2500", + totalUsers: 15, + isActive: true, + createdAt: 2, + }, + ]); + + await expect(service.getPlatformStats()).resolves.toEqual({ + totalValueLocked: "$3,500", + totalUsers: 40, + onlineUsers: 4, + totalPools: 2, + }); + }); + + it("getPlatformStats returns zero stats when getFactoryPools throws", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { service } = makeService({ pool: false }); + vi.spyOn(service, "getFactoryPools").mockRejectedValue( + new Error("stats failed"), + ); + + await expect(service.getPlatformStats()).resolves.toEqual({ + totalValueLocked: "$0", + totalUsers: 0, + onlineUsers: 0, + totalPools: 0, + }); + }); +}); + +describe("SorobanService event-derived pool data", () => { + it("getPoolHistory derives daily TVL from successful lock and unlock events", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-29T12:00:00.000Z")); + const { service, rpcServer } = makeService({ pool: false }); + rpcServer.getLatestLedger.mockResolvedValue({ sequence: 200_000 }); + rpcServer.getEvents.mockResolvedValue({ + events: [ + makeContractEvent({ + action: "lock_assets", + value: [50_000_000n], + ledgerClosedAt: "2026-06-28T10:00:00.000Z", + }), + makeContractEvent({ + action: "unlock_assets", + value: { amount: 20_000_000n }, + ledgerClosedAt: "2026-06-29T10:00:00.000Z", + }), + makeContractEvent({ + action: "lock_assets", + value: { amount: 99_000_000n }, + ledgerClosedAt: "2026-06-29T11:00:00.000Z", + inSuccessfulContractCall: false, + }), + ], + }); + + await expect(service.getPoolHistory(POOL_CONTRACT_ID, 3)).resolves.toEqual([ + { date: "2026-06-27", tvl: "0" }, + { date: "2026-06-28", tvl: "5" }, + { date: "2026-06-29", tvl: "3" }, + ]); + + expect(rpcServer.getEvents).toHaveBeenCalledWith( + expect.objectContaining({ + startLedger: 148_160, + filters: [ + expect.objectContaining({ + type: "contract", + contractIds: [POOL_CONTRACT_ID], + }), + ], + limit: 500, + }), + ); + }); + + it("getPoolHistory returns an empty list when event RPC throws", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + rpcServer.getLatestLedger.mockRejectedValue( + new Error("ledger unavailable"), + ); + + await expect(service.getPoolHistory(POOL_CONTRACT_ID)).resolves.toEqual([]); + + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] getPoolHistory failed:", + expect.any(Error), + ); + }); + + it("getPoolDepositors aggregates successful lock and unlock events by address", async () => { + const otherUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 8)); + const { service, rpcServer } = makeService({ pool: false }); + rpcServer.getLatestLedger.mockResolvedValue({ sequence: 200_000 }); + rpcServer.getEvents.mockResolvedValue({ + events: [ + makeContractEvent({ + action: "lock_assets", + address: USER_PUBLIC_KEY, + value: { amount: 100_000_000n }, + }), + makeContractEvent({ + action: "lock_assets", + address: otherUser, + value: [30_000_000n], + }), + makeContractEvent({ + action: "unlock_assets", + address: USER_PUBLIC_KEY, + value: { amount: 40_000_000n }, + }), + makeContractEvent({ + action: "lock_assets", + address: "", + value: { amount: 999_000_000n }, + }), + makeContractEvent({ + action: "lock_assets", + address: otherUser, + value: { amount: 999_000_000n }, + inSuccessfulContractCall: false, + }), + ], + }); + + await expect( + service.getPoolDepositors(POOL_CONTRACT_ID, 2), + ).resolves.toEqual([ + { address: USER_PUBLIC_KEY, amount: "6", credits: "—" }, + { address: otherUser, amount: "3", credits: "—" }, + ]); + expect(rpcServer.getEvents).toHaveBeenCalledWith( + expect.objectContaining({ + startLedger: 79_040, + filters: [ + expect.objectContaining({ + type: "contract", + contractIds: [POOL_CONTRACT_ID], + }), + ], + limit: 500, + }), + ); + }); + + it("getPoolDepositors returns an empty list when event RPC throws", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + rpcServer.getEvents.mockRejectedValue(new Error("events unavailable")); + + await expect(service.getPoolDepositors(POOL_CONTRACT_ID)).resolves.toEqual( + [], + ); + + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] getPoolDepositors failed:", + expect.any(Error), + ); + }); +}); + +describe("SorobanService leaderboard", () => { + type LeaderboardInternals = { + fetchLeaderboardFromEvents: ( + offset: number, + limit: number, + sortKey: "credits" | "stake", + ) => Promise; + getLeaderboardPoolIds: () => Promise; + extractEventAmount: (valueNative: unknown) => number; + rpcServer: MockRpcServer; + }; + + function internals(service: SorobanService) { + return service as unknown as LeaderboardInternals; + } + + it("getLeaderboardPoolIds combines env and factory pool contract IDs without duplicates", async () => { + const previousPool = process.env.NEXT_PUBLIC_POOL_CONTRACT_ID; + process.env.NEXT_PUBLIC_POOL_CONTRACT_ID = POOL_CONTRACT_ID; + const secondPool = StrKey.encodeContract(Buffer.alloc(32, 9)); + const { service } = makeService({ pool: false }); + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ + { + id: "pool-1", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0", + minLockPeriod: 0, + totalLocked: "0", + totalUsers: 0, + isActive: true, + createdAt: 1, + }, + { + id: "pool-2", + contractAddress: secondPool, + asset: { code: "USDC", issuer: USER_PUBLIC_KEY, isNative: false }, + dailyRate: "0", + minLockPeriod: 0, + totalLocked: "0", + totalUsers: 0, + isActive: true, + createdAt: 2, + }, + ]); + + try { + await expect(internals(service).getLeaderboardPoolIds()).resolves.toEqual( + [POOL_CONTRACT_ID, secondPool], + ); + } finally { + if (previousPool === undefined) + delete process.env.NEXT_PUBLIC_POOL_CONTRACT_ID; + else process.env.NEXT_PUBLIC_POOL_CONTRACT_ID = previousPool; + } + }); + + it("extractEventAmount handles bigint, number, array, object, and null values", () => { + const { service } = makeService({ pool: false }); + const helper = internals(service).extractEventAmount.bind( + internals(service), + ); + + expect(helper(12n)).toBe(12); + expect(helper(34)).toBe(34); + expect(helper([56n])).toBe(56); + expect(helper({ amount: 78n })).toBe(78); + expect(helper({ credits: 90 })).toBe(90); + expect(helper({ credits_earned: 123 })).toBe(123); + expect(helper(null)).toBe(0); + }); + + it("getLeaderboard derives rows from events and sorts by credits or stake", async () => { + const otherUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 8)); + const { service, rpcServer } = makeService({ pool: false }); + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ + { + id: "factory-pool", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0", + minLockPeriod: 0, + totalLocked: "0", + totalUsers: 0, + isActive: true, + createdAt: 1, + }, + ]); + rpcServer.getLatestLedger.mockResolvedValue({ sequence: 200_000 }); + rpcServer.getEvents.mockResolvedValue({ + events: [ + makeContractEvent({ + action: "lock_assets", + address: USER_PUBLIC_KEY, + value: { amount: 300_000_000n }, + }), + makeContractEvent({ + action: "update_credits", + address: USER_PUBLIC_KEY, + value: { credits: 120 }, + }), + makeContractEvent({ + action: "lock_assets", + address: otherUser, + value: [800_000_000n], + }), + makeContractEvent({ + action: "unlock_assets", + address: otherUser, + value: 100_000_000, + }), + makeContractEvent({ + action: "update_credits", + address: otherUser, + value: 50, + }), + makeContractEvent({ + action: "lock_assets", + address: "", + value: { amount: 999_000_000n }, + }), + makeContractEvent({ + action: "update_credits", + address: otherUser, + value: 999, + inSuccessfulContractCall: false, + }), + ], + }); + + await expect(service.getLeaderboard(0, 10, "credits")).resolves.toEqual({ + entries: [ + { + address: USER_PUBLIC_KEY, + totalCredits: 120, + totalStake: 30, + boostUtilization: 0, + }, + { + address: otherUser, + totalCredits: 50, + totalStake: 70, + boostUtilization: 0, + }, + ], + total: 2, + }); + + await expect(service.getLeaderboard(0, 1, "stake")).resolves.toEqual({ + entries: [ + { + address: otherUser, + totalCredits: 50, + totalStake: 70, + boostUtilization: 0, + }, + ], + total: 2, + }); + }); + + it("fetchLeaderboardFromEvents returns an empty page without pool IDs and on RPC errors", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { service, rpcServer } = makeService({ pool: false }); + vi.spyOn(service, "getFactoryPools").mockResolvedValueOnce([]); + + await expect( + internals(service).fetchLeaderboardFromEvents(0, 10, "credits"), + ).resolves.toEqual({ + entries: [], + total: 0, + }); + expect(rpcServer.getEvents).not.toHaveBeenCalled(); + + vi.spyOn(service, "getFactoryPools").mockResolvedValueOnce([ + { + id: "factory-pool", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0", + minLockPeriod: 0, + totalLocked: "0", + totalUsers: 0, + isActive: true, + createdAt: 1, + }, + ]); + rpcServer.getLatestLedger.mockRejectedValueOnce( + new Error("ledger unavailable"), + ); + + await expect( + internals(service).fetchLeaderboardFromEvents(0, 10, "credits"), + ).resolves.toEqual({ + entries: [], + total: 0, + }); + expect(warnSpy).toHaveBeenCalledWith( + "[SmartDrop] event-derived leaderboard failed:", + expect.any(Error), + ); + }); + + it("getLeaderboard uses the configured API and normalizes sparse API rows", async () => { + const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = + "https://leaderboard.example/rankings"; + vi.resetModules(); + const { SorobanService: ApiSorobanService } = await import("./soroban"); + const service = new ApiSorobanService(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + entries: [ + { address: USER_PUBLIC_KEY, totalCredits: "42", totalStake: "7" }, + { boostUtilization: "15" }, + ], + total: 9, + }), + } as Response); + + try { + await expect(service.getLeaderboard(5, 2, "stake")).resolves.toEqual({ + entries: [ + { + address: USER_PUBLIC_KEY, + totalCredits: 42, + totalStake: 7, + boostUtilization: 0, + }, + { + address: "", + totalCredits: 0, + totalStake: 0, + boostUtilization: 15, + }, + ], + total: 9, + }); + } finally { + if (previousApi === undefined) + delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + else process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = previousApi; + vi.resetModules(); + } + + expect(fetchSpy).toHaveBeenCalledWith( + "https://leaderboard.example/rankings?offset=5&limit=2&sort=stake", + { headers: { accept: "application/json" } }, + ); + }); + + it("getLeaderboard falls back to event scanning when the API responds non-OK", async () => { + const previousApi = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = + "https://leaderboard.example/rankings"; + vi.resetModules(); + const { SorobanService: ApiSorobanService } = await import("./soroban"); + const service = new ApiSorobanService(); + const rpcServer = makeMockRpcServer({ + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 200_000 }), + getEvents: vi.fn().mockResolvedValue({ + events: [ + makeContractEvent({ + action: "lock_assets", + address: USER_PUBLIC_KEY, + value: { amount: 250_000_000n }, + }), + ], + }), + }); + internals(service as SorobanService).rpcServer = rpcServer; + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ + { + id: "factory-pool", + contractAddress: POOL_CONTRACT_ID, + asset: { code: "XLM", isNative: true }, + dailyRate: "0", + minLockPeriod: 0, + totalLocked: "0", + totalUsers: 0, + isActive: true, + createdAt: 1, + }, + ]); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 503, + } as Response); + + try { + await expect(service.getLeaderboard(0, 10, "credits")).resolves.toEqual({ + entries: [ + { + address: USER_PUBLIC_KEY, + totalCredits: 0, + totalStake: 25, + boostUtilization: 0, + }, + ], + total: 1, + }); + } finally { + if (previousApi === undefined) + delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + else process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = previousApi; + vi.resetModules(); + } + }); + + it("getCreditVelocity currently returns the zero accumulator", async () => { + const { service } = makeService({ pool: false }); + + await expect(service.getCreditVelocity(12)).resolves.toBe("0"); + }); +}); + +describe("soroban exported utilities and transaction history", () => { + it("builds Stellar Expert transaction URLs and partial unlock previews", () => { + expect(stellarExpertTxUrl("abc123", "testnet")).toBe( + "https://stellar.expert/explorer/testnet/tx/abc123", + ); + expect(computePartialUnlockPreview(100, 25, 8)).toEqual({ + remainingStake: 75, + newDailyRate: 6, + }); + expect(computePartialUnlockPreview(0, 0, 8)).toEqual({ + remainingStake: 0, + newDailyRate: 0, + }); + }); + + it("getUserTransactionHistory parses, filters, and sorts lock and unlock events", async () => { + const otherUser = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 8)); + const rpcOverride = { + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 200_000 }), + getEvents: vi.fn().mockResolvedValue({ + events: [ + makeContractEvent({ + action: "lock_assets", + address: USER_PUBLIC_KEY, + value: [100_000_000n, "XLM"], + ledgerClosedAt: "2026-06-28T10:00:00.000Z", + contractId: POOL_CONTRACT_ID, + txHash: "lock-tx", + }), + makeContractEvent({ + action: "unlock_assets", + address: USER_PUBLIC_KEY, + value: { + amount: 25_000_000n, + symbol: "XLM", + credits_earned: 7_500_000n, + }, + ledgerClosedAt: "2026-06-29T10:00:00.000Z", + contractId: POOL_CONTRACT_ID, + txHash: "unlock-tx", + }), + makeContractEvent({ + action: "unlock_assets", + address: USER_PUBLIC_KEY, + value: [10_000_000n, "XLM", 1_500_000n], + ledgerClosedAt: "2026-06-27T10:00:00.000Z", + contractId: POOL_CONTRACT_ID, + txHash: "unlock-array-tx", + }), + makeContractEvent({ + action: "lock_assets", + address: otherUser, + value: [999_000_000n, "XLM"], + ledgerClosedAt: "2026-06-29T11:00:00.000Z", + txHash: "other-user-tx", + }), + makeContractEvent({ + action: "update_credits", + address: USER_PUBLIC_KEY, + value: { credits: 100 }, + ledgerClosedAt: "2026-06-29T12:00:00.000Z", + txHash: "ignored-action-tx", + }), + makeContractEvent({ + action: "lock_assets", + address: USER_PUBLIC_KEY, + value: [999_000_000n, "XLM"], + ledgerClosedAt: "2026-06-29T13:00:00.000Z", + inSuccessfulContractCall: false, + txHash: "failed-tx", + }), + { + inSuccessfulContractCall: true, + topic: [ + xdr.ScVal.scvSymbol("lock_assets"), + nativeToScVal(USER_PUBLIC_KEY), + ], + value: nativeToScVal([1_000_000n, "XLM"]), + ledgerClosedAt: "2026-06-29T14:00:00.000Z", + txHash: "missing-pool-tx", + }, + { + inSuccessfulContractCall: true, + topic: undefined, + value: nativeToScVal([1_000_000n, "XLM"]), + ledgerClosedAt: "2026-06-29T15:00:00.000Z", + contractId: POOL_CONTRACT_ID, + txHash: "malformed-tx", + }, + ], + }), + }; + + await expect( + getUserTransactionHistory( + USER_PUBLIC_KEY, + [POOL_CONTRACT_ID], + rpcOverride, + ), + ).resolves.toEqual([ + { + date: "2026-06-29T10:00:00.000Z", + action: "unlock", + amount: "25000000", + symbol: "XLM", + poolId: POOL_CONTRACT_ID, + creditsEarned: "7500000", + txHash: "unlock-tx", + }, + { + date: "2026-06-28T10:00:00.000Z", + action: "lock", + amount: "100000000", + symbol: "XLM", + poolId: POOL_CONTRACT_ID, + creditsEarned: undefined, + txHash: "lock-tx", + }, + { + date: "2026-06-27T10:00:00.000Z", + action: "unlock", + amount: "10000000", + symbol: "XLM", + poolId: POOL_CONTRACT_ID, + creditsEarned: "1500000", + txHash: "unlock-array-tx", + }, + ]); + expect(rpcOverride.getEvents).toHaveBeenCalledWith( + expect.objectContaining({ + startLedger: 79_040, + filters: [ + expect.objectContaining({ + type: "contract", + contractIds: [POOL_CONTRACT_ID], + }), + ], + limit: 200, + }), + ); + }); + + it("getUserTransactionHistory handles empty guards and RPC failures", async () => { + const rpcOverride = { + getLatestLedger: vi + .fn() + .mockRejectedValue(new Error("ledger unavailable")), + getEvents: vi.fn(), + }; + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + await expect( + getUserTransactionHistory("", [POOL_CONTRACT_ID], rpcOverride), + ).resolves.toEqual([]); + await expect( + getUserTransactionHistory(USER_PUBLIC_KEY, [], rpcOverride), + ).resolves.toEqual([]); + expect(rpcOverride.getLatestLedger).not.toHaveBeenCalled(); + + await expect( + getUserTransactionHistory( + USER_PUBLIC_KEY, + [POOL_CONTRACT_ID], + rpcOverride, + ), + ).resolves.toEqual([]); + expect(errorSpy).toHaveBeenCalledWith( + "Error fetching transaction history:", + expect.any(Error), + ); + }); +});