From 1bed062f1a7e38fb74f11c23295924003c13d619 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Fri, 28 Aug 2026 09:09:01 -0400 Subject: [PATCH] feat(export): add CSV export endpoint and utility for grant data (#161) --- .../hello-world/src/tests/benchmark_test.rs | 10 +- .../hello-world/src/tests/validation_test.rs | 2 +- frontend/package-lock.json | 1131 ++++++++++++++++- .../app/api/tasks/export/csv/route.test.ts | 75 ++ .../src/app/api/tasks/export/csv/route.ts | 58 + .../src/components/CsvExportButton.test.tsx | 22 + frontend/src/components/CsvExportButton.tsx | 106 ++ frontend/src/lib/csv-export.test.ts | 126 ++ frontend/src/lib/csv-export.ts | 122 ++ frontend/src/lib/form-validation.test.ts | 2 - frontend/src/lib/task-submission-e2e.test.ts | 3 + frontend/src/test/regression.test.ts | 23 +- 12 files changed, 1619 insertions(+), 61 deletions(-) create mode 100644 frontend/src/app/api/tasks/export/csv/route.test.ts create mode 100644 frontend/src/app/api/tasks/export/csv/route.ts create mode 100644 frontend/src/components/CsvExportButton.test.tsx create mode 100644 frontend/src/components/CsvExportButton.tsx create mode 100644 frontend/src/lib/csv-export.test.ts create mode 100644 frontend/src/lib/csv-export.ts diff --git a/contract/contracts/hello-world/src/tests/benchmark_test.rs b/contract/contracts/hello-world/src/tests/benchmark_test.rs index 9bbe115..9099fb6 100644 --- a/contract/contracts/hello-world/src/tests/benchmark_test.rs +++ b/contract/contracts/hello-world/src/tests/benchmark_test.rs @@ -19,8 +19,11 @@ /// 3. Identify unexpectedly expensive operations worth optimising #[cfg(test)] mod benchmark_tests { + extern crate std; + use std::println; + use crate::mock_token::{MockToken, MockTokenClient}; - use crate::tests::test_utils::{create_test_members, mint_tokens, setup_test_env}; + use crate::test_utils::{create_test_members, mint_tokens, setup_test_env}; use crate::{AutoShareContract, AutoShareContractClient}; use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Vec}; @@ -29,11 +32,10 @@ mod benchmark_tests { // ------------------------------------------------------------------------- fn print_resource_report(label: &str, env: &Env) { - let resources = env.budget().print(); println!("\n========================================"); println!(" BENCHMARK: {}", label); println!("========================================"); - println!("{}", resources); + env.budget().print(); println!("----------------------------------------\n"); } @@ -496,7 +498,7 @@ mod benchmark_tests { client.create(&id, &String::from_str(&b.env, "Reduce"), &creator, &5u32, &b.token); reset_budget(&b.env); - client.reduce_usage(&id); + client.reduce_usage(&id, &creator); print_resource_report("reduce_usage", &b.env); } diff --git a/contract/contracts/hello-world/src/tests/validation_test.rs b/contract/contracts/hello-world/src/tests/validation_test.rs index 569e7fd..5f6a394 100644 --- a/contract/contracts/hello-world/src/tests/validation_test.rs +++ b/contract/contracts/hello-world/src/tests/validation_test.rs @@ -6,7 +6,7 @@ mod validation_tests { use crate::base::types::GroupMember; use crate::mock_token::{MockToken, MockTokenClient}; - use crate::tests::test_utils::{mint_tokens, setup_test_env}; + use crate::test_utils::{mint_tokens, setup_test_env}; use crate::{AutoShareContract, AutoShareContractClient}; use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Vec}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3efb82a..c65a413 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,8 @@ "react-spinners": "^0.17.0", "recharts": "^3.5.1", "tailwind-merge": "^3.3.1", - "tailwind-scrollbar-hide": "^4.0.0" + "tailwind-scrollbar-hide": "^4.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -46,7 +47,8 @@ "eslint": "^9", "eslint-config-next": "16.1.3", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@albedo-link/intent": { @@ -1994,6 +1996,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@paunovic/random-words": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@paunovic/random-words/-/random-words-1.2.1.tgz", @@ -3745,6 +3757,268 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -6026,6 +6300,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -6098,6 +6383,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6775,38 +7067,151 @@ } } }, - "node_modules/@wallet-standard/base": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.0.tgz", - "integrity": "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16" + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@wallet-standard/features": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.0.tgz", - "integrity": "sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==", - "license": "Apache-2.0", + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@wallet-standard/base": "^1.1.0" + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": ">=16" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@walletconnect/core": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.11.2.tgz", - "integrity": "sha512-bB4SiXX8hX3/hyBfVPC5gwZCXCl+OPj+/EDVM71iAO3TDsh78KPbrVAbDnnsbHzZVHlsMohtXX3j5XVsheN3+g==", - "license": "Apache-2.0", + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", "dependencies": { - "@walletconnect/heartbeat": "1.2.1", - "@walletconnect/jsonrpc-provider": "1.0.13", - "@walletconnect/jsonrpc-types": "1.0.3", - "@walletconnect/jsonrpc-utils": "1.0.8", - "@walletconnect/jsonrpc-ws-connection": "1.0.14", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.0.tgz", + "integrity": "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.0.tgz", + "integrity": "sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@walletconnect/core": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.11.2.tgz", + "integrity": "sha512-bB4SiXX8hX3/hyBfVPC5gwZCXCl+OPj+/EDVM71iAO3TDsh78KPbrVAbDnnsbHzZVHlsMohtXX3j5XVsheN3+g==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.1", + "@walletconnect/jsonrpc-provider": "1.0.13", + "@walletconnect/jsonrpc-types": "1.0.3", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.14", "@walletconnect/keyvaluestorage": "^1.1.1", "@walletconnect/logger": "^2.0.1", "@walletconnect/relay-api": "^1.0.9", @@ -7571,6 +7976,16 @@ "license": "MIT", "peer": true }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -8267,6 +8682,16 @@ "node": ">=20" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -9185,6 +9610,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9705,6 +10137,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -9750,6 +10192,16 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -10030,6 +10482,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -12024,9 +12491,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -12406,6 +12873,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ofetch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", @@ -12571,6 +13052,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -12671,9 +13159,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -12691,7 +13179,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13474,6 +13962,40 @@ "node": ">= 16" } }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, "node_modules/rpc-websockets": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.3.tgz", @@ -13911,6 +14433,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -14002,6 +14531,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -14022,6 +14558,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -14413,15 +14956,32 @@ "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -14449,9 +15009,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14461,6 +15021,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -15191,6 +15761,461 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -15317,6 +16342,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wif": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz", @@ -15522,10 +16564,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/src/app/api/tasks/export/csv/route.test.ts b/frontend/src/app/api/tasks/export/csv/route.test.ts new file mode 100644 index 0000000..ee93f52 --- /dev/null +++ b/frontend/src/app/api/tasks/export/csv/route.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { GET as exportCsvRoute } from "./route"; +import { POST as createTaskRoute } from "../../route"; +import { resetTaskWorkflowStore } from "@/lib/task-workflow"; + +async function seedTask(title: string, overrides: Record = {}) { + return createTaskRoute( + new Request("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + poster: "GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + title, + description: `Description for ${title}`, + reward: 5_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 3, + difficulty: "intermediate", + technologies: ["Soroban", "Rust"], + organization: "GrantFox", + ...overrides, + }), + }), + ); +} + +describe("GET /api/tasks/export/csv", () => { + beforeEach(() => { + resetTaskWorkflowStore(); + }); + + afterEach(() => { + resetTaskWorkflowStore(); + }); + + it("returns 200 with text/csv content type and attachment headers", async () => { + await seedTask("Grant Alpha"); + await seedTask("Grant Beta"); + + const request = new Request("http://localhost/api/tasks/export/csv"); + const response = await exportCsvRoute(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/csv"); + expect(response.headers.get("Content-Disposition")).toContain("attachment; filename="); + + const text = await response.text(); + expect(text).toContain("Grant ID,Title,Description"); + expect(text).toContain("Grant Alpha"); + expect(text).toContain("Grant Beta"); + }); + + it("filters exported CSV records by difficulty or search parameter", async () => { + await seedTask("Beginner Bounty", { difficulty: "beginner" }); + await seedTask("Advanced Bounty", { difficulty: "advanced" }); + + const request = new Request("http://localhost/api/tasks/export/csv?difficulty=beginner"); + const response = await exportCsvRoute(request); + const text = await response.text(); + + expect(text).toContain("Beginner Bounty"); + expect(text).not.toContain("Advanced Bounty"); + }); + + it("returns empty CSV table with headers when no records match filter", async () => { + const request = new Request("http://localhost/api/tasks/export/csv?search=nonexistent"); + const response = await exportCsvRoute(request); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).toContain("Grant ID,Title,Description"); + const lines = text.trim().split("\r\n"); + expect(lines.length).toBe(1); + }); +}); diff --git a/frontend/src/app/api/tasks/export/csv/route.ts b/frontend/src/app/api/tasks/export/csv/route.ts new file mode 100644 index 0000000..7c3c564 --- /dev/null +++ b/frontend/src/app/api/tasks/export/csv/route.ts @@ -0,0 +1,58 @@ +import { listTasks } from "@/lib/task-workflow"; +import { generateGrantsCsv } from "@/lib/csv-export"; +import { checkRateLimit } from "@/lib/rate-limit"; +import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_DIFFICULTIES: TaskDifficulty[] = ["beginner", "intermediate", "advanced"]; +const VALID_SORTS: TaskSortOrder[] = ["newest", "reward_desc", "deadline_asc"]; + +function parseOptionalNumber(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export async function GET(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { searchParams } = new URL(request.url); + const difficultyParam = searchParams.get("difficulty"); + const sortParam = searchParams.get("sort"); + + const result = listTasks({ + search: searchParams.get("search") ?? undefined, + minReward: parseOptionalNumber(searchParams.get("minReward")), + maxReward: parseOptionalNumber(searchParams.get("maxReward")), + difficulty: VALID_DIFFICULTIES.includes(difficultyParam as TaskDifficulty) + ? (difficultyParam as TaskDifficulty) + : undefined, + technology: searchParams.get("technology") ?? undefined, + organization: searchParams.get("organization") ?? undefined, + sort: VALID_SORTS.includes(sortParam as TaskSortOrder) + ? (sortParam as TaskSortOrder) + : undefined, + page: 1, + pageSize: 50, // Max page size supported by listTasks + }); + + const csvContent = generateGrantsCsv(result.tasks); + const filename = `grant-records-${new Date().toISOString().slice(0, 10)}.csv`; + + return new Response(csvContent, { + status: 200, + headers: { + ...rateLimitHeaders, + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/frontend/src/components/CsvExportButton.test.tsx b/frontend/src/components/CsvExportButton.test.tsx new file mode 100644 index 0000000..404eddf --- /dev/null +++ b/frontend/src/components/CsvExportButton.test.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { CsvExportButton } from "./CsvExportButton"; + +describe("CsvExportButton component", () => { + it("renders accessible button with default label and aria attributes", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Export CSV"); + expect(html).toContain('aria-label="Export CSV"'); + expect(html).toContain('type="button"'); + }); + + it("renders custom label and custom className", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Download Grant Data"); + expect(html).toContain('aria-label="Download Grant Data"'); + expect(html).toContain("custom-export-class"); + }); +}); diff --git a/frontend/src/components/CsvExportButton.tsx b/frontend/src/components/CsvExportButton.tsx new file mode 100644 index 0000000..f536b5b --- /dev/null +++ b/frontend/src/components/CsvExportButton.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React, { useState } from "react"; +import { Download, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export interface CsvExportButtonProps { + apiEndpoint?: string; + queryFilters?: Record; + className?: string; + label?: string; + fileName?: string; + onExportSuccess?: () => void; + onExportError?: (error: Error) => void; +} + +export function CsvExportButton({ + apiEndpoint = "/api/tasks/export/csv", + queryFilters = {}, + className = "", + label = "Export CSV", + fileName, + onExportSuccess, + onExportError, +}: CsvExportButtonProps) { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = async () => { + try { + setIsExporting(true); + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(queryFilters)) { + if (value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + + const queryString = params.toString(); + const url = queryString ? `${apiEndpoint}?${queryString}` : apiEndpoint; + + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "text/csv", + }, + }); + + if (!response.ok) { + throw new Error(`Export failed with status: ${response.status}`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + + // Determine download filename + let resolvedFileName = fileName; + if (!resolvedFileName) { + const disposition = response.headers.get("Content-Disposition"); + if (disposition && disposition.includes("filename=")) { + const match = disposition.match(/filename="?([^"]+)"?/); + if (match && match[1]) { + resolvedFileName = match[1]; + } + } + } + link.download = resolvedFileName || `grants-export-${new Date().toISOString().slice(0, 10)}.csv`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + + onExportSuccess?.(); + } catch (err) { + const error = err instanceof Error ? err : new Error("Failed to export CSV"); + onExportError?.(error); + } finally { + setIsExporting(false); + } + }; + + return ( + + ); +} + +export default CsvExportButton; diff --git a/frontend/src/lib/csv-export.test.ts b/frontend/src/lib/csv-export.test.ts new file mode 100644 index 0000000..3d7b6df --- /dev/null +++ b/frontend/src/lib/csv-export.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + generateGrantsCsv, + mapTaskToExportRecord, + sanitizeCsvCell, + CSV_EXPORT_HEADERS, +} from "./csv-export"; +import type { TaskRecord } from "@/types/task-workflow"; + +const MOCK_TASK_1: TaskRecord = { + id: "1", + poster: "GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + title: "Build DEX Interface", + description: "Create React frontend with swap UI, charts & wallet integration.", + reward: 50_000_000, + deadline: 1774780800, + maxSubmissions: 5, + submissionCount: 2, + status: "open", + createdAt: "2026-03-20T12:00:00.000Z", + difficulty: "intermediate", + technologies: ["React", "TypeScript", "Soroban"], + organization: "Stellar Foundation", +}; + +const MOCK_TASK_INJECTION: TaskRecord = { + id: "2", + poster: "GATTACKER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + title: "=cmd|' /C calc'!A0", + description: "-@SUM(1+1)*cmd|' /C calc'!A0", + reward: 10_000_000, + deadline: 1774780800, + maxSubmissions: 1, + submissionCount: 0, + status: "open", + createdAt: "2026-03-20T12:00:00.000Z", + difficulty: "beginner", + technologies: ["Security", "+Formula"], + organization: "@EvilOrg", +}; + +describe("csv-export utility", () => { + describe("sanitizeCsvCell", () => { + it("handles null and undefined safely", () => { + expect(sanitizeCsvCell(null)).toBe(""); + expect(sanitizeCsvCell(undefined)).toBe(""); + }); + + it("returns simple strings unchanged", () => { + expect(sanitizeCsvCell("Simple String")).toBe("Simple String"); + expect(sanitizeCsvCell(12345)).toBe("12345"); + }); + + it("escapes cells containing commas and quotes per RFC 4180", () => { + expect(sanitizeCsvCell("Hello, world")).toBe('"Hello, world"'); + expect(sanitizeCsvCell('Hello "quoted" text')).toBe('"Hello ""quoted"" text"'); + expect(sanitizeCsvCell("Line 1\nLine 2")).toBe('"Line 1\nLine 2"'); + }); + + it("neutralizes CSV formula injection starting characters (=, +, -, @, \\t, \\r)", () => { + expect(sanitizeCsvCell("=SUM(A1:A10)")).toBe("'=SUM(A1:A10)"); + expect(sanitizeCsvCell("+123456")).toBe("'+123456"); + expect(sanitizeCsvCell("-100")).toBe("'-100"); + expect(sanitizeCsvCell("@SUM")).toBe("'@SUM"); + expect(sanitizeCsvCell("\tTAB")).toBe("'\tTAB"); + }); + + it("wraps and escapes injected formulas that also contain commas or quotes", () => { + const injectedWithComma = sanitizeCsvCell('=cmd|"/C calc",A0'); + expect(injectedWithComma).toBe('"\'=cmd|""/C calc"",A0"'); + }); + }); + + describe("mapTaskToExportRecord", () => { + it("maps TaskRecord correctly with human-readable XLM conversions", () => { + const record = mapTaskToExportRecord(MOCK_TASK_1); + expect(record.id).toBe("1"); + expect(record.title).toBe("Build DEX Interface"); + expect(record.rewardStroops).toBe(50_000_000); + expect(record.rewardXlm).toBe("5"); + expect(record.technologies).toBe("React; TypeScript; Soroban"); + expect(record.organization).toBe("Stellar Foundation"); + expect(record.posterAddress).toBe("GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + }); + }); + + describe("generateGrantsCsv", () => { + it("generates CSV with headers and data rows", () => { + const csv = generateGrantsCsv([MOCK_TASK_1]); + const lines = csv.split("\r\n"); + + expect(lines.length).toBe(2); + expect(lines[0]).toContain("Grant ID,Title,Description,Reward (Stroops)"); + expect(lines[1]).toContain("1,Build DEX Interface"); + expect(lines[1]).toContain("50000000"); + expect(lines[1]).toContain("React; TypeScript; Soroban"); + }); + + it("prevents formula execution across all generated CSV fields", () => { + const csv = generateGrantsCsv([MOCK_TASK_INJECTION]); + const lines = csv.split("\r\n"); + const dataRow = lines[1]; + + // Leading '=' is neutralized with single quote + expect(dataRow).toContain("'=cmd"); + // Leading '-@' is neutralized + expect(dataRow).toContain("'-@SUM"); + // Leading '@' in org is neutralized + expect(dataRow).toContain("'@EvilOrg"); + }); + + it("handles empty task arrays gracefully with header only", () => { + const csv = generateGrantsCsv([]); + expect(csv).toContain("Grant ID,Title,Description"); + const lines = csv.split("\r\n"); + expect(lines.length).toBe(1); + }); + + it("supports omitting headers when requested", () => { + const csv = generateGrantsCsv([MOCK_TASK_1], { includeHeaders: false }); + const lines = csv.split("\r\n"); + expect(lines.length).toBe(1); + expect(lines[0]).toContain("1,Build DEX Interface"); + }); + }); +}); diff --git a/frontend/src/lib/csv-export.ts b/frontend/src/lib/csv-export.ts new file mode 100644 index 0000000..d52a7a5 --- /dev/null +++ b/frontend/src/lib/csv-export.ts @@ -0,0 +1,122 @@ +import type { TaskRecord } from "@/types/task-workflow"; + +export interface CsvExportOptions { + includeHeaders?: boolean; + delimiter?: string; + excludeSensitive?: boolean; +} + +export interface ExportableGrantRecord { + id: string; + title: string; + description: string; + rewardStroops: number; + rewardXlm: string; + deadlineUnix: number; + deadlineIso: string; + maxSubmissions: number; + submissionCount: number; + status: string; + createdAt: string; + difficulty: string; + technologies: string; + organization: string; + posterAddress: string; +} + +export const CSV_EXPORT_HEADERS: Array<{ key: keyof ExportableGrantRecord; label: string }> = [ + { key: "id", label: "Grant ID" }, + { key: "title", label: "Title" }, + { key: "description", label: "Description" }, + { key: "rewardStroops", label: "Reward (Stroops)" }, + { key: "rewardXlm", label: "Reward (XLM)" }, + { key: "deadlineUnix", label: "Deadline (Unix Timestamp)" }, + { key: "deadlineIso", label: "Deadline (ISO)" }, + { key: "maxSubmissions", label: "Max Submissions" }, + { key: "submissionCount", label: "Current Submissions" }, + { key: "status", label: "Status" }, + { key: "createdAt", label: "Created At" }, + { key: "difficulty", label: "Difficulty" }, + { key: "technologies", label: "Technologies" }, + { key: "organization", label: "Organization" }, + { key: "posterAddress", label: "Poster Address" }, +]; + +/** + * Escapes a cell value per RFC 4180 and protects against CSV formula injection (DDE attacks). + * If a string begins with =, +, -, @, \t, or \r, a single quote prefix is prepended to neutralize formula execution in spreadsheet software. + */ +export function sanitizeCsvCell(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + let str = String(value); + + // CSV formula injection protection: neutralize dangerous starting characters + if (/^[=+\-@\t\r]/.test(str)) { + str = `'${str}`; + } + + // If the cell contains quotes, commas, newlines, or carriage returns, wrap in quotes and escape existing quotes + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + + return str; +} + +/** + * Maps an internal TaskRecord to a sanitized, public exportable structure excluding sensitive data. + */ +export function mapTaskToExportRecord(task: TaskRecord): ExportableGrantRecord { + const rewardXlm = (task.reward / 10_000_000).toFixed(7).replace(/\.?0+$/, ""); + let deadlineIso = ""; + try { + deadlineIso = new Date(task.deadline * 1000).toISOString(); + } catch { + deadlineIso = ""; + } + + return { + id: task.id, + title: task.title, + description: task.description, + rewardStroops: task.reward, + rewardXlm: rewardXlm || "0", + deadlineUnix: task.deadline, + deadlineIso, + maxSubmissions: task.maxSubmissions, + submissionCount: task.submissionCount, + status: task.status, + createdAt: task.createdAt, + difficulty: task.difficulty, + technologies: Array.isArray(task.technologies) ? task.technologies.join("; ") : "", + organization: task.organization || "", + posterAddress: task.poster || "", + }; +} + +/** + * Converts an array of TaskRecords into an RFC 4180-compliant CSV string. + */ +export function generateGrantsCsv( + tasks: TaskRecord[], + options: CsvExportOptions = {}, +): string { + const { includeHeaders = true, delimiter = "," } = options; + const rows: string[] = []; + + if (includeHeaders) { + const headerRow = CSV_EXPORT_HEADERS.map((h) => sanitizeCsvCell(h.label)).join(delimiter); + rows.push(headerRow); + } + + for (const task of tasks) { + const exportRecord = mapTaskToExportRecord(task); + const row = CSV_EXPORT_HEADERS.map((h) => sanitizeCsvCell(exportRecord[h.key])).join(delimiter); + rows.push(row); + } + + return rows.join("\r\n"); +} diff --git a/frontend/src/lib/form-validation.test.ts b/frontend/src/lib/form-validation.test.ts index e741ba5..58ee143 100644 --- a/frontend/src/lib/form-validation.test.ts +++ b/frontend/src/lib/form-validation.test.ts @@ -763,7 +763,6 @@ describe("validateCreateTaskForm", () => { }); it("accepts valid token address", () => { - const result = validateCreateTaskForm(VALID_TASK_DATA_WITH_OPTIONALS); const result = validateCreateTaskForm({ ...validForm, token: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW", @@ -844,7 +843,6 @@ describe("validateWorkSubmissionForm", () => { }); it("accepts valid contributor address", () => { - const result = validateWorkSubmissionForm(VALID_WORK_SUBMISSION_WITH_CONTRIBUTOR); const result = validateWorkSubmissionForm({ ...validForm, contributorAddress: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW", diff --git a/frontend/src/lib/task-submission-e2e.test.ts b/frontend/src/lib/task-submission-e2e.test.ts index 28a517a..26c3651 100644 --- a/frontend/src/lib/task-submission-e2e.test.ts +++ b/frontend/src/lib/task-submission-e2e.test.ts @@ -23,6 +23,9 @@ async function createTaskRequest(overrides: Record = {}) { body: JSON.stringify({ poster: POSTER_ADDRESS, ...VALID_TASK_DATA, + reward: 5_000_000, + deadline: futureDeadline(), + maxSubmissions: 3, ...overrides, }), }), diff --git a/frontend/src/test/regression.test.ts b/frontend/src/test/regression.test.ts index ae9dd48..368b190 100644 --- a/frontend/src/test/regression.test.ts +++ b/frontend/src/test/regression.test.ts @@ -15,6 +15,8 @@ * 4. Reference any related PRs or commits */ +import fs from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; // ============================================================================ @@ -32,15 +34,15 @@ import { describe, expect, it } from "vitest"; describe("Regression: Issue #82 - Security Headers", () => { it("should have security headers module available", () => { - // This test verifies the security headers module exists and can be imported - // The actual header value tests are in security-headers.test.ts - expect(() => import("../../../security-headers.mjs")).not.toThrow(); + // This test verifies the security headers module exists + const filePath = path.resolve(__dirname, "../../security-headers.mjs"); + expect(fs.existsSync(filePath)).toBe(true); }); it("should have security header tests in place", () => { // This test ensures the dedicated security header test file exists - // and is being run as part of the test suite - expect(() => import("../lib/security-headers.test.ts")).not.toThrow(); + const filePath = path.resolve(__dirname, "../lib/security-headers.test.ts"); + expect(fs.existsSync(filePath)).toBe(true); }); }); @@ -64,14 +66,17 @@ describe("Regression: Issue #82 - Security Headers", () => { describe("Regression: Issue #83 - Form Accessibility", () => { it("should have accessibility tests for TaskFilter component", () => { // Verifies the TaskFilter accessibility test suite exists - expect(() => import("../components/TaskFilter.test.tsx")).not.toThrow(); + const filePath = path.resolve(__dirname, "../components/TaskFilter.test.tsx"); + expect(fs.existsSync(filePath)).toBe(true); }); it("should have accessibility tests for WaitlistHeroSection component", () => { // Verifies the WaitlistHeroSection accessibility test suite exists - expect(() => - import("../app/(marketing)/landing/components/WaitlistHeroSection.test.tsx"), - ).not.toThrow(); + const filePath = path.resolve( + __dirname, + "../app/(marketing)/landing/components/WaitlistHeroSection.test.tsx", + ); + expect(fs.existsSync(filePath)).toBe(true); }); it("should prevent duplicate accessibility attributes", () => {