From c85c839b77ddbb6c0510ff157956563b293ef60b Mon Sep 17 00:00:00 2001 From: Joneshaves Date: Sat, 27 Jun 2026 02:03:37 +0100 Subject: [PATCH 1/5] test: add Vitest suite for soroban.ts (#25) --- package-lock.json | 135 +++++++++ package.json | 2 + pnpm-lock.yaml | 148 ++++++++- src/lib/soroban.service.test.ts | 512 ++++++++++++++++++++++++++++++++ 4 files changed, 790 insertions(+), 7 deletions(-) create mode 100644 src/lib/soroban.service.test.ts 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..854b8ee --- /dev/null +++ b/src/lib/soroban.service.test.ts @@ -0,0 +1,512 @@ +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, + Contract, + type FeeBumpTransaction, + StrKey, + type Transaction, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { + SorobanService, + formatAssetAmount, + formatCredits, + formatLockTime, + parseCreditsFromXdrResult, + parsePoolsFromXdrResult, + sorobanService, + unlockAssets, +} 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; +}; + +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 invokeContractFromOperation(op: xdr.Operation) { + return op + .body() + .invokeHostFunctionOp() + .hostFunction() + .invokeContract(); +} + +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' }), + ...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 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('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'); + }); +}); + +describe('soroban transaction builders', () => { + 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, + 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)); + }); + + 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, + ); + }); +}); + +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: {}, 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', + 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('unlockAssets signs and submits an assembled transaction on success', async () => { + const { service, rpcServer } = makeService(); + mockAssembleTransactionPassthrough(); + rpcServer.simulateTransaction.mockResolvedValue({ result: {}, 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', + 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: {}, 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(); + }); +}); + +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, + }); + }); +}); From ad4277e490d066e6c3a02ee91c337a0bae9ee32f Mon Sep 17 00:00:00 2001 From: Joneshaves Date: Sun, 28 Jun 2026 22:57:06 +0100 Subject: [PATCH 2/5] test: align write-path mocks with validateSimulationAuth (#56) --- src/lib/soroban.service.test.ts | 52 ++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts index 854b8ee..e42ad87 100644 --- a/src/lib/soroban.service.test.ts +++ b/src/lib/soroban.service.test.ts @@ -1,8 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -const { assembleTransactionMock } = vi.hoisted(() => ({ - assembleTransactionMock: vi.fn(), -})); +const { assembleTransactionMock } = vi.hoisted(() => { + (globalThis as typeof globalThis & { Networks: { TESTNET: string } }).Networks = { + TESTNET: 'Test SDF Network ; September 2015', + }; + + return { + assembleTransactionMock: vi.fn(), + }; +}); vi.mock('@stellar/stellar-sdk', async importOriginal => { const actual = await importOriginal(); @@ -18,6 +24,7 @@ vi.mock('@stellar/stellar-sdk', async importOriginal => { import { Account, + Address, Contract, type FeeBumpTransaction, StrKey, @@ -72,6 +79,22 @@ function invokeContractFromOperation(op: xdr.Operation) { .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')), @@ -211,6 +234,7 @@ describe('soroban transaction builders', () => { expect(result).toEqual({ success: false, + status: 'FAILED', error: 'Simulation failed: stop before signing', }); expect(callSpy).toHaveBeenCalledWith( @@ -228,7 +252,7 @@ describe('soroban transaction builders', () => { 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)); + expect(scValToNative(amountArg)).toBe(BigInt(123_456_789) * BigInt(10_000_000)); }); it('converts unlock display units to stroops before delegating', async () => { @@ -251,6 +275,7 @@ describe('soroban transaction builders', () => { USER_PUBLIC_KEY, '12345678', walletApi, + { onHash: undefined, onStep: undefined }, ); }); }); @@ -377,7 +402,10 @@ 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: {}, minResourceFee: '321' }); + 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) }; @@ -387,6 +415,8 @@ describe('SorobanService RPC writes', () => { success: true, transactionHash: 'lock-hash', hash: 'lock-hash', + status: 'SUCCESS', + resultXdr: undefined, gasUsed: '321', }); expect(assembleSpy).toHaveBeenCalledWith( @@ -402,7 +432,10 @@ describe('SorobanService RPC writes', () => { it('unlockAssets signs and submits an assembled transaction on success', async () => { const { service, rpcServer } = makeService(); mockAssembleTransactionPassthrough(); - rpcServer.simulateTransaction.mockResolvedValue({ result: {}, minResourceFee: '654' }); + 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) }; @@ -412,6 +445,8 @@ describe('SorobanService RPC writes', () => { success: true, transactionHash: 'unlock-hash', hash: 'unlock-hash', + status: 'SUCCESS', + resultXdr: undefined, gasUsed: '654', }); expect(walletApi.signTransaction).toHaveBeenCalledTimes(1); @@ -432,7 +467,10 @@ describe('SorobanService RPC writes', () => { it('setBoost signs and submits an assembled transaction on success', async () => { const { service, rpcServer } = makeService(); mockAssembleTransactionPassthrough(); - rpcServer.simulateTransaction.mockResolvedValue({ result: {}, minResourceFee: '777' }); + 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) }; From ae55020f1762ec3cbe97e7cb8a78b3081cb79246 Mon Sep 17 00:00:00 2001 From: Joneshaves Date: Mon, 29 Jun 2026 12:17:52 +0100 Subject: [PATCH 3/5] test: align write-path mocks with validateSimulationAuth --- src/lib/soroban.service.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts index e42ad87..7070f8d 100644 --- a/src/lib/soroban.service.test.ts +++ b/src/lib/soroban.service.test.ts @@ -1,14 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -const { assembleTransactionMock } = vi.hoisted(() => { - (globalThis as typeof globalThis & { Networks: { TESTNET: string } }).Networks = { - TESTNET: 'Test SDF Network ; September 2015', - }; - - return { - assembleTransactionMock: vi.fn(), - }; -}); +const { assembleTransactionMock } = vi.hoisted(() => ({ + assembleTransactionMock: vi.fn(), +})); vi.mock('@stellar/stellar-sdk', async importOriginal => { const actual = await importOriginal(); From 2075f0fc0b8b0b5ba73840b84bebc4b0b1635053 Mon Sep 17 00:00:00 2001 From: Joneshaves Date: Mon, 29 Jun 2026 13:33:04 +0100 Subject: [PATCH 4/5] test: add soroban service coverage --- src/lib/soroban.service.test.ts | 901 ++++++++++++++++++++++++++++++++ 1 file changed, 901 insertions(+) diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts index 7070f8d..5e84e5b 100644 --- a/src/lib/soroban.service.test.ts +++ b/src/lib/soroban.service.test.ts @@ -21,21 +21,33 @@ import { 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'; @@ -47,6 +59,8 @@ type MockRpcServer = { simulateTransaction: ReturnType; sendTransaction: ReturnType; getTransaction: ReturnType; + getLatestLedger: ReturnType; + getEvents: ReturnType; }; function makePoolNative(overrides: Record = {}) { @@ -65,6 +79,33 @@ function makePoolNative(overrides: Record = {}) { }; } +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() @@ -95,6 +136,8 @@ function makeMockRpcServer(overrides: Partial = {}): MockRpcServe 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, }; } @@ -168,6 +211,70 @@ describe('soroban formatters', () => { }); }); +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()]); @@ -192,6 +299,16 @@ describe('soroban XDR wrappers', () => { 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([ @@ -214,9 +331,62 @@ describe('soroban XDR wrappers', () => { 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' }); @@ -272,6 +442,57 @@ describe('soroban transaction builders', () => { { 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', () => { @@ -423,6 +644,33 @@ describe('SorobanService RPC writes', () => { 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(); @@ -491,6 +739,74 @@ describe('SorobanService RPC writes', () => { }); 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', () => { @@ -542,3 +858,588 @@ describe('SorobanService platform stats', () => { }); }); }); + +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: new Contract(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), + ); + }); +}); From 6dd1ea4e3fd35670c4823447f3ba592306b2a2b0 Mon Sep 17 00:00:00 2001 From: Joneshaves Date: Mon, 29 Jun 2026 13:41:49 +0100 Subject: [PATCH 5/5] test: fix soroban event contract id type --- src/lib/soroban.service.test.ts | 1065 ++++++++++++++++++------------- 1 file changed, 630 insertions(+), 435 deletions(-) diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts index 5e84e5b..fd9208c 100644 --- a/src/lib/soroban.service.test.ts +++ b/src/lib/soroban.service.test.ts @@ -1,11 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +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(); +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, @@ -28,7 +28,7 @@ import { nativeToScVal, scValToNative, xdr, -} from '@stellar/stellar-sdk'; +} from "@stellar/stellar-sdk"; import { SorobanService, amountToStroops, @@ -48,11 +48,12 @@ import { stellarExpertTxUrl, unlockAssets, validateSimulationAuth, -} from './soroban'; +} from "./soroban"; -const POOL_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; +const POOL_CONTRACT_ID = + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; const USER_PUBLIC_KEY = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7)); -const POOL_ID = 'pool-xlm'; +const POOL_ID = "pool-xlm"; type MockRpcServer = { getAccount: ReturnType; @@ -65,9 +66,9 @@ type MockRpcServer = { function makePoolNative(overrides: Record = {}) { return { - id: 'pool-xlm', + id: "pool-xlm", contract_address: POOL_CONTRACT_ID, - asset_code: 'XLM', + asset_code: "XLM", is_native: true, daily_rate: BigInt(5_000_000), min_lock_period: BigInt(86_400), @@ -83,7 +84,7 @@ function makeContractEvent({ action, address = USER_PUBLIC_KEY, value, - ledgerClosedAt = '2026-06-29T12:00:00.000Z', + ledgerClosedAt = "2026-06-29T12:00:00.000Z", inSuccessfulContractCall = true, contractId = POOL_CONTRACT_ID, txHash = `${action}-tx`, @@ -107,11 +108,7 @@ function makeContractEvent({ } function invokeContractFromOperation(op: xdr.Operation) { - return op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + return op.body().invokeHostFunctionOp().hostFunction().invokeContract(); } function makeAuthEntry(functionName: string, contractId = POOL_CONTRACT_ID) { @@ -124,18 +121,23 @@ function makeAuthEntry(functionName: string, contractId = POOL_CONTRACT_ID) { return new xdr.SorobanAuthorizationEntry({ credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), rootInvocation: new xdr.SorobanAuthorizedInvocation({ - function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(contractFn), + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + contractFn, + ), subInvocations: [], }), }); } -function makeMockRpcServer(overrides: Partial = {}): MockRpcServer { +function makeMockRpcServer( + overrides: Partial = {}, +): MockRpcServer { return { - getAccount: vi.fn().mockResolvedValue(new Account(USER_PUBLIC_KEY, '0')), + getAccount: vi.fn().mockResolvedValue(new Account(USER_PUBLIC_KEY, "0")), simulateTransaction: vi.fn(), sendTransaction: vi.fn(), - getTransaction: vi.fn().mockResolvedValue({ status: 'SUCCESS' }), + getTransaction: vi.fn().mockResolvedValue({ status: "SUCCESS" }), getLatestLedger: vi.fn().mockResolvedValue({ sequence: 200_000 }), getEvents: vi.fn().mockResolvedValue({ events: [] }), ...overrides, @@ -167,9 +169,11 @@ function makeService({ } function mockAssembleTransactionPassthrough() { - assembleTransactionMock.mockImplementation((transaction: Transaction | FeeBumpTransaction) => ({ - build: () => transaction, - })); + assembleTransactionMock.mockImplementation( + (transaction: Transaction | FeeBumpTransaction) => ({ + build: () => transaction, + }), + ); return assembleTransactionMock; } @@ -179,30 +183,34 @@ afterEach(() => { 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'); +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', () => { + 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')); + 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'); + 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'); + 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', + const issued = formatAssetAmount("1234.5", { + code: "USDC", issuer: USER_PUBLIC_KEY, isNative: false, }); @@ -211,31 +219,39 @@ describe('soroban formatters', () => { }); }); -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); +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.', + 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('abc')).toThrow('Enter a valid positive decimal amount.'); - expect(() => amountToStroops('1.234', 2)).toThrow( - 'Amount supports at most 2 decimal places.', + expect(() => amountToStroops("1.234", 2)).toThrow( + "Amount supports at most 2 decimal places.", + ); + expect(() => amountToStroops("0")).toThrow( + "Amount must be greater than 0.", ); - 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({ + 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' }, + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + balance: "50.0000000", + }, + { asset_type: "native", balance: "123.4567890" }, ], }), } as Response); @@ -247,124 +263,150 @@ describe('soroban amount and Horizon helpers', () => { ); }); - it('getStellarBalance throws for Horizon non-OK and missing native balances', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + 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).', + "Unable to fetch Stellar balance from Horizon (404).", ); fetchSpy.mockResolvedValueOnce({ ok: true, - json: async () => ({ balances: [{ asset_type: 'credit_alphanum4', balance: '1' }] }), + 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.', + "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(); + 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', () => { +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', + id: "pool-xlm", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'XLM', isNative: true }, - dailyRate: '0.5000000', + asset: { code: "XLM", isNative: true }, + dailyRate: "0.5000000", minLockPeriod: 86_400, - totalLocked: '10.0000000', + totalLocked: "10.0000000", totalUsers: 3, isActive: true, createdAt: 1_700_000_000, }); }); - it('returns an empty array for an empty ScVec', () => { + 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); + 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(parsePoolsFromXdrResult(nativeToScVal({ id: "not-a-vec" }))).toEqual( + [], + ); expect(warnSpy).toHaveBeenCalledWith( - '[SmartDrop] parsePoolsFromXdr: expected Vec (array), got', - 'object', + "[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); + 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' }), + 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(pools.map((pool) => pool.id)).toEqual(["valid-1", "valid-2"]); expect(warnSpy).toHaveBeenCalledWith( - '[SmartDrop] parsePoolsFromXdr: skipping malformed pool at index 1:', + "[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' }); + it("parses i128 credit stroops into display units", () => { + const credits = nativeToScVal(BigInt(25_000_000), { type: "i128" }); - expect(parseCreditsFromXdrResult(credits)).toBe('2.5000000'); + 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); + 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), + 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', + "[SmartDrop] parseUserPositionFromXdr: expected Map (object), got", + "string", ); }); - it('returns zero credits when credit XDR parsing fails', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + 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(parseCreditsFromXdrResult(undefined as unknown as xdr.ScVal)).toBe( + "0", + ); expect(warnSpy).toHaveBeenCalledWith( - '[SmartDrop] parseCreditsFromXdr: failed to parse:', + "[SmartDrop] parseCreditsFromXdr: failed to parse:", expect.any(Error), ); }); }); -describe('soroban transaction builders', () => { - it('builds fee-bump transactions from transaction objects and XDR strings', () => { +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 account = new Account(USER_PUBLIC_KEY, "0"); const innerTransaction = new TransactionBuilder(account, { - fee: '321', + fee: "321", networkPassphrase: Networks.TESTNET, }) - .addOperation(new Contract(POOL_CONTRACT_ID).call('noop')) + .addOperation(new Contract(POOL_CONTRACT_ID).call("noop")) .setTimeout(30) .build(); @@ -380,29 +422,36 @@ describe('soroban transaction builders', () => { ); expect(objectFeeBump.feeSource).toBe(sponsorPublicKey); - expect(objectFeeBump.fee).toBe('642'); + 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 () => { + 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(), + 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', + status: "FAILED", + error: "Simulation failed: stop before signing", }); expect(callSpy).toHaveBeenCalledWith( - 'lock_assets', + "lock_assets", expect.any(xdr.ScVal), expect.any(xdr.ScVal), ); @@ -411,83 +460,92 @@ describe('soroban transaction builders', () => { const invokeContract = invokeContractFromOperation(op); const [addressArg, amountArg] = invokeContract.args(); - expect(invokeContract.functionName().toString()).toBe('lock_assets'); + expect(invokeContract.functionName().toString()).toBe("lock_assets"); expect(addressArg.switch()).toBe(xdr.ScValType.scvAddress()); - expect(addressArg.address().switch()).toBe(xdr.ScAddressType.scAddressTypeAccount()); + 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)); + expect(scValToNative(amountArg)).toBe( + BigInt(123_456_789) * BigInt(10_000_000), + ); }); - it('converts unlock display units to stroops before delegating', async () => { + 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' }); + .spyOn(sorobanService, "unlockAssets") + .mockResolvedValue({ success: true, transactionHash: "abc123" }); await expect( unlockAssets({ - poolContractId: 'pool-xlm', + poolContractId: "pool-xlm", publicKey: USER_PUBLIC_KEY, - amount: '1.2345678', + amount: "1.2345678", walletApi, }), - ).resolves.toEqual({ success: true, transactionHash: 'abc123' }); + ).resolves.toEqual({ success: true, transactionHash: "abc123" }); expect(unlockSpy).toHaveBeenCalledWith( - 'pool-xlm', + "pool-xlm", USER_PUBLIC_KEY, - '12345678', + "12345678", walletApi, { onHash: undefined, onStep: undefined }, ); }); - it('delegates lockAssets wrapper callbacks to the singleton service', async () => { + 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' }); + .spyOn(sorobanService, "lockAssets") + .mockResolvedValue({ + success: true, + transactionHash: "lock-wrapper-hash", + }); await expect( lockAssetsWrapper({ - poolContractId: 'pool-xlm', + poolContractId: "pool-xlm", publicKey: USER_PUBLIC_KEY, - amount: '1.5000000', + amount: "1.5000000", walletApi, onHash, onStep, }), - ).resolves.toEqual({ success: true, transactionHash: 'lock-wrapper-hash' }); + ).resolves.toEqual({ success: true, transactionHash: "lock-wrapper-hash" }); expect(lockSpy).toHaveBeenCalledWith( - 'pool-xlm', + "pool-xlm", USER_PUBLIC_KEY, - '1.5000000', + "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.', +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' }, + { contractId: POOL_CONTRACT_ID, functionName: "lock_assets" }, ]), ).toThrow( - 'Transaction signing was blocked because the simulation returned 0 authorization entries, but SmartDrop expected 1.', + "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' }], + { 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.`, @@ -495,48 +553,54 @@ describe('soroban authorization validation', () => { }); }); -describe('SorobanService RPC reads', () => { - it('getFactoryPools returns parsed pools from a simulated factory call', async () => { +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' })]) }, + result: { + retval: nativeToScVal([makePoolNative({ id: "factory-pool" })]), + }, }); const pools = await service.getFactoryPools(); expect(pools).toHaveLength(1); expect(pools[0]).toMatchObject({ - id: 'factory-pool', + id: "factory-pool", contractAddress: POOL_CONTRACT_ID, - totalLocked: '10.0000000', + totalLocked: "10.0000000", }); expect(rpcServer.getAccount).toHaveBeenCalledWith( - 'GBQ3WPTHKJ5XKWLOKUZJLZL2GVXR6RWQCXUVDQZWM7Q2YNLDRVGM5ZWJ', + "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); + 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', + "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); + 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' }); + 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 () => { + it("getUserPosition returns a parsed user position from a simulated pool call", async () => { const { service, rpcServer } = makeService(); rpcServer.simulateTransaction.mockResolvedValue({ result: { @@ -556,87 +620,115 @@ describe('SorobanService RPC reads', () => { expect(position).toMatchObject({ user: USER_PUBLIC_KEY, poolId: POOL_ID, - amount: '7.5000000', + amount: "7.5000000", lockedAt: 1_700_000_000, - credits: '1.5000000', + 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); + 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(); + await expect( + service.getUserPosition("missing-pool", USER_PUBLIC_KEY), + ).resolves.toBeNull(); - expect(warnSpy).toHaveBeenCalledWith('Pool contract not found for ID: missing-pool'); + 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); + 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' }); + rpcServer.simulateTransaction.mockResolvedValue({ + error: "position failed", + }); - await expect(service.getUserPosition(POOL_ID, USER_PUBLIC_KEY)).resolves.toBeNull(); + await expect( + service.getUserPosition(POOL_ID, USER_PUBLIC_KEY), + ).resolves.toBeNull(); }); - it('calculateUserCredits returns parsed credit display units', async () => { + it("calculateUserCredits returns parsed credit display units", async () => { const { service, rpcServer } = makeService(); rpcServer.simulateTransaction.mockResolvedValue({ - result: { retval: nativeToScVal(BigInt(12_345_678), { type: 'i128' }) }, + result: { retval: nativeToScVal(BigInt(12_345_678), { type: "i128" }) }, }); - await expect(service.calculateUserCredits(POOL_ID, USER_PUBLIC_KEY)).resolves.toBe( - '1.2345678', - ); + 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); + 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'); + await expect( + service.calculateUserCredits("missing-pool", USER_PUBLIC_KEY), + ).resolves.toBe("0"); - expect(warnSpy).toHaveBeenCalledWith('Pool contract not found for ID: missing-pool'); + 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); + 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' }); + rpcServer.simulateTransaction.mockResolvedValue({ error: "credit failed" }); - await expect(service.calculateUserCredits(POOL_ID, USER_PUBLIC_KEY)).resolves.toBe('0'); + 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 () => { +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', + result: { auth: [makeAuthEntry("lock_assets")] }, + minResourceFee: "321", }); - rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'lock-hash' }); - const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + 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); + 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', + transactionHash: "lock-hash", + hash: "lock-hash", + status: "SUCCESS", resultXdr: undefined, - gasUsed: '321', + gasUsed: "321", }); expect(assembleSpy).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ minResourceFee: '321' }), + expect.objectContaining({ minResourceFee: "321" }), ); expect(walletApi.signTransaction).toHaveBeenCalledWith(expect.any(String), { networkPassphrase: expect.any(String), @@ -644,193 +736,247 @@ describe('SorobanService RPC writes', () => { expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); }); - it('lockAssets returns decoded contract error details when confirmation fails', async () => { + 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', + result: { auth: [makeAuthEntry("lock_assets")] }, + minResourceFee: "321", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "PENDING", + hash: "failed-lock-hash", }); - rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'failed-lock-hash' }); rpcServer.getTransaction.mockResolvedValue({ - status: 'FAILED', - errorResult: 'Host function failed with contract code: 1', + status: "FAILED", + errorResult: "Host function failed with contract code: 1", }); - const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; - const result = await service.lockAssets(POOL_ID, USER_PUBLIC_KEY, '50000000', walletApi); + 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', + transactionHash: "failed-lock-hash", + hash: "failed-lock-hash", + status: "FAILED", + errorCode: "1", + error: "Assets are still locked", }); - expect(rpcServer.getTransaction).toHaveBeenCalledWith('failed-lock-hash'); + expect(rpcServer.getTransaction).toHaveBeenCalledWith("failed-lock-hash"); }); - it('unlockAssets signs and submits an assembled transaction on success', async () => { + 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', + result: { auth: [makeAuthEntry("unlock_assets")] }, + minResourceFee: "654", }); - rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'unlock-hash' }); - const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + 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); + 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', + transactionHash: "unlock-hash", + hash: "unlock-hash", + status: "SUCCESS", resultXdr: undefined, - gasUsed: '654', + gasUsed: "654", }); expect(walletApi.signTransaction).toHaveBeenCalledTimes(1); expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1); }); - it('unlockAssets throws when the pool is not registered', async () => { + 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', { + service.unlockAssets("missing-pool", USER_PUBLIC_KEY, "10000000", { signTransaction: vi.fn(), }), - ).rejects.toThrow('Pool contract not found for ID: missing-pool'); + ).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 () => { + 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', + result: { auth: [makeAuthEntry("set_boost")] }, + minResourceFee: "777", + }); + rpcServer.sendTransaction.mockResolvedValue({ + status: "PENDING", + hash: "boost-hash", }); - rpcServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'boost-hash' }); - const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; - const result = await service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, walletApi); + 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', + 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 () => { + 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({ + 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', + 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 () => { + it("setBoost returns a failure result when simulation reports an error", async () => { const { service, rpcServer } = makeService(); - rpcServer.simulateTransaction.mockResolvedValue({ error: 'boost simulation failed' }); + rpcServer.simulateTransaction.mockResolvedValue({ + error: "boost simulation failed", + }); await expect( - service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, { signTransaction: vi.fn() }), + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, { + signTransaction: vi.fn(), + }), ).resolves.toEqual({ success: false, - error: 'Simulation failed: boost simulation failed', + 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); + 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', + 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')), + signTransaction: vi + .fn() + .mockRejectedValue(new Error("wallet rejected")), }), ).resolves.toEqual({ success: false, - error: 'wallet rejected', + error: "wallet rejected", }); - expect(errorSpy).toHaveBeenCalledWith('Error setting boost:', expect.any(Error)); + 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 () => { + 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', + result: { auth: [makeAuthEntry("set_boost")] }, + minResourceFee: "777", }); rpcServer.sendTransaction.mockResolvedValue({ - status: 'ERROR', - errorResult: 'tx rejected', + status: "ERROR", + errorResult: "tx rejected", }); - const walletApi = { signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope) }; + const walletApi = { + signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope), + }; - await expect(service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, walletApi)).resolves.toEqual({ + await expect( + service.setBoost(POOL_ID, USER_PUBLIC_KEY, 40, walletApi), + ).resolves.toEqual({ success: false, - error: 'Transaction failed: tx rejected', + 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); + 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')] }, + 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)); + 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 () => { +describe("SorobanService platform stats", () => { + it("getPlatformStats aggregates pool totals from getFactoryPools", async () => { const { service } = makeService({ pool: false }); - vi.spyOn(service, 'getFactoryPools').mockResolvedValue([ + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ { - id: 'pool-1', + id: "pool-1", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'XLM', isNative: true }, - dailyRate: '0.5000000', + asset: { code: "XLM", isNative: true }, + dailyRate: "0.5000000", minLockPeriod: 86_400, - totalLocked: '1000', + totalLocked: "1000", totalUsers: 25, isActive: true, createdAt: 1, }, { - id: 'pool-2', + id: "pool-2", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'USDC', issuer: USER_PUBLIC_KEY, isNative: false }, - dailyRate: '0.2500000', + asset: { code: "USDC", issuer: USER_PUBLIC_KEY, isNative: false }, + dailyRate: "0.2500000", minLockPeriod: 43_200, - totalLocked: '2500', + totalLocked: "2500", totalUsers: 15, isActive: true, createdAt: 2, @@ -838,20 +984,22 @@ describe('SorobanService platform stats', () => { ]); await expect(service.getPlatformStats()).resolves.toEqual({ - totalValueLocked: '$3,500', + totalValueLocked: "$3,500", totalUsers: 40, onlineUsers: 4, totalPools: 2, }); }); - it('getPlatformStats returns zero stats when getFactoryPools throws', async () => { - vi.spyOn(console, 'error').mockImplementation(() => undefined); + 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')); + vi.spyOn(service, "getFactoryPools").mockRejectedValue( + new Error("stats failed"), + ); await expect(service.getPlatformStats()).resolves.toEqual({ - totalValueLocked: '$0', + totalValueLocked: "$0", totalUsers: 0, onlineUsers: 0, totalPools: 0, @@ -859,37 +1007,37 @@ describe('SorobanService platform stats', () => { }); }); -describe('SorobanService event-derived pool data', () => { - it('getPoolHistory derives daily TVL from successful lock and unlock events', async () => { +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')); + 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', + action: "lock_assets", value: [50_000_000n], - ledgerClosedAt: '2026-06-28T10:00:00.000Z', + ledgerClosedAt: "2026-06-28T10:00:00.000Z", }), makeContractEvent({ - action: 'unlock_assets', + action: "unlock_assets", value: { amount: 20_000_000n }, - ledgerClosedAt: '2026-06-29T10:00:00.000Z', + ledgerClosedAt: "2026-06-29T10:00:00.000Z", }), makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", value: { amount: 99_000_000n }, - ledgerClosedAt: '2026-06-29T11:00:00.000Z', + 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' }, + { date: "2026-06-27", tvl: "0" }, + { date: "2026-06-28", tvl: "5" }, + { date: "2026-06-29", tvl: "3" }, ]); expect(rpcServer.getEvents).toHaveBeenCalledWith( @@ -897,7 +1045,7 @@ describe('SorobanService event-derived pool data', () => { startLedger: 148_160, filters: [ expect.objectContaining({ - type: 'contract', + type: "contract", contractIds: [POOL_CONTRACT_ID], }), ], @@ -906,47 +1054,51 @@ describe('SorobanService event-derived pool data', () => { ); }); - it('getPoolHistory returns an empty list when event RPC throws', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + 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')); + rpcServer.getLatestLedger.mockRejectedValue( + new Error("ledger unavailable"), + ); await expect(service.getPoolHistory(POOL_CONTRACT_ID)).resolves.toEqual([]); expect(warnSpy).toHaveBeenCalledWith( - '[SmartDrop] getPoolHistory failed:', + "[SmartDrop] getPoolHistory failed:", expect.any(Error), ); }); - it('getPoolDepositors aggregates successful lock and unlock events by address', async () => { + 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', + action: "lock_assets", address: USER_PUBLIC_KEY, value: { amount: 100_000_000n }, }), makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", address: otherUser, value: [30_000_000n], }), makeContractEvent({ - action: 'unlock_assets', + action: "unlock_assets", address: USER_PUBLIC_KEY, value: { amount: 40_000_000n }, }), makeContractEvent({ - action: 'lock_assets', - address: '', + action: "lock_assets", + address: "", value: { amount: 999_000_000n }, }), makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", address: otherUser, value: { amount: 999_000_000n }, inSuccessfulContractCall: false, @@ -954,16 +1106,18 @@ describe('SorobanService event-derived pool data', () => { ], }); - await expect(service.getPoolDepositors(POOL_CONTRACT_ID, 2)).resolves.toEqual([ - { address: USER_PUBLIC_KEY, amount: '6', credits: '—' }, - { address: otherUser, amount: '3', credits: '—' }, + 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', + type: "contract", contractIds: [POOL_CONTRACT_ID], }), ], @@ -972,26 +1126,30 @@ describe('SorobanService event-derived pool data', () => { ); }); - it('getPoolDepositors returns an empty list when event RPC throws', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + 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')); + rpcServer.getEvents.mockRejectedValue(new Error("events unavailable")); - await expect(service.getPoolDepositors(POOL_CONTRACT_ID)).resolves.toEqual([]); + await expect(service.getPoolDepositors(POOL_CONTRACT_ID)).resolves.toEqual( + [], + ); expect(warnSpy).toHaveBeenCalledWith( - '[SmartDrop] getPoolDepositors failed:', + "[SmartDrop] getPoolDepositors failed:", expect.any(Error), ); }); }); -describe('SorobanService leaderboard', () => { +describe("SorobanService leaderboard", () => { type LeaderboardInternals = { fetchLeaderboardFromEvents: ( offset: number, limit: number, - sortKey: 'credits' | 'stake', + sortKey: "credits" | "stake", ) => Promise; getLeaderboardPoolIds: () => Promise; extractEventAmount: (valueNative: unknown) => number; @@ -1002,30 +1160,30 @@ describe('SorobanService leaderboard', () => { return service as unknown as LeaderboardInternals; } - it('getLeaderboardPoolIds combines env and factory pool contract IDs without duplicates', async () => { + 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([ + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ { - id: 'pool-1', + id: "pool-1", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'XLM', isNative: true }, - dailyRate: '0', + asset: { code: "XLM", isNative: true }, + dailyRate: "0", minLockPeriod: 0, - totalLocked: '0', + totalLocked: "0", totalUsers: 0, isActive: true, createdAt: 1, }, { - id: 'pool-2', + id: "pool-2", contractAddress: secondPool, - asset: { code: 'USDC', issuer: USER_PUBLIC_KEY, isNative: false }, - dailyRate: '0', + asset: { code: "USDC", issuer: USER_PUBLIC_KEY, isNative: false }, + dailyRate: "0", minLockPeriod: 0, - totalLocked: '0', + totalLocked: "0", totalUsers: 0, isActive: true, createdAt: 2, @@ -1033,19 +1191,21 @@ describe('SorobanService leaderboard', () => { ]); try { - await expect(internals(service).getLeaderboardPoolIds()).resolves.toEqual([ - POOL_CONTRACT_ID, - secondPool, - ]); + await expect(internals(service).getLeaderboardPoolIds()).resolves.toEqual( + [POOL_CONTRACT_ID, secondPool], + ); } finally { - if (previousPool === undefined) delete process.env.NEXT_PUBLIC_POOL_CONTRACT_ID; + 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', () => { + it("extractEventAmount handles bigint, number, array, object, and null values", () => { const { service } = makeService({ pool: false }); - const helper = internals(service).extractEventAmount.bind(internals(service)); + const helper = internals(service).extractEventAmount.bind( + internals(service), + ); expect(helper(12n)).toBe(12); expect(helper(34)).toBe(34); @@ -1056,17 +1216,17 @@ describe('SorobanService leaderboard', () => { expect(helper(null)).toBe(0); }); - it('getLeaderboard derives rows from events and sorts by credits or stake', async () => { + 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([ + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ { - id: 'factory-pool', + id: "factory-pool", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'XLM', isNative: true }, - dailyRate: '0', + asset: { code: "XLM", isNative: true }, + dailyRate: "0", minLockPeriod: 0, - totalLocked: '0', + totalLocked: "0", totalUsers: 0, isActive: true, createdAt: 1, @@ -1076,37 +1236,37 @@ describe('SorobanService leaderboard', () => { rpcServer.getEvents.mockResolvedValue({ events: [ makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", address: USER_PUBLIC_KEY, value: { amount: 300_000_000n }, }), makeContractEvent({ - action: 'update_credits', + action: "update_credits", address: USER_PUBLIC_KEY, value: { credits: 120 }, }), makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", address: otherUser, value: [800_000_000n], }), makeContractEvent({ - action: 'unlock_assets', + action: "unlock_assets", address: otherUser, value: 100_000_000, }), makeContractEvent({ - action: 'update_credits', + action: "update_credits", address: otherUser, value: 50, }), makeContractEvent({ - action: 'lock_assets', - address: '', + action: "lock_assets", + address: "", value: { amount: 999_000_000n }, }), makeContractEvent({ - action: 'update_credits', + action: "update_credits", address: otherUser, value: 999, inSuccessfulContractCall: false, @@ -1114,7 +1274,7 @@ describe('SorobanService leaderboard', () => { ], }); - await expect(service.getLeaderboard(0, 10, 'credits')).resolves.toEqual({ + await expect(service.getLeaderboard(0, 10, "credits")).resolves.toEqual({ entries: [ { address: USER_PUBLIC_KEY, @@ -1132,7 +1292,7 @@ describe('SorobanService leaderboard', () => { total: 2, }); - await expect(service.getLeaderboard(0, 1, 'stake')).resolves.toEqual({ + await expect(service.getLeaderboard(0, 1, "stake")).resolves.toEqual({ entries: [ { address: otherUser, @@ -1145,61 +1305,70 @@ describe('SorobanService leaderboard', () => { }); }); - it('fetchLeaderboardFromEvents returns an empty page without pool IDs and on RPC errors', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + 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([]); + vi.spyOn(service, "getFactoryPools").mockResolvedValueOnce([]); - await expect(internals(service).fetchLeaderboardFromEvents(0, 10, 'credits')).resolves.toEqual({ + await expect( + internals(service).fetchLeaderboardFromEvents(0, 10, "credits"), + ).resolves.toEqual({ entries: [], total: 0, }); expect(rpcServer.getEvents).not.toHaveBeenCalled(); - vi.spyOn(service, 'getFactoryPools').mockResolvedValueOnce([ + vi.spyOn(service, "getFactoryPools").mockResolvedValueOnce([ { - id: 'factory-pool', + id: "factory-pool", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'XLM', isNative: true }, - dailyRate: '0', + asset: { code: "XLM", isNative: true }, + dailyRate: "0", minLockPeriod: 0, - totalLocked: '0', + totalLocked: "0", totalUsers: 0, isActive: true, createdAt: 1, }, ]); - rpcServer.getLatestLedger.mockRejectedValueOnce(new Error('ledger unavailable')); + rpcServer.getLatestLedger.mockRejectedValueOnce( + new Error("ledger unavailable"), + ); - await expect(internals(service).fetchLeaderboardFromEvents(0, 10, 'credits')).resolves.toEqual({ + await expect( + internals(service).fetchLeaderboardFromEvents(0, 10, "credits"), + ).resolves.toEqual({ entries: [], total: 0, }); expect(warnSpy).toHaveBeenCalledWith( - '[SmartDrop] event-derived leaderboard failed:', + "[SmartDrop] event-derived leaderboard failed:", expect.any(Error), ); }); - it('getLeaderboard uses the configured API and normalizes sparse API rows', async () => { + 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'; + process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = + "https://leaderboard.example/rankings"; vi.resetModules(); - const { SorobanService: ApiSorobanService } = await import('./soroban'); + const { SorobanService: ApiSorobanService } = await import("./soroban"); const service = new ApiSorobanService(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ entries: [ - { address: USER_PUBLIC_KEY, totalCredits: '42', totalStake: '7' }, - { boostUtilization: '15' }, + { address: USER_PUBLIC_KEY, totalCredits: "42", totalStake: "7" }, + { boostUtilization: "15" }, ], total: 9, }), } as Response); try { - await expect(service.getLeaderboard(5, 2, 'stake')).resolves.toEqual({ + await expect(service.getLeaderboard(5, 2, "stake")).resolves.toEqual({ entries: [ { address: USER_PUBLIC_KEY, @@ -1208,7 +1377,7 @@ describe('SorobanService leaderboard', () => { boostUtilization: 0, }, { - address: '', + address: "", totalCredits: 0, totalStake: 0, boostUtilization: 15, @@ -1217,29 +1386,31 @@ describe('SorobanService leaderboard', () => { total: 9, }); } finally { - if (previousApi === undefined) delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + 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' } }, + "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 () => { + 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'; + process.env.NEXT_PUBLIC_LEADERBOARD_API_URL = + "https://leaderboard.example/rankings"; vi.resetModules(); - const { SorobanService: ApiSorobanService } = await import('./soroban'); + 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', + action: "lock_assets", address: USER_PUBLIC_KEY, value: { amount: 250_000_000n }, }), @@ -1247,27 +1418,27 @@ describe('SorobanService leaderboard', () => { }), }); internals(service as SorobanService).rpcServer = rpcServer; - vi.spyOn(service, 'getFactoryPools').mockResolvedValue([ + vi.spyOn(service, "getFactoryPools").mockResolvedValue([ { - id: 'factory-pool', + id: "factory-pool", contractAddress: POOL_CONTRACT_ID, - asset: { code: 'XLM', isNative: true }, - dailyRate: '0', + asset: { code: "XLM", isNative: true }, + dailyRate: "0", minLockPeriod: 0, - totalLocked: '0', + totalLocked: "0", totalUsers: 0, isActive: true, createdAt: 1, }, ]); - vi.spyOn(console, 'warn').mockImplementation(() => undefined); - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + 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({ + await expect(service.getLeaderboard(0, 10, "credits")).resolves.toEqual({ entries: [ { address: USER_PUBLIC_KEY, @@ -1279,23 +1450,24 @@ describe('SorobanService leaderboard', () => { total: 1, }); } finally { - if (previousApi === undefined) delete process.env.NEXT_PUBLIC_LEADERBOARD_API_URL; + 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 () => { + it("getCreditVelocity currently returns the zero accumulator", async () => { const { service } = makeService({ pool: false }); - await expect(service.getCreditVelocity(12)).resolves.toBe('0'); + 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', +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, @@ -1307,106 +1479,117 @@ describe('soroban exported utilities and transaction history', () => { }); }); - it('getUserTransactionHistory parses, filters, and sorts lock and unlock events', async () => { + 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', + action: "lock_assets", address: USER_PUBLIC_KEY, - value: [100_000_000n, 'XLM'], - ledgerClosedAt: '2026-06-28T10:00:00.000Z', + value: [100_000_000n, "XLM"], + ledgerClosedAt: "2026-06-28T10:00:00.000Z", contractId: POOL_CONTRACT_ID, - txHash: 'lock-tx', + txHash: "lock-tx", }), makeContractEvent({ - action: 'unlock_assets', + 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: new Contract(POOL_CONTRACT_ID), - txHash: 'unlock-tx', + 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', + action: "unlock_assets", address: USER_PUBLIC_KEY, - value: [10_000_000n, 'XLM', 1_500_000n], - ledgerClosedAt: '2026-06-27T10:00:00.000Z', + value: [10_000_000n, "XLM", 1_500_000n], + ledgerClosedAt: "2026-06-27T10:00:00.000Z", contractId: POOL_CONTRACT_ID, - txHash: 'unlock-array-tx', + txHash: "unlock-array-tx", }), makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", address: otherUser, - value: [999_000_000n, 'XLM'], - ledgerClosedAt: '2026-06-29T11:00:00.000Z', - txHash: 'other-user-tx', + value: [999_000_000n, "XLM"], + ledgerClosedAt: "2026-06-29T11:00:00.000Z", + txHash: "other-user-tx", }), makeContractEvent({ - action: 'update_credits', + action: "update_credits", address: USER_PUBLIC_KEY, value: { credits: 100 }, - ledgerClosedAt: '2026-06-29T12:00:00.000Z', - txHash: 'ignored-action-tx', + ledgerClosedAt: "2026-06-29T12:00:00.000Z", + txHash: "ignored-action-tx", }), makeContractEvent({ - action: 'lock_assets', + action: "lock_assets", address: USER_PUBLIC_KEY, - value: [999_000_000n, 'XLM'], - ledgerClosedAt: '2026-06-29T13:00:00.000Z', + value: [999_000_000n, "XLM"], + ledgerClosedAt: "2026-06-29T13:00:00.000Z", inSuccessfulContractCall: false, - txHash: 'failed-tx', + 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', + 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', + value: nativeToScVal([1_000_000n, "XLM"]), + ledgerClosedAt: "2026-06-29T15:00:00.000Z", contractId: POOL_CONTRACT_ID, - txHash: 'malformed-tx', + txHash: "malformed-tx", }, ], }), }; await expect( - getUserTransactionHistory(USER_PUBLIC_KEY, [POOL_CONTRACT_ID], rpcOverride), + getUserTransactionHistory( + USER_PUBLIC_KEY, + [POOL_CONTRACT_ID], + rpcOverride, + ), ).resolves.toEqual([ { - date: '2026-06-29T10:00:00.000Z', - action: 'unlock', - amount: '25000000', - symbol: 'XLM', + date: "2026-06-29T10:00:00.000Z", + action: "unlock", + amount: "25000000", + symbol: "XLM", poolId: POOL_CONTRACT_ID, - creditsEarned: '7500000', - txHash: 'unlock-tx', + creditsEarned: "7500000", + txHash: "unlock-tx", }, { - date: '2026-06-28T10:00:00.000Z', - action: 'lock', - amount: '100000000', - symbol: 'XLM', + date: "2026-06-28T10:00:00.000Z", + action: "lock", + amount: "100000000", + symbol: "XLM", poolId: POOL_CONTRACT_ID, creditsEarned: undefined, - txHash: 'lock-tx', + txHash: "lock-tx", }, { - date: '2026-06-27T10:00:00.000Z', - action: 'unlock', - amount: '10000000', - symbol: 'XLM', + date: "2026-06-27T10:00:00.000Z", + action: "unlock", + amount: "10000000", + symbol: "XLM", poolId: POOL_CONTRACT_ID, - creditsEarned: '1500000', - txHash: 'unlock-array-tx', + creditsEarned: "1500000", + txHash: "unlock-array-tx", }, ]); expect(rpcOverride.getEvents).toHaveBeenCalledWith( @@ -1414,7 +1597,7 @@ describe('soroban exported utilities and transaction history', () => { startLedger: 79_040, filters: [ expect.objectContaining({ - type: 'contract', + type: "contract", contractIds: [POOL_CONTRACT_ID], }), ], @@ -1423,22 +1606,34 @@ describe('soroban exported utilities and transaction history', () => { ); }); - it('getUserTransactionHistory handles empty guards and RPC failures', async () => { + it("getUserTransactionHistory handles empty guards and RPC failures", async () => { const rpcOverride = { - getLatestLedger: vi.fn().mockRejectedValue(new Error('ledger unavailable')), + getLatestLedger: vi + .fn() + .mockRejectedValue(new Error("ledger unavailable")), getEvents: vi.fn(), }; - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + 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([]); + 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), + getUserTransactionHistory( + USER_PUBLIC_KEY, + [POOL_CONTRACT_ID], + rpcOverride, + ), ).resolves.toEqual([]); expect(errorSpy).toHaveBeenCalledWith( - 'Error fetching transaction history:', + "Error fetching transaction history:", expect.any(Error), ); });