diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 4c8bf632..a483b29b 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -2,16 +2,16 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] env: CARGO_TERM_COLOR: always defaults: run: - working-directory: contracts # 👈 all cargo commands run in contracts/ + working-directory: contracts # 👈 all cargo commands run in contracts/ jobs: format: @@ -94,4 +94,74 @@ jobs: ${{ runner.os }}-cargo-build- ${{ runner.os }}-cargo- - name: Build all crates - run: cargo build --all --verbose \ No newline at end of file + run: cargo build --all --verbose + + # ───────────────────────────────────────────── + # BACKEND — NestJS + # ───────────────────────────────────────────── + backend: + name: Backend (NestJS) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build (TypeScript check) + run: npm run build + + - name: Unit tests + run: npm run test -- --passWithNoTests + + # ───────────────────────────────────────────── + # FRONTEND — Next.js + # ───────────────────────────────────────────── + frontend: + name: Frontend (Next.js) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 + + - name: Unit tests + run: npm run test -- --passWithNoTests diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c2658d7d..00000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/backend/package-lock.json b/backend/package-lock.json index 0dbde9c0..01d40fe5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.15", + "@stellar/stellar-sdk": "^14.5.0", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", @@ -1275,6 +1276,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2847,6 +2849,7 @@ "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", + "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2892,6 +2895,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz", "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", "hasInstallScript": true, + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2985,6 +2989,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", @@ -3166,6 +3171,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.15.tgz", "integrity": "sha512-OmCUJwvtagzXfMVko595O98UI3M9zg+URL+/HV7vd3QPMCZ3uGCKSq15YYJ99LHJn9NyK4e4Szm2KnHtUg2QzA==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -3184,11 +3190,25 @@ } } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3363,6 +3383,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4183,6 +4204,94 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-base/node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@stellar/stellar-base/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.5.0.tgz", + "integrity": "sha512-Uzjq+An/hUA+Q5ERAYPtT0+MMiwWnYYWMwozmZMjxjdL2MmSjucBDF8Q04db6K/ekU4B5cHuOfsdlrfaxQYblw==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.0.4", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@streamparser/json": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", @@ -4361,6 +4470,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4524,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4763,6 +4874,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -5140,6 +5252,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5152,7 +5265,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -5186,6 +5298,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5503,13 +5616,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -5712,6 +5825,15 @@ "node": ">=0.6" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", @@ -5841,6 +5963,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5941,6 +6064,7 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -5987,6 +6111,7 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", + "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -6018,6 +6143,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -6266,13 +6392,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7157,6 +7285,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7212,6 +7341,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7437,6 +7567,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -7524,6 +7663,7 @@ "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7690,6 +7830,15 @@ "bser": "2.1.1" } }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7962,9 +8111,10 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8710,6 +8860,18 @@ "node": ">=8" } }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -8862,6 +9024,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9748,6 +9911,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10718,6 +10882,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10847,6 +11012,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -11107,6 +11273,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11376,7 +11543,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -11470,6 +11636,7 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", "license": "MIT", + "peer": true, "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", @@ -11791,6 +11958,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12758,6 +12926,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -12882,6 +13056,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13032,6 +13207,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^3.17.0", @@ -13183,6 +13359,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13361,6 +13538,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13528,7 +13711,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13542,7 +13724,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -13552,7 +13733,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/package.json b/backend/package.json index 3e47de34..cc9473e2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,6 +36,7 @@ "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.15", + "@stellar/stellar-sdk": "^14.5.0", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", diff --git a/backend/src/categories/categories.controller.spec.ts b/backend/src/categories/categories.controller.spec.ts deleted file mode 100644 index c59972a3..00000000 --- a/backend/src/categories/categories.controller.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CategoriesController } from './categories.controller'; -import { CategoriesService } from './categories.service'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -const mockCategory = { id: 'uuid-1', name: 'Electronics' }; -const mockCategoryWithCount = { ...mockCategory, assetCount: 5 }; - -const mockService = { - findAll: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - remove: jest.fn(), -}; - -describe('CategoriesController', () => { - let controller: CategoriesController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CategoriesController], - providers: [{ provide: CategoriesService, useValue: mockService }], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: () => true }) - .compile(); - - controller = module.get(CategoriesController); - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return all categories with asset counts', async () => { - mockService.findAll.mockResolvedValue([mockCategoryWithCount]); - - const result = await controller.findAll(); - - expect(mockService.findAll).toHaveBeenCalledTimes(1); - expect(result).toEqual([mockCategoryWithCount]); - }); - }); - - describe('findOne', () => { - it('should return a single category by id', async () => { - mockService.findOne.mockResolvedValue(mockCategory); - - const result = await controller.findOne('uuid-1'); - - expect(mockService.findOne).toHaveBeenCalledWith('uuid-1'); - expect(result).toEqual(mockCategory); - }); - - it('should propagate exceptions from the service', async () => { - mockService.findOne.mockRejectedValue(new Error('Category not found')); - - await expect(controller.findOne('missing-id')).rejects.toThrow('Category not found'); - }); - }); - - describe('create', () => { - const dto = { name: 'Electronics' }; - - it('should create and return a new category', async () => { - mockService.create.mockResolvedValue(mockCategory); - - const result = await controller.create(dto); - - expect(mockService.create).toHaveBeenCalledWith(dto); - expect(result).toEqual(mockCategory); - }); - - it('should propagate conflict exceptions from the service', async () => { - mockService.create.mockRejectedValue(new Error('A category with this name already exists')); - - await expect(controller.create(dto)).rejects.toThrow( - 'A category with this name already exists', - ); - }); - }); - - describe('remove', () => { - it('should call service.remove with the correct id', async () => { - mockService.remove.mockResolvedValue(undefined); - - const result = await controller.remove('uuid-1'); - - expect(mockService.remove).toHaveBeenCalledWith('uuid-1'); - expect(result).toBeUndefined(); - }); - - it('should propagate exceptions from the service', async () => { - mockService.remove.mockRejectedValue(new Error('Category not found')); - - await expect(controller.remove('missing-id')).rejects.toThrow('Category not found'); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/categories/categories.service.spec.ts b/backend/src/categories/categories.service.spec.ts deleted file mode 100644 index fe50a500..00000000 --- a/backend/src/categories/categories.service.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { NotFoundException, ConflictException } from '@nestjs/common'; -import { CategoriesService } from './categories.service'; -import { Category } from './category.entity'; - -const mockCategory: Category = { - id: 'uuid-1', - name: 'Electronics', -} as Category; - -const mockRepo = { - query: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - remove: jest.fn(), -}; - -describe('CategoriesService', () => { - let service: CategoriesService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CategoriesService, - { provide: getRepositoryToken(Category), useValue: mockRepo }, - ], - }).compile(); - - service = module.get(CategoriesService); - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return categories with numeric assetCount', async () => { - const rawRows = [ - { ...mockCategory, assetCount: '3' }, - { id: 'uuid-2', name: 'Furniture', assetCount: '0' }, - ]; - mockRepo.query.mockResolvedValue(rawRows); - - const result = await service.findAll(); - - expect(mockRepo.query).toHaveBeenCalledTimes(1); - expect(result).toEqual([ - { ...mockCategory, assetCount: 3 }, - { id: 'uuid-2', name: 'Furniture', assetCount: 0 }, - ]); - expect(typeof result[0].assetCount).toBe('number'); - }); - - it('should return an empty array when no categories exist', async () => { - mockRepo.query.mockResolvedValue([]); - const result = await service.findAll(); - expect(result).toEqual([]); - }); - }); - - describe('findOne', () => { - it('should return a category when found', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - - const result = await service.findOne('uuid-1'); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); - expect(result).toEqual(mockCategory); - }); - - it('should throw NotFoundException when category does not exist', async () => { - mockRepo.findOne.mockResolvedValue(null); - - await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); - await expect(service.findOne('missing-id')).rejects.toThrow('Category not found'); - }); - }); - - describe('create', () => { - const dto = { name: 'Electronics' }; - - it('should create and return a new category', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.create.mockReturnValue(mockCategory); - mockRepo.save.mockResolvedValue(mockCategory); - - const result = await service.create(dto); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { name: dto.name } }); - expect(mockRepo.create).toHaveBeenCalledWith(dto); - expect(mockRepo.save).toHaveBeenCalledWith(mockCategory); - expect(result).toEqual(mockCategory); - }); - - it('should throw ConflictException when a category with the same name exists', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - - await expect(service.create(dto)).rejects.toThrow(ConflictException); - await expect(service.create(dto)).rejects.toThrow( - 'A category with this name already exists', - ); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - }); - - describe('remove', () => { - it('should remove the category successfully', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - mockRepo.remove.mockResolvedValue(undefined); - - await service.remove('uuid-1'); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); - expect(mockRepo.remove).toHaveBeenCalledWith(mockCategory); - }); - - it('should throw NotFoundException if category does not exist', async () => { - mockRepo.findOne.mockResolvedValue(null); - - await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); - expect(mockRepo.remove).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/categories/dto/create-category.dto.ts b/backend/src/categories/dto/create-category.dto.ts index 9501e61c..a1692d84 100644 --- a/backend/src/categories/dto/create-category.dto.ts +++ b/backend/src/categories/dto/create-category.dto.ts @@ -3,14 +3,12 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; export class CreateCategoryDto { @ApiProperty({ example: 'Laptop' }) - @ApiDescription('The name of the category') @IsString() @IsNotEmpty() @MaxLength(100) name: string; @ApiPropertyOptional({ example: 'Portable computing devices' }) - @ApiDescription('A brief description of the category') @IsString() @IsOptional() @MaxLength(500) diff --git a/backend/src/departments/department.entity.ts b/backend/src/departments/department.entity.ts new file mode 100644 index 00000000..9fc3cbef --- /dev/null +++ b/backend/src/departments/department.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('departments') +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ nullable: true }) + description: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts new file mode 100644 index 00000000..5fb1642a --- /dev/null +++ b/backend/src/departments/departments.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { DepartmentsService } from './departments.service'; +import { CreateDepartmentDto } from './dto/create-department.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('Departments') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('departments') +export class DepartmentsController { + constructor(private readonly service: DepartmentsService) {} + + @Get() + @ApiOperation({ summary: 'List all departments' }) + findAll() { + return this.service.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a department by ID' }) + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Post() + @ApiOperation({ summary: 'Create a new department' }) + create(@Body() dto: CreateDepartmentDto) { + return this.service.create(dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a department' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/backend/src/departments/departments.module.ts b/backend/src/departments/departments.module.ts new file mode 100644 index 00000000..a8cbe033 --- /dev/null +++ b/backend/src/departments/departments.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Department } from './department.entity'; +import { DepartmentsService } from './departments.service'; +import { DepartmentsController } from './departments.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Department])], + controllers: [DepartmentsController], + providers: [DepartmentsService], + exports: [DepartmentsService], +}) +export class DepartmentsModule {} diff --git a/backend/src/departments/departments.service.ts b/backend/src/departments/departments.service.ts new file mode 100644 index 00000000..c28343de --- /dev/null +++ b/backend/src/departments/departments.service.ts @@ -0,0 +1,45 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from './department.entity'; +import { CreateDepartmentDto } from './dto/create-department.dto'; + +export interface DepartmentWithCount extends Department { + assetCount: number; +} + +@Injectable() +export class DepartmentsService { + constructor( + @InjectRepository(Department) + private readonly repo: Repository, + ) {} + + async findAll(): Promise { + const rows: (Department & { assetCount: string })[] = await this.repo.query(` + SELECT d.*, COALESCE(COUNT(a.id), 0)::int AS "assetCount" + FROM departments d + LEFT JOIN assets a ON a."departmentId" = d.id + GROUP BY d.id + ORDER BY d.name ASC + `); + return rows.map((r) => ({ ...r, assetCount: Number(r.assetCount) })); + } + + async findOne(id: string): Promise { + const dept = await this.repo.findOne({ where: { id } }); + if (!dept) throw new NotFoundException('Department not found'); + return dept; + } + + async create(dto: CreateDepartmentDto): Promise { + const existing = await this.repo.findOne({ where: { name: dto.name } }); + if (existing) throw new ConflictException('A department with this name already exists'); + return this.repo.save(this.repo.create(dto)); + } + + async remove(id: string): Promise { + const dept = await this.findOne(id); + await this.repo.remove(dept); + } +} diff --git a/backend/src/departments/dto/create-department.dto.ts b/backend/src/departments/dto/create-department.dto.ts new file mode 100644 index 00000000..d01cf4f5 --- /dev/null +++ b/backend/src/departments/dto/create-department.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateDepartmentDto { + @ApiProperty({ example: 'Engineering' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ example: 'Software engineering department' }) + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; +} diff --git a/contracts/.gitignore b/contracts/.gitignore index a7eaa152..796603f3 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -1,3 +1,2 @@ target -.env -test_snapshots/ \ No newline at end of file +.env \ No newline at end of file diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index e95c91ed..a7592e0b 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1280,9 +1280,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "22.0.10" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f420c93b0e44c6d08207adf86b6c608a95b272440f55737f20dcb3812e1980f" +checksum = "2826e2c9d364edbb2ea112dc861077c74557bdad0a7a00487969088c7c648169" dependencies = [ "serde", "serde_json", @@ -1294,9 +1294,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "22.0.10" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d702069aa16d36dee1d6b3c75e4bfae8e2692f2cbc16496652757d18d8533f7e" +checksum = "c7ac27d7573e62b745513fa1be8dab7a09b9676a7f39db97164f1d458a344749" dependencies = [ "arbitrary", "bytes-lit", @@ -1316,9 +1316,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "22.0.10" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965b977c52d16dd8dfd9074702583e5a5778f230cda0fa78b2ea743e9149ac8d" +checksum = "9ef0d7d62b2584696d306b8766728971c7d0731a03a5e047f1fc68722ac8cf0c" dependencies = [ "crate-git-revision", "darling 0.20.11", @@ -1336,9 +1336,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "22.0.10" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ead5de5373f3278707c06142a12bd6bcf4cbe211a89a74904ae238b3aaabc5" +checksum = "a4ad0867aec99770ed614fedbec7ac4591791df162ff9e548ab7ebd07cd23a9c" dependencies = [ "base64 0.13.1", "stellar-xdr", @@ -1348,9 +1348,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "22.0.10" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9dd041c94ce48aa9f7f115e748ee9fa5b9f263df4a70178f060dd0876e15d11" +checksum = "aebe31c042adfa2885ec47b67b08fcead8707da80a3fe737eaf2a9ae1a8cfdc3" dependencies = [ "prettyplease", "proc-macro2", diff --git a/contracts/asset-maintenance/src/lib.rs b/contracts/asset-maintenance/src/lib.rs index f8b11a27..1bc42af5 100644 --- a/contracts/asset-maintenance/src/lib.rs +++ b/contracts/asset-maintenance/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, +}; mod test; @@ -141,11 +143,11 @@ pub enum DataKey { Admin, AssetRegistry, Provider(Address), - MaintenanceHistory(u64), // asset_id -> Vec - MaintenanceSchedule(u64), // asset_id -> ScheduledMaintenance - Warranty(u64), // asset_id -> WarrantyInfo - Alerts(u64), // asset_id -> Vec - AssetStats(u64), // asset_id -> AssetStats (downtime, total cost, etc.) + MaintenanceHistory(u64), // asset_id -> Vec + MaintenanceSchedule(u64), // asset_id -> ScheduledMaintenance + Warranty(u64), // asset_id -> WarrantyInfo + Alerts(u64), // asset_id -> Vec + AssetStats(u64), // asset_id -> AssetStats (downtime, total cost, etc.) } #[contracttype] @@ -538,7 +540,7 @@ impl AssetMaintenanceContract { } } - score.clamp(0, 100) + score.min(100).max(0) } fn verify_asset_exists(_env: &Env, _asset_id: u64) -> bool { diff --git a/contracts/asset-maintenance/src/test.rs b/contracts/asset-maintenance/src/test.rs index 74ab59c7..ff3e32c6 100644 --- a/contracts/asset-maintenance/src/test.rs +++ b/contracts/asset-maintenance/src/test.rs @@ -8,7 +8,7 @@ use soroban_sdk::{vec, Address, Env, String}; #[test] fn test_init_and_provider_registration() { let env = Env::default(); - let contract_id = env.register(AssetMaintenanceContract, ()); + let contract_id = env.register_contract(None, AssetMaintenanceContract); let client = AssetMaintenanceContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -35,11 +35,11 @@ fn test_init_and_provider_registration() { let fetched = client.get_provider_details(&provider_addr).unwrap(); assert_eq!(fetched.name, String::from_str(&env, "Service Corp")); - assert!(fetched.is_active); + assert_eq!(fetched.is_active, true); client.deactivate_provider(&provider_addr); let deactivated = client.get_provider_details(&provider_addr).unwrap(); - assert!(!deactivated.is_active); + assert_eq!(deactivated.is_active, false); } #[test] @@ -47,7 +47,7 @@ fn test_maintenance_lifecycle() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(AssetMaintenanceContract, ()); + let contract_id = env.register_contract(None, AssetMaintenanceContract); let client = AssetMaintenanceContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -112,7 +112,7 @@ fn test_warranty_and_claims() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(AssetMaintenanceContract, ()); + let contract_id = env.register_contract(None, AssetMaintenanceContract); let client = AssetMaintenanceContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -147,7 +147,7 @@ fn test_alerts_and_stats() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(AssetMaintenanceContract, ()); + let contract_id = env.register_contract(None, AssetMaintenanceContract); let client = AssetMaintenanceContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -172,11 +172,11 @@ fn test_alerts_and_stats() { client.acknowledge_maintenance_alert(&asset_id, &0, &admin); let acknowledged_alerts = client.get_alerts(&asset_id); - assert!(acknowledged_alerts.get(0).unwrap().acknowledged); + assert_eq!(acknowledged_alerts.get(0).unwrap().acknowledged, true); // Test stats let stats = client.get_asset_stats(&asset_id); assert_eq!(stats.service_count, 0); // No service yet - assert!(!client.is_maintenance_cost_excessive(&asset_id, &1000)); + assert_eq!(client.is_maintenance_cost_excessive(&asset_id, &1000), false); } diff --git a/contracts/assetsup/src/asset.rs b/contracts/assetsup/src/asset.rs index daee3f8c..42c5d91d 100644 --- a/contracts/assetsup/src/asset.rs +++ b/contracts/assetsup/src/asset.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; +use soroban_sdk::{Address, BytesN, String, Vec, contracttype}; use crate::types::{AssetStatus, CustomAttribute}; diff --git a/contracts/assetsup/src/audit.rs b/contracts/assetsup/src/audit.rs index 8e01ea54..ff0e0a8a 100644 --- a/contracts/assetsup/src/audit.rs +++ b/contracts/assetsup/src/audit.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{contracttype, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{Address, BytesN, Env, String, Vec, contracttype}; + +use crate::types::ActionType; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -9,20 +11,18 @@ pub enum DataKey { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct AuditEntry { - pub timestamp: u64, - pub action: String, pub actor: Address, - pub details: String, + pub action: ActionType, + pub timestamp: u64, + pub note: String, } -/// Internal function to append an audit log entry for an asset -/// This function is used by various modules to record significant events -pub(crate) fn append_audit_log( +pub fn log_action( env: &Env, asset_id: &BytesN<32>, - action: String, actor: Address, - details: String, + action: ActionType, + note: String, ) { let key = DataKey::AuditLog(asset_id.clone()); let mut log: Vec = env @@ -32,18 +32,16 @@ pub(crate) fn append_audit_log( .unwrap_or_else(|| Vec::new(env)); let entry = AuditEntry { - timestamp: env.ledger().timestamp(), - action, actor, - details, + action, + timestamp: env.ledger().timestamp(), + note, }; log.push_back(entry); env.storage().persistent().set(&key, &log); } -/// Public function to retrieve the audit log for an asset -/// Returns an empty vector if no history exists pub fn get_asset_log(env: &Env, asset_id: &BytesN<32>) -> Vec { let key = DataKey::AuditLog(asset_id.clone()); env.storage() diff --git a/contracts/assetsup/src/branch.rs b/contracts/assetsup/src/branch.rs index 393cbb99..5e91284d 100644 --- a/contracts/assetsup/src/branch.rs +++ b/contracts/assetsup/src/branch.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, BytesN, String}; +use soroban_sdk::{Address, BytesN, String, contracttype}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/assetsup/src/detokenization.rs b/contracts/assetsup/src/detokenization.rs index d297ce5f..888aea1a 100644 --- a/contracts/assetsup/src/detokenization.rs +++ b/contracts/assetsup/src/detokenization.rs @@ -1,25 +1,27 @@ use crate::error::Error; -use crate::types::{ - ActiveProposal, DetokenizationProposal, ExecutedProposal, RejectedProposal, TokenDataKey, - TokenizedAsset, -}; +use crate::types::{ContractEvent, DetokenizationProposal, TokenDataKey, TokenizedAsset}; use crate::voting; -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, BigInt, Env}; /// Propose detokenization (requires voting) -pub fn propose_detokenization(env: &Env, asset_id: u64, proposer: Address) -> Result { +pub fn propose_detokenization( + env: &Env, + asset_id: u64, + proposer: Address, +) -> Result { let store = env.storage().persistent(); // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Check if proposal already exists let proposal_key = TokenDataKey::DetokenizationProposal(asset_id); if store.has(&proposal_key) { - if let Some(DetokenizationProposal::Active(_)) = - store.get::<_, DetokenizationProposal>(&proposal_key) - { + if let Some(Some(DetokenizationProposal::Active { .. })) = store.get(&proposal_key) { return Err(Error::DetokenizationAlreadyProposed); } } @@ -28,11 +30,11 @@ pub fn propose_detokenization(env: &Env, asset_id: u64, proposer: Address) -> Re let proposal_id = asset_id; // Use asset_id as proposal_id for simplicity let timestamp = env.ledger().timestamp(); - let proposal = DetokenizationProposal::Active(ActiveProposal { + let proposal = DetokenizationProposal::Active { proposal_id, proposer, created_at: timestamp, - }); + }; store.set(&proposal_key, &proposal); @@ -40,18 +42,24 @@ pub fn propose_detokenization(env: &Env, asset_id: u64, proposer: Address) -> Re } /// Execute detokenization if vote passed -/// This will remove all tokens from circulation and clear tokenization records -pub fn execute_detokenization(env: &Env, asset_id: u64, proposal_id: u64) -> Result<(), Error> { +pub fn execute_detokenization( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result<(), Error> { let store = env.storage().persistent(); // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Check if proposal is active let proposal_key = TokenDataKey::DetokenizationProposal(asset_id); - match store.get::<_, DetokenizationProposal>(&proposal_key) { - Some(DetokenizationProposal::Active(_)) => { + match store.get(&proposal_key) { + Some(Some(DetokenizationProposal::Active { .. })) => { // Continue } _ => { @@ -65,105 +73,58 @@ pub fn execute_detokenization(env: &Env, asset_id: u64, proposal_id: u64) -> Res return Err(Error::DetokenizationNotApproved); } - // Save total supply for event before clearing - let total_supply = tokenized_asset.total_supply; - - // Clear all votes BEFORE removing TokenizedAsset (voting module needs it) - voting::clear_proposal_votes(env, asset_id, proposal_id)?; - - // Get list of all token holders before clearing - let holders_list_key = TokenDataKey::TokenHoldersList(asset_id); - let holders = store - .get::<_, soroban_sdk::Vec
>(&holders_list_key) - .ok_or(Error::AssetNotTokenized)?; - - // Remove all token holder records - for holder in holders.iter() { - let holder_key = TokenDataKey::TokenHolder(asset_id, holder.clone()); - if store.has(&holder_key) { - store.remove(&holder_key); - } - - // Remove any token locks - let lock_key = TokenDataKey::TokenLockedUntil(asset_id, holder.clone()); - if store.has(&lock_key) { - store.remove(&lock_key); - } - - // Remove unclaimed dividends - let dividend_key = TokenDataKey::UnclaimedDividend(asset_id, holder); - if store.has(÷nd_key) { - store.remove(÷nd_key); - } - } - - // Remove token holders list - if store.has(&holders_list_key) { - store.remove(&holders_list_key); - } - - // Remove transfer restrictions - let restriction_key = TokenDataKey::TransferRestriction(asset_id); - if store.has(&restriction_key) { - store.remove(&restriction_key); - } - - // Remove whitelist - let whitelist_key = TokenDataKey::Whitelist(asset_id); - if store.has(&whitelist_key) { - store.remove(&whitelist_key); - } - - // Remove token metadata - let metadata_key = TokenDataKey::TokenMetadata(asset_id); - if store.has(&metadata_key) { - store.remove(&metadata_key); - } - - // Remove the tokenized asset record (this eliminates all tokens from circulation) - if store.has(&key) { - store.remove(&key); - } - // Update proposal to executed let timestamp = env.ledger().timestamp(); - let executed_proposal = DetokenizationProposal::Executed(ExecutedProposal { + let executed_proposal = DetokenizationProposal::Executed { proposal_id, executed_at: timestamp, - }); + }; store.set(&proposal_key, &executed_proposal); - // Emit event: (asset_id, proposal_id, total_supply_removed) + // Clear all votes + voting::clear_proposal_votes(env, asset_id, proposal_id)?; + + // Emit event env.events().publish( ("detokenization", "asset_detokenized"), - (asset_id, proposal_id, total_supply), + ContractEvent::AssetDetokenized { + asset_id, + proposal_id, + }, ); Ok(()) } /// Reject detokenization proposal -#[allow(dead_code)] pub fn reject_detokenization(env: &Env, asset_id: u64) -> Result<(), Error> { let store = env.storage().persistent(); // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get proposal let proposal_key = TokenDataKey::DetokenizationProposal(asset_id); - let proposal: DetokenizationProposal = - store.get(&proposal_key).ok_or(Error::InvalidProposal)?; + let proposal: DetokenizationProposal = store + .get(&proposal_key) + .ok_or(Error::InvalidProposal)? + .ok_or(Error::InvalidProposal)?; match proposal { - DetokenizationProposal::Active(ActiveProposal { proposal_id, .. }) => { + DetokenizationProposal::Active { + proposal_id, + .. + } => { // Mark as rejected let timestamp = env.ledger().timestamp(); - let rejected_proposal = DetokenizationProposal::Rejected(RejectedProposal { + let rejected_proposal = DetokenizationProposal::Rejected { proposal_id, rejected_at: timestamp, - }); + }; store.set(&proposal_key, &rejected_proposal); // Clear votes @@ -183,7 +144,10 @@ pub fn get_detokenization_proposal( let store = env.storage().persistent(); let key = TokenDataKey::DetokenizationProposal(asset_id); - store.get(&key).ok_or(Error::InvalidProposal) + store + .get(&key) + .ok_or(Error::InvalidProposal)? + .ok_or(Error::InvalidProposal) } /// Check if detokenization is in progress @@ -191,8 +155,8 @@ pub fn is_detokenization_active(env: &Env, asset_id: u64) -> Result let store = env.storage().persistent(); let key = TokenDataKey::DetokenizationProposal(asset_id); - match store.get::<_, DetokenizationProposal>(&key) { - Some(DetokenizationProposal::Active(_)) => Ok(true), + match store.get(&key) { + Some(Some(DetokenizationProposal::Active { .. })) => Ok(true), _ => Ok(false), } } diff --git a/contracts/assetsup/src/dividends.rs b/contracts/assetsup/src/dividends.rs index 9ba0819e..4e8d3aab 100644 --- a/contracts/assetsup/src/dividends.rs +++ b/contracts/assetsup/src/dividends.rs @@ -1,10 +1,14 @@ use crate::error::Error; -use crate::types::{OwnershipRecord, TokenDataKey, TokenizedAsset}; -use soroban_sdk::{Address, Env, Vec}; +use crate::types::{ContractEvent, OwnershipRecord, TokenDataKey, TokenizedAsset}; +use soroban_sdk::{Address, BigInt, Env}; /// Distribute dividends proportionally to all token holders -pub fn distribute_dividends(env: &Env, asset_id: u64, total_amount: i128) -> Result<(), Error> { - if total_amount <= 0 { +pub fn distribute_dividends( + env: &Env, + asset_id: u64, + total_amount: BigInt, +) -> Result<(), Error> { + if total_amount <= BigInt::from_i128(env, 0) { return Err(Error::InvalidDividendAmount); } @@ -12,7 +16,10 @@ pub fn distribute_dividends(env: &Env, asset_id: u64, total_amount: i128) -> Res // Get tokenized asset let key = TokenDataKey::TokenizedAsset(asset_id); - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; if !tokenized_asset.revenue_sharing_enabled { return Err(Error::InvalidDividendAmount); @@ -20,74 +27,107 @@ pub fn distribute_dividends(env: &Env, asset_id: u64, total_amount: i128) -> Res // Get all token holders let holders_key = TokenDataKey::TokenHoldersList(asset_id); - let holders: Vec
= store.get(&holders_key).ok_or(Error::AssetNotTokenized)?; + let holders = store + .get(&holders_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Distribute proportionally to each holder for holder in holders.iter() { let holder_key = TokenDataKey::TokenHolder(asset_id, holder.clone()); - let mut ownership: OwnershipRecord = store.get(&holder_key).ok_or(Error::HolderNotFound)?; + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; // Calculate proportional dividend: (balance / total_supply) * total_amount - let proportion = (ownership.balance * total_amount) / tokenized_asset.total_supply; + let proportion = (&ownership.balance * &total_amount) / &tokenized_asset.total_supply; // Add to unclaimed dividends - ownership.unclaimed_dividends += proportion; + ownership.unclaimed_dividends = &ownership.unclaimed_dividends + &proportion; store.set(&holder_key, &ownership); } - // Emit event: (asset_id, total_amount, holder_count) + // Emit event env.events().publish( ("dividend", "distributed"), - (asset_id, total_amount, holders.len()), + ContractEvent::DividendDistributed { + asset_id, + total_amount, + holder_count: holders.len() as u32, + }, ); Ok(()) } /// Claim unclaimed dividends -pub fn claim_dividends(env: &Env, asset_id: u64, holder: Address) -> Result { +pub fn claim_dividends( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { let store = env.storage().persistent(); // Get tokenized asset let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get holder's ownership record let holder_key = TokenDataKey::TokenHolder(asset_id, holder.clone()); - let mut ownership: OwnershipRecord = store.get(&holder_key).ok_or(Error::HolderNotFound)?; + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; // Get unclaimed amount - let unclaimed = ownership.unclaimed_dividends; + let unclaimed = ownership.unclaimed_dividends.clone(); - if unclaimed <= 0 { + if unclaimed <= BigInt::from_i128(env, 0) { return Err(Error::NoDividendsToClaim); } // Clear unclaimed dividends - ownership.unclaimed_dividends = 0; + ownership.unclaimed_dividends = BigInt::from_i128(env, 0); store.set(&holder_key, &ownership); - // Emit event: (asset_id, holder, amount) - env.events() - .publish(("dividend", "claimed"), (asset_id, holder, unclaimed)); + // Emit event + env.events().publish( + ("dividend", "claimed"), + ContractEvent::DividendClaimed { + asset_id, + holder, + amount: unclaimed.clone(), + }, + ); Ok(unclaimed) } /// Get unclaimed dividends for a holder -pub fn get_unclaimed_dividends(env: &Env, asset_id: u64, holder: Address) -> Result { +pub fn get_unclaimed_dividends( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { let store = env.storage().persistent(); // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get holder's ownership record let holder_key = TokenDataKey::TokenHolder(asset_id, holder); - match store.get::<_, OwnershipRecord>(&holder_key) { - Some(ownership) => Ok(ownership.unclaimed_dividends), - None => Ok(0), + match store.get(&holder_key) { + Some(Some(ownership)) => Ok(ownership.unclaimed_dividends), + _ => Ok(BigInt::from_i128(env, 0)), } } @@ -96,7 +136,10 @@ pub fn enable_revenue_sharing(env: &Env, asset_id: u64) -> Result<(), Error> { let store = env.storage().persistent(); let key = TokenDataKey::TokenizedAsset(asset_id); - let mut tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; tokenized_asset.revenue_sharing_enabled = true; store.set(&key, &tokenized_asset); @@ -109,7 +152,10 @@ pub fn disable_revenue_sharing(env: &Env, asset_id: u64) -> Result<(), Error> { let store = env.storage().persistent(); let key = TokenDataKey::TokenizedAsset(asset_id); - let mut tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; tokenized_asset.revenue_sharing_enabled = false; store.set(&key, &tokenized_asset); diff --git a/contracts/assetsup/src/error.rs b/contracts/assetsup/src/error.rs index 2d8008a8..969f77c5 100644 --- a/contracts/assetsup/src/error.rs +++ b/contracts/assetsup/src/error.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracterror, panic_with_error, Env}; +use soroban_sdk::{Env, contracterror, panic_with_error}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -52,14 +52,23 @@ pub enum Error { InvalidPurchaseValue = 37, InvalidMetadataUri = 38, InvalidOwnerAddress = 39, - - LeaseNotFound = 40, - LeaseAlreadyExists = 41, + // Leasing errors + LeaseAlreadyExists = 40, + LeaseNotFound = 41, AssetAlreadyLeased = 42, - InvalidLeaseStatus = 43, + LeaseNotActive = 43, LeaseAlreadyStarted = 44, - LeaseNotExpired = 45, - InvalidTimestamps = 46, + InvalidLeasePeriod = 45, + InvalidRentAmount = 46, + LeaseNotExpired = 47, + // Insurance errors + PolicyAlreadyExists = 48, + PolicyNotFound = 49, + PolicyNotActive = 50, + PolicyNotExpired = 51, + ClaimAlreadyExists = 52, + ClaimNotFound = 53, + InvalidClaimStatus = 54, } pub fn handle_error(env: &Env, error: Error) -> ! { diff --git a/contracts/assetsup/src/insurance.rs b/contracts/assetsup/src/insurance.rs index 58d5d531..89872704 100644 --- a/contracts/assetsup/src/insurance.rs +++ b/contracts/assetsup/src/insurance.rs @@ -1,9 +1,12 @@ -#![allow(dead_code)] +#![allow(clippy::too_many_arguments)] -use crate::audit; -use crate::Error; -use soroban_sdk::{contracttype, log, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Env, Vec, symbol_short}; +use crate::error::Error; + +// ── Enums ─────────────────────────────────────────────────────────────────── + +/// Lifecycle states of an insurance policy. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum PolicyStatus { @@ -13,6 +16,7 @@ pub enum PolicyStatus { Suspended, } +/// Lifecycle states of an insurance claim. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum ClaimStatus { @@ -24,15 +28,18 @@ pub enum ClaimStatus { Disputed, } +/// Category of coverage offered by a policy. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum PolicyType { - Liability, - Property, Comprehensive, - Custom, + Theft, + Damage, + Liability, + BusinessInterruption, } +/// Nature of an insurance claim. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum ClaimType { @@ -43,6 +50,9 @@ pub enum ClaimType { Other, } +// ── Structs ────────────────────────────────────────────────────────────────── + +/// An insurance policy covering a registered asset. #[contracttype] #[derive(Clone, Debug)] pub struct InsurancePolicy { @@ -50,17 +60,25 @@ pub struct InsurancePolicy { pub holder: Address, pub insurer: Address, pub asset_id: BytesN<32>, + /// Category of coverage. pub policy_type: PolicyType, + /// Maximum payout (in stroops). pub coverage_amount: i128, + /// Amount the holder must bear before coverage kicks in (stroops). pub deductible: i128, + /// Periodic premium cost (stroops). pub premium: i128, + /// Ledger timestamp when coverage begins. pub start_date: u64, + /// Ledger timestamp when coverage ends. pub end_date: u64, pub status: PolicyStatus, pub auto_renew: bool, + /// Timestamp of last premium payment (0 = not yet paid). pub last_payment: u64, } +/// A claim filed against an active policy. #[contracttype] #[derive(Clone, Debug)] pub struct InsuranceClaim { @@ -68,303 +86,281 @@ pub struct InsuranceClaim { pub policy_id: BytesN<32>, pub asset_id: BytesN<32>, pub claimant: Address, + /// Nature of the claim. pub claim_type: ClaimType, + /// Amount requested (stroops). pub amount: i128, pub status: ClaimStatus, + /// Ledger timestamp when the claim was filed. pub filed_at: u64, + /// Final approved payout amount (0 until approved). pub approved_amount: i128, } +/// Storage keys used by the insurance module. #[contracttype] #[derive(Clone)] pub enum DataKey { Policy(BytesN<32>), Claim(BytesN<32>), + /// List of policy_ids registered for an asset. AssetPolicies(BytesN<32>), + /// List of claim_ids filed for an asset. AssetClaims(BytesN<32>), } -/// Create a new insurance policy with date validation and asset indexing -pub fn create_policy(env: Env, policy: InsurancePolicy) -> Result<(), Error> { - // Validate coverage and deductible - if policy.coverage_amount <= 0 || policy.deductible >= policy.coverage_amount { - return Err(Error::InvalidPayment); - } +// ── Internal helpers ───────────────────────────────────────────────────────── - // Validate premium - if policy.premium <= 0 { - return Err(Error::InvalidPayment); - } +fn load_policy(env: &Env, policy_id: &BytesN<32>) -> Result { + env.storage() + .persistent() + .get(&DataKey::Policy(policy_id.clone())) + .ok_or(Error::PolicyNotFound) +} + +fn load_claim(env: &Env, claim_id: &BytesN<32>) -> Result { + env.storage() + .persistent() + .get(&DataKey::Claim(claim_id.clone())) + .ok_or(Error::ClaimNotFound) +} - // Validate dates: start_date must be before end_date - if policy.start_date >= policy.end_date { +fn save_policy(env: &Env, policy: &InsurancePolicy) { + env.storage() + .persistent() + .set(&DataKey::Policy(policy.policy_id.clone()), policy); +} + +fn save_claim(env: &Env, claim: &InsuranceClaim) { + env.storage() + .persistent() + .set(&DataKey::Claim(claim.claim_id.clone()), claim); +} + +// ── Policy operations ──────────────────────────────────────────────────────── + +/// Register a new insurance policy for an asset. +/// The insurer must authenticate. +pub fn create_policy(env: Env, policy: InsurancePolicy) -> Result<(), Error> { + policy.insurer.require_auth(); + + if policy.coverage_amount <= 0 || policy.deductible >= policy.coverage_amount { return Err(Error::InvalidPayment); } - - // Validate that start_date is not in the past (allow current timestamp) - let current_time = env.ledger().timestamp(); - if policy.start_date < current_time { + if policy.end_date <= policy.start_date { return Err(Error::InvalidPayment); } - let key = DataKey::Policy(policy.policy_id.clone()); let store = env.storage().persistent(); - // Check if policy already exists - if store.has(&key) { - return Err(Error::AssetAlreadyExists); + if store.has(&DataKey::Policy(policy.policy_id.clone())) { + return Err(Error::PolicyAlreadyExists); } - // Store the policy - store.set(&key, &policy); + store.set(&DataKey::Policy(policy.policy_id.clone()), &policy); - // Maintain asset index: add policy to asset's policy list + // Index by asset let mut list: Vec> = store .get(&DataKey::AssetPolicies(policy.asset_id.clone())) .unwrap_or_else(|| Vec::new(&env)); - list.push_back(policy.policy_id.clone()); store.set(&DataKey::AssetPolicies(policy.asset_id.clone()), &list); - // Append audit log - audit::append_audit_log( - &env, - &policy.asset_id, - String::from_str(&env, "INSURANCE_POLICY_CREATED"), - policy.insurer.clone(), - String::from_str(&env, "Insurance policy created"), + env.events().publish( + (symbol_short!("pol_cr"),), + (policy.policy_id, policy.asset_id, policy.insurer, env.ledger().timestamp()), ); - log!(&env, "PolicyCreated: {:?}", policy.policy_id); Ok(()) } -/// Cancel a policy (authorized by holder or insurer) +/// Cancel an active policy. +/// Either the holder or the insurer may cancel. pub fn cancel_policy(env: Env, policy_id: BytesN<32>, caller: Address) -> Result<(), Error> { - let store = env.storage().persistent(); - let key = DataKey::Policy(policy_id.clone()); + caller.require_auth(); - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy = load_policy(&env, &policy_id)?; - // Only holder or insurer can cancel - if caller != policy.holder && caller != policy.insurer { - return Err(Error::Unauthorized); + if policy.status != PolicyStatus::Active { + return Err(Error::PolicyNotActive); } - - // Validate status transition: only Active or Suspended policies can be cancelled - if policy.status != PolicyStatus::Active && policy.status != PolicyStatus::Suspended { + if caller != policy.holder && caller != policy.insurer { return Err(Error::Unauthorized); } policy.status = PolicyStatus::Cancelled; - store.set(&key, &policy); - - // Append audit log - audit::append_audit_log( - &env, - &policy.asset_id, - String::from_str(&env, "INSURANCE_POLICY_CANCELLED"), - caller, - String::from_str(&env, "Insurance policy cancelled"), + save_policy(&env, &policy); + + env.events().publish( + (symbol_short!("pol_cx"),), + (policy_id, caller, env.ledger().timestamp()), ); - log!(&env, "PolicyCancelled: {:?}", policy_id); Ok(()) } -/// Suspend a policy (insurer only) +/// Suspend an active policy. +/// Only the insurer may suspend (e.g. missed premium payment). pub fn suspend_policy(env: Env, policy_id: BytesN<32>, insurer: Address) -> Result<(), Error> { - let store = env.storage().persistent(); - let key = DataKey::Policy(policy_id.clone()); - - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + insurer.require_auth(); - // Only insurer can suspend - if insurer != policy.insurer { - return Err(Error::Unauthorized); - } + let mut policy = load_policy(&env, &policy_id)?; - // Validate status transition: only Active policies can be suspended if policy.status != PolicyStatus::Active { + return Err(Error::PolicyNotActive); + } + if insurer != policy.insurer { return Err(Error::Unauthorized); } policy.status = PolicyStatus::Suspended; - store.set(&key, &policy); + save_policy(&env, &policy); + + env.events().publish( + (symbol_short!("pol_sus"),), + (policy_id, insurer, env.ledger().timestamp()), + ); - log!(&env, "PolicySuspended: {:?}", policy_id); Ok(()) } -/// Expire a policy (permissionless, but requires end_date < current timestamp) +/// Mark a policy as expired once its end_date has passed. +/// Anyone may call this permissionlessly. pub fn expire_policy(env: Env, policy_id: BytesN<32>) -> Result<(), Error> { - let store = env.storage().persistent(); - let key = DataKey::Policy(policy_id.clone()); - - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy = load_policy(&env, &policy_id)?; - let current_time = env.ledger().timestamp(); - - // Require that end_date has passed - if policy.end_date >= current_time { - return Err(Error::Unauthorized); + if policy.status != PolicyStatus::Active { + return Err(Error::PolicyNotActive); } - - // Validate status transition: only Active or Suspended policies can expire - if policy.status != PolicyStatus::Active && policy.status != PolicyStatus::Suspended { - return Err(Error::Unauthorized); + if env.ledger().timestamp() < policy.end_date { + return Err(Error::PolicyNotExpired); } policy.status = PolicyStatus::Expired; - store.set(&key, &policy); + save_policy(&env, &policy); + + env.events().publish( + (symbol_short!("pol_ex"),), + (policy_id, env.ledger().timestamp()), + ); - log!(&env, "PolicyExpired: {:?}", policy_id); Ok(()) } -/// Renew a policy (insurer only) +/// Renew an expired or active policy with a new end date. +/// Only the insurer may renew. pub fn renew_policy( env: Env, policy_id: BytesN<32>, new_end_date: u64, - new_premium: i128, insurer: Address, ) -> Result<(), Error> { - let store = env.storage().persistent(); - let key = DataKey::Policy(policy_id.clone()); + insurer.require_auth(); - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy = load_policy(&env, &policy_id)?; - // Only insurer can renew if insurer != policy.insurer { return Err(Error::Unauthorized); } - - // Validate status transition: only Active or Expired policies can be renewed - if policy.status != PolicyStatus::Active && policy.status != PolicyStatus::Expired { - return Err(Error::Unauthorized); - } - - let current_time = env.ledger().timestamp(); - - // Validate new end date is in the future - if new_end_date <= current_time { - return Err(Error::InvalidPayment); + // Can renew Active or Expired policies (not Cancelled/Suspended). + if policy.status == PolicyStatus::Cancelled || policy.status == PolicyStatus::Suspended { + return Err(Error::PolicyNotActive); } - - // Validate new premium is positive - if new_premium <= 0 { + if new_end_date <= env.ledger().timestamp() { return Err(Error::InvalidPayment); } - // Update policy policy.end_date = new_end_date; - policy.premium = new_premium; policy.status = PolicyStatus::Active; - policy.last_payment = current_time; - - store.set(&key, &policy); + policy.last_payment = env.ledger().timestamp(); + save_policy(&env, &policy); - // Append audit log - audit::append_audit_log( - &env, - &policy.asset_id, - String::from_str(&env, "INSURANCE_POLICY_RENEWED"), - insurer, - String::from_str(&env, "Insurance policy renewed"), + env.events().publish( + (symbol_short!("pol_ren"),), + (policy_id, insurer, new_end_date), ); - log!(&env, "PolicyRenewed: {:?}", policy_id); Ok(()) } -/// Get all policies for a specific asset -pub fn get_asset_policies(env: Env, asset_id: BytesN<32>) -> Vec> { - env.storage() - .persistent() - .get(&DataKey::AssetPolicies(asset_id)) - .unwrap_or_else(|| Vec::new(&env)) -} +// ── Claim operations ───────────────────────────────────────────────────────── -/// File a new insurance claim against an active policy -pub fn file_insurance_claim(env: Env, claim: InsuranceClaim) -> Result<(), Error> { - // Claimant must authenticate +/// File a claim against an active policy. +/// The claimant must authenticate. +pub fn file_claim(env: Env, claim: InsuranceClaim) -> Result<(), Error> { claim.claimant.require_auth(); + if claim.amount <= 0 { + return Err(Error::InvalidPayment); + } + let store = env.storage().persistent(); - let policy_key = DataKey::Policy(claim.policy_id.clone()); + let policy: InsurancePolicy = store + .get(&DataKey::Policy(claim.policy_id.clone())) + .ok_or(Error::PolicyNotFound)?; - // Verify policy exists and is Active - let policy: InsurancePolicy = store.get(&policy_key).ok_or(Error::AssetNotFound)?; if policy.status != PolicyStatus::Active { - return Err(Error::Unauthorized); + return Err(Error::PolicyNotActive); } - - // Verify claim amount is positive - if claim.amount <= 0 { + if claim.amount > policy.coverage_amount { return Err(Error::InvalidPayment); } - // Verify claim doesn't already exist - let claim_key = DataKey::Claim(claim.claim_id.clone()); - if store.has(&claim_key) { - return Err(Error::AssetAlreadyExists); - } - - // Verify claim status is Submitted - if claim.status != ClaimStatus::Submitted { - return Err(Error::Unauthorized); + if store.has(&DataKey::Claim(claim.claim_id.clone())) { + return Err(Error::ClaimAlreadyExists); } - // Store the claim - store.set(&claim_key, &claim); + store.set(&DataKey::Claim(claim.claim_id.clone()), &claim); - // Index claim by asset_id + // Index claims by asset let mut asset_claims: Vec> = store .get(&DataKey::AssetClaims(claim.asset_id.clone())) .unwrap_or_else(|| Vec::new(&env)); asset_claims.push_back(claim.claim_id.clone()); store.set(&DataKey::AssetClaims(claim.asset_id.clone()), &asset_claims); - log!(&env, "ClaimFiled: {:?}", claim.claim_id); + env.events().publish( + (symbol_short!("clm_fil"),), + (claim.claim_id, claim.policy_id, claim.claimant, env.ledger().timestamp()), + ); + Ok(()) } -/// Move a claim from Submitted to UnderReview status -pub fn mark_insurance_claim_under_review( +/// Move a submitted claim into the UnderReview state. +/// Only the policy's insurer may do this. +pub fn mark_claim_under_review( env: Env, claim_id: BytesN<32>, insurer: Address, ) -> Result<(), Error> { insurer.require_auth(); - let store = env.storage().persistent(); - let claim_key = DataKey::Claim(claim_id.clone()); + let mut claim = load_claim(&env, &claim_id)?; - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; - - // Verify insurer is authorized - let policy: InsurancePolicy = store - .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; - if insurer != policy.insurer { - return Err(Error::Unauthorized); + if claim.status != ClaimStatus::Submitted { + return Err(Error::InvalidClaimStatus); } - // Validate status transition: only Submitted claims can move to UnderReview - if claim.status != ClaimStatus::Submitted { + let policy = load_policy(&env, &claim.policy_id)?; + if insurer != policy.insurer { return Err(Error::Unauthorized); } claim.status = ClaimStatus::UnderReview; - store.set(&claim_key, &claim); + save_claim(&env, &claim); + + env.events().publish( + (symbol_short!("clm_rev"),), + (claim_id, insurer, env.ledger().timestamp()), + ); - log!(&env, "ClaimUnderReview: {:?}", claim_id); Ok(()) } -/// Approve a claim and set the approved amount -pub fn approve_insurance_claim( +/// Approve a claim and set the approved payout amount. +/// Only the policy's insurer may approve. +pub fn approve_claim( env: Env, claim_id: BytesN<32>, insurer: Address, @@ -372,147 +368,140 @@ pub fn approve_insurance_claim( ) -> Result<(), Error> { insurer.require_auth(); - let store = env.storage().persistent(); - let claim_key = DataKey::Claim(claim_id.clone()); - - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim = load_claim(&env, &claim_id)?; - // Verify insurer is authorized - let policy: InsurancePolicy = store - .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; - if insurer != policy.insurer { - return Err(Error::Unauthorized); + if claim.status != ClaimStatus::Submitted && claim.status != ClaimStatus::UnderReview { + return Err(Error::InvalidClaimStatus); } - // Validate status transition: only UnderReview claims can be approved - if claim.status != ClaimStatus::UnderReview { + let policy = load_policy(&env, &claim.policy_id)?; + if insurer != policy.insurer { return Err(Error::Unauthorized); } - - // Validate approved amount - if approved_amount <= 0 { - return Err(Error::InvalidPayment); - } - - // Approved amount cannot exceed coverage amount - if approved_amount > policy.coverage_amount { + if approved_amount <= 0 || approved_amount > policy.coverage_amount { return Err(Error::InvalidPayment); } claim.status = ClaimStatus::Approved; claim.approved_amount = approved_amount; - store.set(&claim_key, &claim); + save_claim(&env, &claim); + + env.events().publish( + (symbol_short!("clm_app"),), + (claim_id, insurer, approved_amount), + ); - log!(&env, "ClaimApproved: {:?}", claim_id); Ok(()) } -/// Reject a claim (only Submitted or UnderReview claims can be rejected) -pub fn reject_insurance_claim( +/// Reject a submitted or under-review claim. +/// Only the policy's insurer may reject. +pub fn reject_claim( env: Env, claim_id: BytesN<32>, insurer: Address, ) -> Result<(), Error> { insurer.require_auth(); - let store = env.storage().persistent(); - let claim_key = DataKey::Claim(claim_id.clone()); + let mut claim = load_claim(&env, &claim_id)?; - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; - - // Verify insurer is authorized - let policy: InsurancePolicy = store - .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; - if insurer != policy.insurer { - return Err(Error::Unauthorized); + if claim.status != ClaimStatus::Submitted && claim.status != ClaimStatus::UnderReview { + return Err(Error::InvalidClaimStatus); } - // Validate status transition: only Submitted or UnderReview claims can be rejected - if claim.status != ClaimStatus::Submitted && claim.status != ClaimStatus::UnderReview { + let policy = load_policy(&env, &claim.policy_id)?; + if insurer != policy.insurer { return Err(Error::Unauthorized); } claim.status = ClaimStatus::Rejected; - store.set(&claim_key, &claim); + save_claim(&env, &claim); + + env.events().publish( + (symbol_short!("clm_rej"),), + (claim_id, insurer, env.ledger().timestamp()), + ); - log!(&env, "ClaimRejected: {:?}", claim_id); Ok(()) } -/// Allow claimant to dispute a rejected claim -pub fn dispute_insurance_claim( +/// Dispute a rejected claim. Only the original claimant may dispute. +pub fn dispute_claim( env: Env, claim_id: BytesN<32>, claimant: Address, ) -> Result<(), Error> { claimant.require_auth(); - let store = env.storage().persistent(); - let claim_key = DataKey::Claim(claim_id.clone()); - - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim = load_claim(&env, &claim_id)?; - // Verify claimant is authorized - if claimant != claim.claimant { - return Err(Error::Unauthorized); - } - - // Validate status transition: only Rejected claims can be disputed if claim.status != ClaimStatus::Rejected { + return Err(Error::InvalidClaimStatus); + } + if claimant != claim.claimant { return Err(Error::Unauthorized); } claim.status = ClaimStatus::Disputed; - store.set(&claim_key, &claim); + save_claim(&env, &claim); + + env.events().publish( + (symbol_short!("clm_dis"),), + (claim_id, claimant, env.ledger().timestamp()), + ); - log!(&env, "ClaimDisputed: {:?}", claim_id); Ok(()) } -/// Mark an approved claim as paid -pub fn pay_insurance_claim(env: Env, claim_id: BytesN<32>, insurer: Address) -> Result<(), Error> { +/// Mark an approved claim as paid. +/// The insurer confirms off-chain payment has been processed. +pub fn pay_claim(env: Env, claim_id: BytesN<32>, insurer: Address) -> Result<(), Error> { insurer.require_auth(); - let store = env.storage().persistent(); - let claim_key = DataKey::Claim(claim_id.clone()); - - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim = load_claim(&env, &claim_id)?; - // Verify insurer is authorized - let policy: InsurancePolicy = store - .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; - if insurer != policy.insurer { - return Err(Error::Unauthorized); + if claim.status != ClaimStatus::Approved { + return Err(Error::InvalidClaimStatus); } - // Validate status transition: only Approved claims can be paid - if claim.status != ClaimStatus::Approved { + let policy = load_policy(&env, &claim.policy_id)?; + if insurer != policy.insurer { return Err(Error::Unauthorized); } claim.status = ClaimStatus::Paid; - store.set(&claim_key, &claim); + save_claim(&env, &claim); + + env.events().publish( + (symbol_short!("clm_pay"),), + (claim_id, insurer, claim.approved_amount), + ); - log!(&env, "ClaimPaid: {:?}", claim_id); Ok(()) } -/// Get a specific insurance claim by ID -pub fn get_insurance_claim(env: Env, claim_id: BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey::Claim(claim_id)) +// ── Read operations ────────────────────────────────────────────────────────── + +pub fn get_policy(env: Env, policy_id: BytesN<32>) -> Result { + load_policy(&env, &policy_id) } -/// Get all claims for a specific asset -pub fn get_asset_insurance_claims(env: Env, asset_id: BytesN<32>) -> Vec> { +pub fn get_claim(env: Env, claim_id: BytesN<32>) -> Result { + load_claim(&env, &claim_id) +} + +/// Return all policy IDs registered for an asset. +pub fn get_asset_policies(env: Env, asset_id: BytesN<32>) -> Vec> { env.storage() .persistent() - .get(&DataKey::AssetClaims(asset_id)) + .get(&DataKey::AssetPolicies(asset_id)) .unwrap_or_else(|| Vec::new(&env)) } -pub fn get_policy(env: Env, policy_id: BytesN<32>) -> Option { - env.storage().persistent().get(&DataKey::Policy(policy_id)) +/// Return all claim IDs filed for an asset. +pub fn get_asset_claims(env: Env, asset_id: BytesN<32>) -> Vec> { + env.storage() + .persistent() + .get(&DataKey::AssetClaims(asset_id)) + .unwrap_or_else(|| Vec::new(&env)) } diff --git a/contracts/assetsup/src/lease.rs b/contracts/assetsup/src/lease.rs deleted file mode 100644 index 559284f1..00000000 --- a/contracts/assetsup/src/lease.rs +++ /dev/null @@ -1,226 +0,0 @@ -use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; - -use crate::error::Error; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum LeaseStatus { - Active, - Returned, - Cancelled, - Expired, -} - -#[contracttype] -#[derive(Clone, Debug)] -pub struct Lease { - pub lease_id: BytesN<32>, - pub asset_id: BytesN<32>, - pub lessor: Address, - pub lessee: Address, - pub start_timestamp: u64, - pub end_timestamp: u64, - pub rent_per_period: i128, - pub deposit: i128, - pub status: LeaseStatus, -} - -// ─── Storage Keys ───────────────────────────────────────────────────────────── - -#[contracttype] -pub enum DataKey { - Lease(BytesN<32>), - AssetActiveLease(BytesN<32>), - LesseeLeases(Address), -} - -// ─── Internal helpers ───────────────────────────────────────────────────────── - -fn load_lease(env: &Env, lease_id: &BytesN<32>) -> Result { - env.storage() - .persistent() - .get(&DataKey::Lease(lease_id.clone())) - .ok_or(Error::LeaseNotFound) -} - -fn save_lease(env: &Env, lease: &Lease) { - env.storage() - .persistent() - .set(&DataKey::Lease(lease.lease_id.clone()), lease); -} - -fn set_asset_active_lease(env: &Env, asset_id: &BytesN<32>, lease_id: &BytesN<32>) { - env.storage() - .persistent() - .set(&DataKey::AssetActiveLease(asset_id.clone()), lease_id); -} - -fn clear_asset_active_lease(env: &Env, asset_id: &BytesN<32>) { - env.storage() - .persistent() - .remove(&DataKey::AssetActiveLease(asset_id.clone())); -} - -fn get_active_lease_id(env: &Env, asset_id: &BytesN<32>) -> Option> { - env.storage() - .persistent() - .get(&DataKey::AssetActiveLease(asset_id.clone())) -} - -fn append_lessee_lease(env: &Env, lessee: &Address, lease_id: &BytesN<32>) { - let key = DataKey::LesseeLeases(lessee.clone()); - let mut ids: Vec> = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| Vec::new(env)); - ids.push_back(lease_id.clone()); - env.storage().persistent().set(&key, &ids); -} - -// ─── Public functions (called from lib.rs) ──────────────────────────────────── - -pub fn create_lease( - env: &Env, - asset_id: BytesN<32>, - lease_id: BytesN<32>, - lessor: Address, - lessee: Address, - start: u64, - end: u64, - rent: i128, - deposit: i128, -) -> Result<(), Error> { - if end <= start { - return Err(Error::InvalidTimestamps); - } - - if env - .storage() - .persistent() - .has(&DataKey::Lease(lease_id.clone())) - { - return Err(Error::LeaseAlreadyExists); - } - - // Asset must not already have an Active lease - if let Some(existing_id) = get_active_lease_id(env, &asset_id) { - let existing = load_lease(env, &existing_id)?; - if existing.status == LeaseStatus::Active { - return Err(Error::AssetAlreadyLeased); - } - } - - let lease = Lease { - lease_id: lease_id.clone(), - asset_id: asset_id.clone(), - lessor: lessor.clone(), - lessee: lessee.clone(), - start_timestamp: start, - end_timestamp: end, - rent_per_period: rent, - deposit, - status: LeaseStatus::Active, - }; - - save_lease(env, &lease); - set_asset_active_lease(env, &asset_id, &lease_id); - append_lessee_lease(env, &lessee, &lease_id); - - env.events().publish( - (soroban_sdk::symbol_short!("lease_new"),), - (lease_id, asset_id, lessor, lessee, env.ledger().timestamp()), - ); - - Ok(()) -} - -pub fn return_leased_asset(env: &Env, lease_id: BytesN<32>, caller: Address) -> Result<(), Error> { - let mut lease = load_lease(env, &lease_id)?; - - if caller != lease.lessor && caller != lease.lessee { - return Err(Error::Unauthorized); - } - - if lease.status != LeaseStatus::Active { - return Err(Error::InvalidLeaseStatus); - } - - lease.status = LeaseStatus::Returned; - save_lease(env, &lease); - clear_asset_active_lease(env, &lease.asset_id); - - env.events().publish( - (soroban_sdk::symbol_short!("lease_ret"),), - (lease_id, caller, env.ledger().timestamp()), - ); - - Ok(()) -} - -pub fn cancel_lease(env: &Env, lease_id: BytesN<32>, caller: Address) -> Result<(), Error> { - let mut lease = load_lease(env, &lease_id)?; - - if caller != lease.lessor { - return Err(Error::Unauthorized); - } - - if lease.status != LeaseStatus::Active { - return Err(Error::InvalidLeaseStatus); - } - - if env.ledger().timestamp() >= lease.start_timestamp { - return Err(Error::LeaseAlreadyStarted); - } - - lease.status = LeaseStatus::Cancelled; - save_lease(env, &lease); - clear_asset_active_lease(env, &lease.asset_id); - - env.events().publish( - (soroban_sdk::symbol_short!("lease_can"),), - (lease_id, caller, env.ledger().timestamp()), - ); - - Ok(()) -} - -pub fn expire_lease(env: &Env, lease_id: BytesN<32>) -> Result<(), Error> { - let mut lease = load_lease(env, &lease_id)?; - - if lease.status != LeaseStatus::Active { - return Err(Error::InvalidLeaseStatus); - } - - if env.ledger().timestamp() <= lease.end_timestamp { - return Err(Error::LeaseNotExpired); - } - - lease.status = LeaseStatus::Expired; - save_lease(env, &lease); - clear_asset_active_lease(env, &lease.asset_id); - - env.events().publish( - (soroban_sdk::symbol_short!("lease_exp"),), - (lease_id, env.ledger().timestamp()), - ); - - Ok(()) -} - -pub fn get_lease(env: &Env, lease_id: BytesN<32>) -> Result { - load_lease(env, &lease_id) -} - -pub fn get_asset_active_lease(env: &Env, asset_id: BytesN<32>) -> Option { - get_active_lease_id(env, &asset_id).and_then(|id| load_lease(env, &id).ok()) -} - -pub fn get_lessee_leases(env: &Env, lessee: Address) -> Vec> { - env.storage() - .persistent() - .get(&DataKey::LesseeLeases(lessee)) - .unwrap_or_else(|| Vec::new(env)) -} diff --git a/contracts/assetsup/src/leasing.rs b/contracts/assetsup/src/leasing.rs new file mode 100644 index 00000000..787a0d9c --- /dev/null +++ b/contracts/assetsup/src/leasing.rs @@ -0,0 +1,246 @@ +#![allow(clippy::too_many_arguments)] + +use soroban_sdk::{Address, BytesN, Env, Vec, contracttype, symbol_short}; + +use crate::error::Error; + +// ── Types ────────────────────────────────────────────────────────────────── + +/// Lifecycle states of a lease. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LeaseStatus { + /// Lease is currently in force. + Active, + /// Lease ran past its end_timestamp without being returned. + Expired, + /// Lessee (or lessor) returned the asset before expiry. + Returned, + /// Lessor cancelled before the lease start date. + Cancelled, +} + +/// On-chain record of an asset lease agreement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Lease { + /// Unique identifier for this lease. + pub lease_id: BytesN<32>, + /// The asset being leased (references asset registry). + pub asset_id: BytesN<32>, + /// Owner of the asset who is renting it out. + pub lessor: Address, + /// Party renting the asset. + pub lessee: Address, + /// Ledger timestamp when the lease begins. + pub start_timestamp: u64, + /// Ledger timestamp when the lease ends. + pub end_timestamp: u64, + /// Rent owed per agreed period (in stroops / smallest unit). + pub rent_per_period: i128, + /// Refundable security deposit (in stroops). + pub deposit: i128, + /// Current lifecycle state. + pub status: LeaseStatus, + /// Ledger timestamp when this record was created. + pub created_at: u64, + /// Ledger timestamp when the asset was returned (0 if not yet returned). + pub returned_at: u64, +} + +/// Storage keys used by the leasing module. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + /// Full lease record by lease_id. + Lease(BytesN<32>), + /// Points to the active lease_id for an asset (absent when no active lease). + AssetActiveLease(BytesN<32>), + /// List of all lease_ids held by a given lessee. + LesseeLeases(Address), +} + +// ── Write operations ──────────────────────────────────────────────────────── + +/// Create a new lease for an asset. +/// +/// Auth (`lessor.require_auth()`) is handled by the public contract wrapper in +/// `lib.rs`; this function only enforces business rules. +pub fn create_lease( + env: &Env, + asset_id: BytesN<32>, + lease_id: BytesN<32>, + lessor: Address, + lessee: Address, + start_timestamp: u64, + end_timestamp: u64, + rent_per_period: i128, + deposit: i128, +) -> Result { + if end_timestamp <= start_timestamp { + return Err(Error::InvalidLeasePeriod); + } + if rent_per_period <= 0 { + return Err(Error::InvalidRentAmount); + } + + let store = env.storage().persistent(); + + if store.has(&DataKey::Lease(lease_id.clone())) { + return Err(Error::LeaseAlreadyExists); + } + if store.has(&DataKey::AssetActiveLease(asset_id.clone())) { + return Err(Error::AssetAlreadyLeased); + } + + let lease = Lease { + lease_id: lease_id.clone(), + asset_id: asset_id.clone(), + lessor, + lessee: lessee.clone(), + start_timestamp, + end_timestamp, + rent_per_period, + deposit, + status: LeaseStatus::Active, + created_at: env.ledger().timestamp(), + returned_at: 0, + }; + + store.set(&DataKey::Lease(lease_id.clone()), &lease); + store.set(&DataKey::AssetActiveLease(asset_id.clone()), &lease_id); + + // Append to lessee's history list. + let lessee_key = DataKey::LesseeLeases(lessee.clone()); + let mut lessee_leases: Vec> = store + .get(&lessee_key) + .unwrap_or_else(|| Vec::new(env)); + lessee_leases.push_back(lease_id.clone()); + store.set(&lessee_key, &lessee_leases); + + env.events().publish( + (symbol_short!("lease_cr"),), + (lease_id, asset_id, lessee, env.ledger().timestamp()), + ); + + Ok(lease) +} + +/// Mark a lease as returned early. +/// `caller` must be either the lessor or the lessee. +/// Auth is enforced by the lib.rs wrapper. +pub fn return_asset( + env: &Env, + lease_id: BytesN<32>, + caller: Address, +) -> Result<(), Error> { + let store = env.storage().persistent(); + let key = DataKey::Lease(lease_id.clone()); + + let mut lease: Lease = store.get(&key).ok_or(Error::LeaseNotFound)?; + + if lease.status != LeaseStatus::Active { + return Err(Error::LeaseNotActive); + } + if caller != lease.lessor && caller != lease.lessee { + return Err(Error::Unauthorized); + } + + lease.status = LeaseStatus::Returned; + lease.returned_at = env.ledger().timestamp(); + store.set(&key, &lease); + store.remove(&DataKey::AssetActiveLease(lease.asset_id.clone())); + + env.events().publish( + (symbol_short!("lease_rt"),), + (lease_id, caller, env.ledger().timestamp()), + ); + + Ok(()) +} + +/// Cancel a lease that has not yet started. +/// Only the lessor may cancel; auth is enforced by the lib.rs wrapper. +pub fn cancel_lease( + env: &Env, + lease_id: BytesN<32>, + caller: Address, +) -> Result<(), Error> { + let store = env.storage().persistent(); + let key = DataKey::Lease(lease_id.clone()); + + let mut lease: Lease = store.get(&key).ok_or(Error::LeaseNotFound)?; + + if lease.status != LeaseStatus::Active { + return Err(Error::LeaseNotActive); + } + if caller != lease.lessor { + return Err(Error::Unauthorized); + } + if env.ledger().timestamp() >= lease.start_timestamp { + return Err(Error::LeaseAlreadyStarted); + } + + lease.status = LeaseStatus::Cancelled; + store.set(&key, &lease); + store.remove(&DataKey::AssetActiveLease(lease.asset_id.clone())); + + env.events().publish( + (symbol_short!("lease_cx"),), + (lease_id, caller, env.ledger().timestamp()), + ); + + Ok(()) +} + +/// Settle a lease as expired once its `end_timestamp` has passed. +/// Anyone may call this (no auth required). +pub fn expire_lease(env: &Env, lease_id: BytesN<32>) -> Result<(), Error> { + let store = env.storage().persistent(); + let key = DataKey::Lease(lease_id.clone()); + + let mut lease: Lease = store.get(&key).ok_or(Error::LeaseNotFound)?; + + if lease.status != LeaseStatus::Active { + return Err(Error::LeaseNotActive); + } + if env.ledger().timestamp() < lease.end_timestamp { + return Err(Error::LeaseNotExpired); + } + + lease.status = LeaseStatus::Expired; + store.set(&key, &lease); + store.remove(&DataKey::AssetActiveLease(lease.asset_id.clone())); + + env.events().publish( + (symbol_short!("lease_ex"),), + (lease_id, env.ledger().timestamp()), + ); + + Ok(()) +} + +// ── Read operations ───────────────────────────────────────────────────────── + +/// Fetch a lease record by its ID. +pub fn get_lease(env: &Env, lease_id: BytesN<32>) -> Result { + env.storage() + .persistent() + .get(&DataKey::Lease(lease_id)) + .ok_or(Error::LeaseNotFound) +} + +/// Return the active lease ID for an asset, or `None` if it is not leased. +pub fn get_asset_active_lease(env: &Env, asset_id: BytesN<32>) -> Option> { + env.storage() + .persistent() + .get(&DataKey::AssetActiveLease(asset_id)) +} + +/// Return all lease IDs associated with a lessee. +pub fn get_lessee_leases(env: &Env, lessee: Address) -> Vec> { + env.storage() + .persistent() + .get(&DataKey::LesseeLeases(lessee)) + .unwrap_or_else(|| Vec::new(env)) +} diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index 7c220273..f24663b0 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -1,23 +1,20 @@ #![no_std] -#![allow(clippy::too_many_arguments)] -use crate::error::{handle_error, Error}; -use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec, -}; +use crate::error::{Error, handle_error}; +use soroban_sdk::{Address, BigInt, BytesN, Env, String, Vec, contract, contractimpl, contracttype, symbol_short}; pub(crate) mod asset; pub(crate) mod audit; pub(crate) mod branch; -pub(crate) mod detokenization; -pub(crate) mod dividends; pub(crate) mod error; +pub(crate) mod types; pub(crate) mod insurance; -pub(crate) mod lease; pub(crate) mod tokenization; -pub(crate) mod transfer_restrictions; -pub(crate) mod types; +pub(crate) mod dividends; pub(crate) mod voting; +pub(crate) mod transfer_restrictions; +pub(crate) mod detokenization; +pub(crate) mod leasing; #[cfg(test)] mod tests; @@ -53,9 +50,7 @@ impl AssetUpContract { // Initialize contract state env.storage().persistent().set(&DataKey::Paused, &false); - env.storage() - .persistent() - .set(&DataKey::TotalAssetCount, &0u64); + env.storage().persistent().set(&DataKey::TotalAssetCount, &0u64); // Set contract metadata let metadata = ContractMetadata { @@ -64,14 +59,10 @@ impl AssetUpContract { description: String::from_str(&env, "Professional asset registry smart contract"), created_at: env.ledger().timestamp(), }; - env.storage() - .persistent() - .set(&DataKey::ContractMetadata, &metadata); + env.storage().persistent().set(&DataKey::ContractMetadata, &metadata); // Add admin as first authorized registrar - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(admin.clone()), &true); + env.storage().persistent().set(&DataKey::AuthorizedRegistrar(admin.clone()), &true); Ok(()) } @@ -87,19 +78,11 @@ impl AssetUpContract { } pub fn is_paused(env: Env) -> Result { - Ok(env - .storage() - .persistent() - .get(&DataKey::Paused) - .unwrap_or(false)) + Ok(env.storage().persistent().get(&DataKey::Paused).unwrap_or(false)) } pub fn get_total_asset_count(env: Env) -> Result { - Ok(env - .storage() - .persistent() - .get(&DataKey::TotalAssetCount) - .unwrap_or(0u64)) + Ok(env.storage().persistent().get(&DataKey::TotalAssetCount).unwrap_or(0u64)) } pub fn get_contract_metadata(env: Env) -> Result { @@ -111,11 +94,7 @@ impl AssetUpContract { } pub fn is_authorized_registrar(env: Env, address: Address) -> Result { - Ok(env - .storage() - .persistent() - .get(&DataKey::AuthorizedRegistrar(address)) - .unwrap_or(false)) + Ok(env.storage().persistent().get(&DataKey::AuthorizedRegistrar(address)).unwrap_or(false)) } // Asset functions @@ -146,26 +125,14 @@ impl AssetUpContract { // Update owner registry let owner_key = asset::DataKey::OwnerRegistry(asset.owner.clone()); - let mut owner_assets: Vec> = - store.get(&owner_key).unwrap_or_else(|| Vec::new(&env)); + let mut owner_assets: Vec> = store.get(&owner_key).unwrap_or_else(|| Vec::new(&env)); owner_assets.push_back(asset.id.clone()); store.set(&owner_key, &owner_assets); // Update total asset count let mut total_count = Self::get_total_asset_count(env.clone())?; total_count += 1; - env.storage() - .persistent() - .set(&DataKey::TotalAssetCount, &total_count); - - // Append audit log - audit::append_audit_log( - &env, - &asset.id, - String::from_str(&env, "ASSET_REGISTERED"), - caller.clone(), - String::from_str(&env, "Asset registered by authorized registrar"), - ); + env.storage().persistent().set(&DataKey::TotalAssetCount, &total_count); // Emit event env.events().publish( @@ -193,10 +160,7 @@ impl AssetUpContract { } // Validate owner address is not zero address - let zero_address = Address::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); + let zero_address = Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); if asset.owner == zero_address { return Err(Error::InvalidOwnerAddress); } @@ -257,15 +221,6 @@ impl AssetUpContract { store.set(&key, &asset); - // Append audit log - audit::append_audit_log( - &env, - &asset_id, - String::from_str(&env, "METADATA_UPDATED"), - caller.clone(), - String::from_str(&env, "Asset metadata updated"), - ); - // Emit event env.events().publish( (symbol_short!("asset_upd"),), @@ -287,10 +242,7 @@ impl AssetUpContract { } // Validate new owner is not zero address - let zero_address = Address::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); + let zero_address = Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); if new_owner == zero_address { return Err(Error::InvalidOwnerAddress); } @@ -312,8 +264,7 @@ impl AssetUpContract { // Remove asset from old owner's registry let old_owner_key = asset::DataKey::OwnerRegistry(old_owner.clone()); - let mut old_owner_assets: Vec> = - store.get(&old_owner_key).unwrap_or_else(|| Vec::new(&env)); + let mut old_owner_assets: Vec> = store.get(&old_owner_key).unwrap_or_else(|| Vec::new(&env)); if let Some(index) = old_owner_assets.iter().position(|x| x == asset_id) { old_owner_assets.remove(index as u32); } @@ -321,8 +272,7 @@ impl AssetUpContract { // Add asset to new owner's registry let new_owner_key = asset::DataKey::OwnerRegistry(new_owner.clone()); - let mut new_owner_assets: Vec> = - store.get(&new_owner_key).unwrap_or_else(|| Vec::new(&env)); + let mut new_owner_assets: Vec> = store.get(&new_owner_key).unwrap_or_else(|| Vec::new(&env)); new_owner_assets.push_back(asset_id.clone()); store.set(&new_owner_key, &new_owner_assets); @@ -332,15 +282,6 @@ impl AssetUpContract { asset.status = AssetStatus::Transferred; store.set(&key, &asset); - // Append audit log - audit::append_audit_log( - &env, - &asset_id, - String::from_str(&env, "OWNERSHIP_TRANSFERRED"), - caller.clone(), - String::from_str(&env, "Asset ownership transferred to new owner"), - ); - // Emit event env.events().publish( (symbol_short!("asset_tx"),), @@ -373,15 +314,6 @@ impl AssetUpContract { asset.status = AssetStatus::Retired; store.set(&key, &asset); - // Append audit log - audit::append_audit_log( - &env, - &asset_id, - String::from_str(&env, "ASSET_RETIRED"), - caller.clone(), - String::from_str(&env, "Asset retired from active use"), - ); - // Emit event env.events().publish( (symbol_short!("asset_ret"),), @@ -426,10 +358,7 @@ impl AssetUpContract { }) } - pub fn batch_get_asset_info( - env: Env, - asset_ids: Vec>, - ) -> Result, Error> { + pub fn batch_get_asset_info(env: Env, asset_ids: Vec>) -> Result, Error> { let mut results = Vec::new(&env); for asset_id in asset_ids.iter() { match Self::get_asset_info(env.clone(), asset_id.clone()) { @@ -446,10 +375,7 @@ impl AssetUpContract { current_admin.require_auth(); // Validate new admin is not zero address - let zero_address = Address::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); + let zero_address = Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); if new_admin == zero_address { return Err(Error::InvalidOwnerAddress); } @@ -458,12 +384,8 @@ impl AssetUpContract { env.storage().persistent().set(&DataKey::Admin, &new_admin); // Remove old admin from authorized registrars and add new admin - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(old_admin.clone()), &false); - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(new_admin.clone()), &true); + env.storage().persistent().set(&DataKey::AuthorizedRegistrar(old_admin.clone()), &false); + env.storage().persistent().set(&DataKey::AuthorizedRegistrar(new_admin.clone()), &true); // Emit event env.events().publish( @@ -478,9 +400,7 @@ impl AssetUpContract { let admin = Self::get_admin(env.clone())?; admin.require_auth(); - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &true); + env.storage().persistent().set(&DataKey::AuthorizedRegistrar(registrar), &true); Ok(()) } @@ -493,9 +413,7 @@ impl AssetUpContract { return Err(Error::Unauthorized); } - env.storage() - .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &false); + env.storage().persistent().set(&DataKey::AuthorizedRegistrar(registrar), &false); Ok(()) } @@ -545,9 +463,9 @@ impl AssetUpContract { env: Env, asset_id: u64, symbol: String, - total_supply: i128, + total_supply: BigInt, decimals: u32, - min_voting_threshold: i128, + min_voting_threshold: BigInt, tokenizer: Address, name: String, description: String, @@ -582,7 +500,7 @@ impl AssetUpContract { pub fn mint_tokens( env: Env, asset_id: u64, - amount: i128, + amount: BigInt, minter: Address, ) -> Result { minter.require_auth(); @@ -593,7 +511,7 @@ impl AssetUpContract { pub fn burn_tokens( env: Env, asset_id: u64, - amount: i128, + amount: BigInt, burner: Address, ) -> Result { burner.require_auth(); @@ -606,7 +524,7 @@ impl AssetUpContract { asset_id: u64, from: Address, to: Address, - amount: i128, + amount: BigInt, ) -> Result<(), Error> { from.require_auth(); @@ -617,7 +535,7 @@ impl AssetUpContract { } /// Get token balance for an address - pub fn get_token_balance(env: Env, asset_id: u64, holder: Address) -> Result { + pub fn get_token_balance(env: Env, asset_id: u64, holder: Address) -> Result { tokenization::get_token_balance(&env, asset_id, holder) } @@ -626,16 +544,14 @@ impl AssetUpContract { tokenization::get_token_holders(&env, asset_id) } - /// Lock tokens until timestamp (only the asset tokenizer can call this) + /// Lock tokens until timestamp pub fn lock_tokens( env: Env, asset_id: u64, holder: Address, until_timestamp: u64, - caller: Address, ) -> Result<(), Error> { - caller.require_auth(); - tokenization::lock_tokens(&env, asset_id, holder, until_timestamp, caller) + tokenization::lock_tokens(&env, asset_id, holder, until_timestamp) } /// Unlock tokens @@ -643,17 +559,12 @@ impl AssetUpContract { tokenization::unlock_tokens(&env, asset_id, holder) } - /// Check if a holder's tokens are currently locked - pub fn is_tokens_locked(env: Env, asset_id: u64, holder: Address) -> bool { - tokenization::is_tokens_locked(&env, asset_id, holder) - } - /// Get ownership percentage for a holder (in basis points) pub fn get_ownership_percentage( env: Env, asset_id: u64, holder: Address, - ) -> Result { + ) -> Result { tokenization::calculate_ownership_percentage(&env, asset_id, holder) } @@ -663,7 +574,11 @@ impl AssetUpContract { } /// Update asset valuation - pub fn update_valuation(env: Env, asset_id: u64, new_valuation: i128) -> Result<(), Error> { + pub fn update_valuation( + env: Env, + asset_id: u64, + new_valuation: BigInt, + ) -> Result<(), Error> { tokenization::update_valuation(&env, asset_id, new_valuation) } @@ -672,12 +587,16 @@ impl AssetUpContract { // ===================== /// Distribute dividends proportionally to all holders - pub fn distribute_dividends(env: Env, asset_id: u64, total_amount: i128) -> Result<(), Error> { + pub fn distribute_dividends( + env: Env, + asset_id: u64, + total_amount: BigInt, + ) -> Result<(), Error> { dividends::distribute_dividends(&env, asset_id, total_amount) } /// Claim unclaimed dividends - pub fn claim_dividends(env: Env, asset_id: u64, holder: Address) -> Result { + pub fn claim_dividends(env: Env, asset_id: u64, holder: Address) -> Result { holder.require_auth(); dividends::claim_dividends(&env, asset_id, holder) } @@ -687,7 +606,7 @@ impl AssetUpContract { env: Env, asset_id: u64, holder: Address, - ) -> Result { + ) -> Result { dividends::get_unclaimed_dividends(&env, asset_id, holder) } @@ -717,7 +636,11 @@ impl AssetUpContract { } /// Get vote tally for a proposal - pub fn get_vote_tally(env: Env, asset_id: u64, proposal_id: u64) -> Result { + pub fn get_vote_tally( + env: Env, + asset_id: u64, + proposal_id: u64, + ) -> Result { voting::get_vote_tally(&env, asset_id, proposal_id) } @@ -732,7 +655,11 @@ impl AssetUpContract { } /// Check if proposal passed - pub fn proposal_passed(env: Env, asset_id: u64, proposal_id: u64) -> Result { + pub fn proposal_passed( + env: Env, + asset_id: u64, + proposal_id: u64, + ) -> Result { voting::proposal_passed(&env, asset_id, proposal_id) } @@ -762,7 +689,11 @@ impl AssetUpContract { } /// Remove address from whitelist - pub fn remove_from_whitelist(env: Env, asset_id: u64, address: Address) -> Result<(), Error> { + pub fn remove_from_whitelist( + env: Env, + asset_id: u64, + address: Address, + ) -> Result<(), Error> { transfer_restrictions::remove_from_whitelist(&env, asset_id, address) } @@ -791,7 +722,11 @@ impl AssetUpContract { } /// Execute detokenization (if vote passed) - pub fn execute_detokenization(env: Env, asset_id: u64, proposal_id: u64) -> Result<(), Error> { + pub fn execute_detokenization( + env: Env, + asset_id: u64, + proposal_id: u64, + ) -> Result<(), Error> { detokenization::execute_detokenization(&env, asset_id, proposal_id) } @@ -809,119 +744,275 @@ impl AssetUpContract { } // ===================== - // Insurance Policy Management + // Insurance Functions // ===================== - /// Create a new insurance policy + /// Register a new insurance policy for an asset. + /// The insurer must authenticate. pub fn create_insurance_policy( env: Env, - policy: insurance::InsurancePolicy, + policy_id: BytesN<32>, + holder: Address, + insurer: Address, + asset_id: BytesN<32>, + policy_type: insurance::PolicyType, + coverage_amount: i128, + deductible: i128, + premium: i128, + start_date: u64, + end_date: u64, + auto_renew: bool, ) -> Result<(), Error> { - policy.insurer.require_auth(); + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + let policy = insurance::InsurancePolicy { + policy_id, + holder, + insurer, + asset_id, + policy_type, + coverage_amount, + deductible, + premium, + start_date, + end_date, + status: insurance::PolicyStatus::Active, + auto_renew, + last_payment: 0, + }; insurance::create_policy(env, policy) } - /// Cancel a policy (holder or insurer) + /// Cancel an active policy. Caller must be the holder or insurer. pub fn cancel_insurance_policy( env: Env, policy_id: BytesN<32>, caller: Address, ) -> Result<(), Error> { - caller.require_auth(); + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } insurance::cancel_policy(env, policy_id, caller) } - /// Suspend a policy (insurer only) + /// Suspend an active policy. Only the insurer may suspend. pub fn suspend_insurance_policy( env: Env, policy_id: BytesN<32>, insurer: Address, ) -> Result<(), Error> { - insurer.require_auth(); + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } insurance::suspend_policy(env, policy_id, insurer) } - /// Expire a policy (permissionless) + /// Mark a policy as expired (permissionless once end_date passes). pub fn expire_insurance_policy(env: Env, policy_id: BytesN<32>) -> Result<(), Error> { insurance::expire_policy(env, policy_id) } - /// Renew a policy (insurer only) + /// Renew a policy with a new end date. Only the insurer may renew. pub fn renew_insurance_policy( env: Env, policy_id: BytesN<32>, new_end_date: u64, - new_premium: i128, insurer: Address, ) -> Result<(), Error> { - insurer.require_auth(); - insurance::renew_policy(env, policy_id, new_end_date, new_premium, insurer) + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + insurance::renew_policy(env, policy_id, new_end_date, insurer) } - /// Get a specific policy + /// File a claim against an active policy. Claimant must authenticate. + pub fn file_insurance_claim( + env: Env, + claim_id: BytesN<32>, + policy_id: BytesN<32>, + asset_id: BytesN<32>, + claimant: Address, + claim_type: insurance::ClaimType, + amount: i128, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + let claim = insurance::InsuranceClaim { + claim_id, + policy_id, + asset_id, + claimant, + claim_type, + amount, + status: insurance::ClaimStatus::Submitted, + filed_at: env.ledger().timestamp(), + approved_amount: 0, + }; + insurance::file_claim(env, claim) + } + + /// Move a submitted claim into UnderReview. Only the insurer may do this. + pub fn mark_insurance_claim_under_review( + env: Env, + claim_id: BytesN<32>, + insurer: Address, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + insurance::mark_claim_under_review(env, claim_id, insurer) + } + + /// Approve a claim and set its payout amount. Only the insurer may approve. + pub fn approve_insurance_claim( + env: Env, + claim_id: BytesN<32>, + insurer: Address, + approved_amount: i128, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + insurance::approve_claim(env, claim_id, insurer, approved_amount) + } + + /// Reject a submitted or under-review claim. Only the insurer may reject. + pub fn reject_insurance_claim( + env: Env, + claim_id: BytesN<32>, + insurer: Address, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + insurance::reject_claim(env, claim_id, insurer) + } + + /// Dispute a rejected claim. Only the original claimant may dispute. + pub fn dispute_insurance_claim( + env: Env, + claim_id: BytesN<32>, + claimant: Address, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + insurance::dispute_claim(env, claim_id, claimant) + } + + /// Mark an approved claim as paid. Only the insurer may confirm payment. + pub fn pay_insurance_claim( + env: Env, + claim_id: BytesN<32>, + insurer: Address, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + insurance::pay_claim(env, claim_id, insurer) + } + + /// Fetch a policy by ID. pub fn get_insurance_policy( env: Env, policy_id: BytesN<32>, - ) -> Option { + ) -> Result { insurance::get_policy(env, policy_id) } - /// Get all policies for an asset + /// Fetch a claim by ID. + pub fn get_insurance_claim( + env: Env, + claim_id: BytesN<32>, + ) -> Result { + insurance::get_claim(env, claim_id) + } + + /// Return all policy IDs registered for an asset. pub fn get_asset_insurance_policies(env: Env, asset_id: BytesN<32>) -> Vec> { insurance::get_asset_policies(env, asset_id) } - /// Create a new lease. Lessor authenticates; asset must not already be actively leased. + /// Return all claim IDs filed for an asset. + pub fn get_asset_insurance_claims(env: Env, asset_id: BytesN<32>) -> Vec> { + insurance::get_asset_claims(env, asset_id) + } + + // ===================== + // Leasing Functions + // ===================== + + /// Create a lease agreement for an asset. + /// The `lessor` must authenticate; the asset must not already be leased. pub fn create_lease( env: Env, asset_id: BytesN<32>, lease_id: BytesN<32>, lessor: Address, lessee: Address, - start: u64, - end: u64, - rent: i128, + start_timestamp: u64, + end_timestamp: u64, + rent_per_period: i128, deposit: i128, - ) -> Result<(), Error> { + ) -> Result { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } lessor.require_auth(); - lease::create_lease( - &env, asset_id, lease_id, lessor, lessee, start, end, rent, deposit, + leasing::create_lease( + &env, asset_id, lease_id, lessor, lessee, + start_timestamp, end_timestamp, rent_per_period, deposit, ) } - /// Return a leased asset. Callable by lessor or lessee. + /// Return a leased asset before expiry. + /// Caller must be the lessor or lessee. pub fn return_leased_asset( env: Env, lease_id: BytesN<32>, caller: Address, ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } caller.require_auth(); - lease::return_leased_asset(&env, lease_id, caller) + leasing::return_asset(&env, lease_id, caller) } - /// Cancel a lease before it starts. Lessor only. - pub fn cancel_lease(env: Env, lease_id: BytesN<32>, caller: Address) -> Result<(), Error> { + /// Cancel a lease before it has started. + /// Only the lessor may cancel. + pub fn cancel_lease( + env: Env, + lease_id: BytesN<32>, + caller: Address, + ) -> Result<(), Error> { + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } caller.require_auth(); - lease::cancel_lease(&env, lease_id, caller) + leasing::cancel_lease(&env, lease_id, caller) } - /// Expire a lease permissionlessly once end_timestamp has passed. + /// Settle a lease as expired once its end_timestamp has passed. + /// No auth required — anyone can trigger expiry. pub fn expire_lease(env: Env, lease_id: BytesN<32>) -> Result<(), Error> { - lease::expire_lease(&env, lease_id) + leasing::expire_lease(&env, lease_id) } - /// Fetch a lease by ID. - pub fn get_lease(env: Env, lease_id: BytesN<32>) -> Result { - lease::get_lease(&env, lease_id) + /// Fetch a lease record by ID. + pub fn get_lease(env: Env, lease_id: BytesN<32>) -> Result { + leasing::get_lease(&env, lease_id) } - /// Return the active lease for an asset, or None. - pub fn get_asset_active_lease(env: Env, asset_id: BytesN<32>) -> Option { - lease::get_asset_active_lease(&env, asset_id) + /// Return the active lease ID for an asset, or None if it is not leased. + pub fn get_asset_active_lease(env: Env, asset_id: BytesN<32>) -> Option> { + leasing::get_asset_active_lease(&env, asset_id) } - /// Return all lease IDs for a given lessee. + /// Return all lease IDs ever created by a lessee. pub fn get_lessee_leases(env: Env, lessee: Address) -> Vec> { - lease::get_lessee_leases(&env, lessee) + leasing::get_lessee_leases(&env, lessee) } } diff --git a/contracts/assetsup/src/tests/admin.rs b/contracts/assetsup/src/tests/admin.rs deleted file mode 100644 index e6cbd37e..00000000 --- a/contracts/assetsup/src/tests/admin.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::tests::helpers::*; -use soroban_sdk::{testutils::Address as _, Address}; - -#[test] -fn test_update_admin_success() { - let env = create_env(); - let (admin, new_admin, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - client.update_admin(&new_admin); - - // Verify admin was updated - assert_eq!(client.get_admin(), new_admin); - - // Verify new admin is authorized registrar - assert!(client.is_authorized_registrar(&new_admin)); - - // Verify old admin is no longer authorized registrar - assert!(!client.is_authorized_registrar(&admin)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #39)")] -fn test_update_admin_zero_address() { - let env = create_env(); - let admin = Address::generate(&env); - let client = initialize_contract(&env, &admin); - - let zero_address = Address::from_string(&soroban_sdk::String::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )); - - env.mock_all_auths(); - - // Should panic with InvalidOwnerAddress error - client.update_admin(&zero_address); -} - -#[test] -fn test_pause_unpause_contract() { - let env = create_env(); - let admin = Address::generate(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Initially not paused - assert!(!client.is_paused()); - - // Pause contract - client.pause_contract(); - assert!(client.is_paused()); - - // Unpause contract - client.unpause_contract(); - assert!(!client.is_paused()); -} - -#[test] -fn test_add_authorized_registrar() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Initially user1 is not authorized - assert!(!client.is_authorized_registrar(&user1)); - - // Add user1 as authorized registrar - client.add_authorized_registrar(&user1); - assert!(client.is_authorized_registrar(&user1)); -} - -#[test] -fn test_remove_authorized_registrar() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Add user1 as authorized registrar - client.add_authorized_registrar(&user1); - assert!(client.is_authorized_registrar(&user1)); - - // Remove user1 from authorized registrars - client.remove_authorized_registrar(&user1); - assert!(!client.is_authorized_registrar(&user1)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_remove_admin_from_registrars() { - let env = create_env(); - let admin = Address::generate(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Should panic with Unauthorized error - cannot remove admin - client.remove_authorized_registrar(&admin); -} diff --git a/contracts/assetsup/src/tests/asset.rs b/contracts/assetsup/src/tests/asset.rs deleted file mode 100644 index 79872cb1..00000000 --- a/contracts/assetsup/src/tests/asset.rs +++ /dev/null @@ -1,340 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::AssetStatus; -use soroban_sdk::{testutils::Address as _, Address, String, Vec}; - -#[test] -fn test_register_asset_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // Verify asset was registered - let stored_asset = client.get_asset(&asset_id); - assert_eq!(stored_asset.id, asset_id); - assert_eq!(stored_asset.owner, user1); - - // Verify total asset count increased - assert_eq!(client.get_total_asset_count(), 1); - - // Verify asset is in owner's registry - let owner_assets = client.get_assets_by_owner(&user1); - assert_eq!(owner_assets.len(), 1); - assert_eq!(owner_assets.get(0).unwrap(), asset_id); -} - -#[test] -#[should_panic(expected = "Error(Contract, #3)")] -fn test_register_asset_already_exists() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // Try to register same asset again - should panic with AssetAlreadyExists - client.register_asset(&asset, &admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #34)")] -fn test_register_asset_when_paused() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Pause contract - client.pause_contract(); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id); - - // Should panic with ContractPaused error - client.register_asset(&asset, &admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_register_asset_unauthorized() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id); - - env.mock_all_auths(); - - // user2 is not authorized registrar - should panic with Unauthorized - client.register_asset(&asset, &user2); -} - -#[test] -#[should_panic(expected = "Error(Contract, #36)")] -fn test_register_asset_invalid_name_too_short() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let mut asset = create_test_asset(&env, &user1, asset_id); - asset.name = String::from_str(&env, "AB"); // Too short (< 3 chars) - - env.mock_all_auths(); - - // Should panic with InvalidAssetName error - client.register_asset(&asset, &admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #37)")] -fn test_register_asset_invalid_purchase_value() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let mut asset = create_test_asset(&env, &user1, asset_id); - asset.purchase_value = -100; // Negative value - - env.mock_all_auths(); - - // Should panic with InvalidPurchaseValue error - client.register_asset(&asset, &admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #39)")] -fn test_register_asset_zero_owner() { - let env = create_env(); - let admin = Address::generate(&env); - let client = initialize_contract(&env, &admin); - - let zero_address = Address::from_string(&String::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &zero_address, asset_id); - - env.mock_all_auths(); - - // Should panic with InvalidOwnerAddress error - client.register_asset(&asset, &admin); -} - -#[test] -fn test_update_asset_metadata_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // Update metadata - let new_description = Some(String::from_str(&env, "Updated description")); - let new_uri = Some(String::from_str(&env, "ipfs://QmUpdated123")); - - client.update_asset_metadata(&asset_id, &new_description, &new_uri, &None, &user1); - - // Verify metadata was updated (just check it doesn't error) - let updated_asset = client.get_asset(&asset_id); - assert!(!updated_asset.description.is_empty()); - assert!(!updated_asset.metadata_uri.is_empty()); -} - -#[test] -#[should_panic(expected = "Error(Contract, #4)")] -fn test_update_asset_metadata_not_found() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 999); - let new_description = Some(String::from_str(&env, "Updated")); - - env.mock_all_auths(); - - // Should panic with AssetNotFound error - client.update_asset_metadata(&asset_id, &new_description, &None, &None, &user1); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_update_asset_metadata_unauthorized() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - let new_description = Some(String::from_str(&env, "Hacked")); - - // user2 is not owner or admin - should panic with Unauthorized - client.update_asset_metadata(&asset_id, &new_description, &None, &None, &user2); -} - -#[test] -fn test_transfer_asset_ownership_success() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // Transfer ownership - client.transfer_asset_ownership(&asset_id, &user2, &user1); - - // Verify ownership was transferred - let transferred_asset = client.get_asset(&asset_id); - assert_eq!(transferred_asset.owner, user2); - assert_eq!(transferred_asset.status, AssetStatus::Transferred); - - // Verify asset is in new owner's registry - let user2_assets = client.get_assets_by_owner(&user2); - assert_eq!(user2_assets.len(), 1); - - // Verify asset is removed from old owner's registry - let user1_assets = client.get_assets_by_owner(&user1); - assert_eq!(user1_assets.len(), 0); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_transfer_asset_ownership_unauthorized() { - let env = create_env(); - let (admin, user1, user2, user3) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // user3 is not owner - should panic with Unauthorized - client.transfer_asset_ownership(&asset_id, &user2, &user3); -} - -#[test] -fn test_retire_asset_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // Retire asset - client.retire_asset(&asset_id, &user1); - - // Verify asset was retired - let retired_asset = client.get_asset(&asset_id); - assert_eq!(retired_asset.status, AssetStatus::Retired); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_retire_asset_unauthorized() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - // user2 is not owner or admin - should panic with Unauthorized - client.retire_asset(&asset_id, &user2); -} - -#[test] -fn test_check_asset_exists() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - - // Asset doesn't exist yet - assert!(!client.check_asset_exists(&asset_id)); - - // Register asset - client.register_asset(&asset, &admin); - - // Asset now exists - assert!(client.check_asset_exists(&asset_id)); -} - -#[test] -fn test_get_asset_info() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &user1, asset_id.clone()); - - env.mock_all_auths(); - client.register_asset(&asset, &admin); - - let info = client.get_asset_info(&asset_id); - assert_eq!(info.id, asset_id); - assert_eq!(info.owner, user1); - assert_eq!(info.status, AssetStatus::Active); -} - -#[test] -fn test_batch_get_asset_info() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id1 = generate_asset_id(&env, 1); - let asset_id2 = generate_asset_id(&env, 2); - let asset1 = create_test_asset(&env, &user1, asset_id1.clone()); - let asset2 = create_test_asset(&env, &user1, asset_id2.clone()); - - env.mock_all_auths(); - client.register_asset(&asset1, &admin); - client.register_asset(&asset2, &admin); - - let mut ids = Vec::new(&env); - ids.push_back(asset_id1.clone()); - ids.push_back(asset_id2.clone()); - - let infos = client.batch_get_asset_info(&ids); - assert_eq!(infos.len(), 2); -} diff --git a/contracts/assetsup/src/tests/audit_trail.rs b/contracts/assetsup/src/tests/audit_trail.rs deleted file mode 100644 index 5d7642fb..00000000 --- a/contracts/assetsup/src/tests/audit_trail.rs +++ /dev/null @@ -1,222 +0,0 @@ -#![cfg(test)] - -use crate::types::AssetStatus; -use crate::{asset, AssetUpContract, AssetUpContractClient}; -use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Vec}; - -fn create_test_asset(env: &Env, id: BytesN<32>, owner: Address) -> asset::Asset { - asset::Asset { - id, - name: String::from_str(env, "Test Asset"), - description: String::from_str(env, "A test asset for audit trail"), - category: String::from_str(env, "Electronics"), - owner, - registration_timestamp: env.ledger().timestamp(), - last_transfer_timestamp: 0, - status: AssetStatus::Active, - metadata_uri: String::from_str(env, "ipfs://QmTest123"), - purchase_value: 1000, - custom_attributes: Vec::new(env), - } -} - -#[test] -fn test_audit_log_on_asset_registration() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let registrar = Address::generate(&env); - - // Initialize contract - client.initialize(&admin); - - // Add registrar - client.add_authorized_registrar(®istrar); - - // Create and register asset - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let asset = create_test_asset(&env, asset_id.clone(), admin.clone()); - - client.register_asset(&asset, ®istrar); - - // Get audit log - let logs = client.get_asset_audit_logs(&asset_id); - - // Verify audit log entry exists - assert_eq!(logs.len(), 1); - - let entry = logs.get(0).unwrap(); - assert_eq!(entry.action, String::from_str(&env, "ASSET_REGISTERED")); - assert_eq!(entry.actor, registrar); -} - -#[test] -fn test_audit_log_on_ownership_transfer() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let owner = Address::generate(&env); - let new_owner = Address::generate(&env); - - // Initialize contract - client.initialize(&admin); - client.add_authorized_registrar(&admin); - - // Register asset - let asset_id = BytesN::from_array(&env, &[2u8; 32]); - let asset = create_test_asset(&env, asset_id.clone(), owner.clone()); - client.register_asset(&asset, &admin); - - // Transfer ownership - client.transfer_asset_ownership(&asset_id, &new_owner, &owner); - - // Get audit log - let logs = client.get_asset_audit_logs(&asset_id); - - // Verify two entries: registration and transfer - assert_eq!(logs.len(), 2); - - let transfer_entry = logs.get(1).unwrap(); - assert_eq!( - transfer_entry.action, - String::from_str(&env, "OWNERSHIP_TRANSFERRED") - ); - assert_eq!(transfer_entry.actor, owner); -} - -#[test] -fn test_audit_log_on_asset_retirement() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let owner = Address::generate(&env); - - // Initialize contract - client.initialize(&admin); - client.add_authorized_registrar(&admin); - - // Register asset - let asset_id = BytesN::from_array(&env, &[3u8; 32]); - let asset = create_test_asset(&env, asset_id.clone(), owner.clone()); - client.register_asset(&asset, &admin); - - // Retire asset - client.retire_asset(&asset_id, &owner); - - // Get audit log - let logs = client.get_asset_audit_logs(&asset_id); - - // Verify two entries: registration and retirement - assert_eq!(logs.len(), 2); - - let retire_entry = logs.get(1).unwrap(); - assert_eq!(retire_entry.action, String::from_str(&env, "ASSET_RETIRED")); - assert_eq!(retire_entry.actor, owner); -} - -#[test] -fn test_audit_log_chronological_order() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let owner = Address::generate(&env); - let new_owner = Address::generate(&env); - - // Initialize contract - client.initialize(&admin); - client.add_authorized_registrar(&admin); - - // Register asset - let asset_id = BytesN::from_array(&env, &[4u8; 32]); - let asset = create_test_asset(&env, asset_id.clone(), owner.clone()); - client.register_asset(&asset, &admin); - - // Update metadata - client.update_asset_metadata( - &asset_id, - &Some(String::from_str(&env, "Updated description")), - &None, - &None, - &owner, - ); - - // Transfer ownership - client.transfer_asset_ownership(&asset_id, &new_owner, &owner); - - // Get audit log - let logs = client.get_asset_audit_logs(&asset_id); - - // Verify chronological order - assert_eq!(logs.len(), 3); - - let first_entry = logs.get(0).unwrap(); - let second_entry = logs.get(1).unwrap(); - let third_entry = logs.get(2).unwrap(); - - assert!(first_entry.timestamp <= second_entry.timestamp); - assert!(second_entry.timestamp <= third_entry.timestamp); - - assert_eq!( - first_entry.action, - String::from_str(&env, "ASSET_REGISTERED") - ); - assert_eq!( - second_entry.action, - String::from_str(&env, "METADATA_UPDATED") - ); - assert_eq!( - third_entry.action, - String::from_str(&env, "OWNERSHIP_TRANSFERRED") - ); -} - -#[test] -fn test_empty_audit_log_for_nonexistent_asset() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - - // Initialize contract - client.initialize(&admin); - - // Get audit log for non-existent asset - let asset_id = BytesN::from_array(&env, &[99u8; 32]); - let logs = client.get_asset_audit_logs(&asset_id); - - // Verify empty log - assert_eq!(logs.len(), 0); -} - -#[test] -fn test_audit_log_internal_function_not_directly_callable() { - // This test verifies that append_audit_log is pub(crate) and cannot be called - // directly from outside the contract. This is enforced at compile time by Rust's - // visibility rules, so we just document the requirement here. - - // The audit::append_audit_log function is marked as pub(crate), which means: - // 1. It can be called from within the assetsup crate (lib.rs, tokenization.rs, etc.) - // 2. It CANNOT be called from external contracts or test code outside the crate - // 3. Only get_asset_log is publicly accessible through the contract interface - - // This test passes by virtue of the code compiling with the correct visibility. -} diff --git a/contracts/assetsup/src/tests/detokenization.rs b/contracts/assetsup/src/tests/detokenization.rs deleted file mode 100644 index 06576037..00000000 --- a/contracts/assetsup/src/tests/detokenization.rs +++ /dev/null @@ -1,235 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::{AssetType, DetokenizationProposal}; -use soroban_sdk::String; - -#[test] -fn test_propose_detokenization_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Propose detokenization - let proposal_id = client.propose_detokenization(&1u64, &user1); - - assert_eq!(proposal_id, 1); - - // Verify proposal is active - assert!(client.is_detokenization_active(&1u64)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #29)")] -fn test_propose_detokenization_already_proposed() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.propose_detokenization(&1u64, &user1); - - // Try to propose again - should panic with DetokenizationAlreadyProposed - client.propose_detokenization(&1u64, &user1); -} - -#[test] -#[should_panic(expected = "Error(Contract, #11)")] -fn test_propose_detokenization_not_tokenized() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Should panic with AssetNotTokenized error - client.propose_detokenization(&999u64, &user1); -} - -#[test] -fn test_execute_detokenization_success() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Transfer 60% to user2 - client.transfer_tokens(&1u64, &user1, &user2, &600000i128); - - // Propose detokenization - let proposal_id = client.propose_detokenization(&1u64, &user1); - - // Vote with majority - client.cast_vote(&1u64, &proposal_id, &user2); - - // Execute detokenization - client.execute_detokenization(&1u64, &proposal_id); - - // Verify asset is no longer tokenized - assert!(!client.is_detokenization_active(&1u64)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #28)")] -fn test_execute_detokenization_not_approved() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Transfer 30% to user2 (not enough for majority) - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - - // Propose detokenization - let proposal_id = client.propose_detokenization(&1u64, &user1); - - // Vote with minority - client.cast_vote(&1u64, &proposal_id, &user2); - - // Should panic with DetokenizationNotApproved error - client.execute_detokenization(&1u64, &proposal_id); -} - -#[test] -#[should_panic(expected = "Error(Contract, #24)")] -fn test_execute_detokenization_no_proposal() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Should panic with InvalidProposal error - client.execute_detokenization(&1u64, &1u64); -} - -#[test] -fn test_get_detokenization_proposal() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - let proposal_id = client.propose_detokenization(&1u64, &user1); - - let proposal = client.get_detokenization_proposal(&1u64); - - match proposal { - DetokenizationProposal::Active(active) => { - assert_eq!(active.proposal_id, proposal_id); - assert_eq!(active.proposer, user1); - } - _ => panic!("Expected Active proposal"), - } -} - -#[test] -fn test_detokenization_clears_all_data() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Set up some data - client.transfer_tokens(&1u64, &user1, &user2, &600000i128); - client.add_to_whitelist(&1u64, &user2); - client.enable_revenue_sharing(&1u64); - - // Propose and execute detokenization - let proposal_id = client.propose_detokenization(&1u64, &user1); - client.cast_vote(&1u64, &proposal_id, &user2); - client.execute_detokenization(&1u64, &proposal_id); - - // Verify whitelist is cleared - let whitelist = client.get_whitelist(&1u64); - assert_eq!(whitelist.len(), 0); -} diff --git a/contracts/assetsup/src/tests/detokenization_new.rs b/contracts/assetsup/src/tests/detokenization_new.rs index cea89e1c..17714c86 100644 --- a/contracts/assetsup/src/tests/detokenization_new.rs +++ b/contracts/assetsup/src/tests/detokenization_new.rs @@ -2,23 +2,22 @@ extern crate std; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, BigInt, Env, String}; -use crate::detokenization; use crate::tokenization; use crate::types::AssetType; use crate::voting; -use crate::AssetUpContract; +use crate::detokenization; -fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { - tokenization::tokenize_asset( +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 1000u64; + let _ = tokenization::tokenize_asset( env, asset_id, String::from_str(env, "DETON"), - 1000, + BigInt::from_i128(env, 1000), 2, - 100, + BigInt::from_i128(env, 100), tokenizer.clone(), crate::types::TokenMetadata { name: String::from_str(env, "Detokenization Test"), @@ -30,246 +29,122 @@ fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { accredited_investor_required: false, geographic_restrictions: soroban_sdk::Vec::new(env), }, - ) - .unwrap(); + ); + asset_id } #[test] fn test_propose_detokenization() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let proposal_some = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - let _proposal_id = - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - // Verify proposal exists - detokenization::get_detokenization_proposal(&env, asset_id) - .ok() - .is_some() - }); + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); - assert!(proposal_some); + // Verify proposal exists + let proposal = detokenization::get_detokenization_proposal(&env, asset_id).ok(); + assert!(proposal.is_some()); } #[test] fn test_duplicate_proposal_prevention() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let second_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - // Propose once - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - // Try to propose again - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).is_err() - }); + // Propose once + detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - assert!(second_err); + // Try to propose again + let result = detokenization::propose_detokenization(&env, asset_id, proposer); + assert!(result.is_err()); } #[test] fn test_detokenization_active_check() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let (before_active, after_active) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Should not be active initially - let before = detokenization::is_detokenization_active(&env, asset_id).unwrap(); - - // Propose - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); + // Should not be active initially + let is_active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); + assert!(!is_active); - // Should be active now - let after = detokenization::is_detokenization_active(&env, asset_id).unwrap(); - (before, after) - }); + // Propose + detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); - assert!(!before_active); - assert!(after_active); + // Should be active now + let is_active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); + assert!(is_active); } #[test] fn test_execute_detokenization_without_majority() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let execute_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - // Propose - let proposal_id = - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - // Try to execute without votes - detokenization::execute_detokenization(&env, asset_id, proposal_id).is_err() - }); + // Propose + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - assert!(execute_err); // Should fail - no majority + // Try to execute without votes + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_err()); // Should fail - no majority } #[test] fn test_execute_detokenization_with_majority() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let (execute_ok, is_active) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + // Propose + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); - // Propose - let proposal_id = - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); + // Get >50% votes + // Tokenizer has 1000 tokens (100%), cast vote + voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); - // Tokenizer has 1000 tokens (100%), cast vote - voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); + // Now execute - should succeed + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_ok()); - // Now execute - should succeed - let ok = detokenization::execute_detokenization(&env, asset_id, proposal_id).is_ok(); - - // Should no longer be active - let active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); - (ok, active) - }); - - assert!(execute_ok); + // Should no longer be active + let is_active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); assert!(!is_active); } #[test] fn test_detokenization_majority_threshold() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; - - let (first_execute_err, second_execute_ok) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Transfer 400 to holder2 (40% < 50% threshold) - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 400) - .unwrap(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - // Propose - let proposal_id = - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); + // Transfer 400 to holder2 (40% < 50% threshold) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 400)) + .unwrap(); - // Only holder2 votes (40%) - voting::cast_vote(&env, asset_id, proposal_id, holder2.clone()).unwrap(); - - // Should fail execution (only 40%) - let first_err = - detokenization::execute_detokenization(&env, asset_id, proposal_id).is_err(); - - // Now tokenizer also votes (100% total) - voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); - - // Should succeed - let second_ok = detokenization::execute_detokenization(&env, asset_id, proposal_id).is_ok(); - (first_err, second_ok) - }); - - assert!(first_execute_err); - assert!(second_execute_ok); -} - -#[test] -fn test_token_elimination_on_execution() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; - - let (before_exists, after_exists, balance_cleared, holders_cleared) = - env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Transfer some tokens to create multiple holders - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 300) - .unwrap(); - - // Verify asset exists before detokenization - let before_exists = tokenization::get_tokenized_asset(&env, asset_id).is_ok(); - - // Propose detokenization - let proposal_id = - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - - // Both holders vote (100%) - voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, proposal_id, holder2.clone()).unwrap(); - - // Execute detokenization - detokenization::execute_detokenization(&env, asset_id, proposal_id).unwrap(); - - // Verify tokens are removed from circulation - let after_exists = tokenization::get_tokenized_asset(&env, asset_id).is_ok(); - - // Verify balances are cleared - let balance1 = tokenization::get_token_balance(&env, asset_id, tokenizer.clone()); - let balance2 = tokenization::get_token_balance(&env, asset_id, holder2.clone()); - let balance_cleared = balance1.unwrap_or(0) == 0 && balance2.unwrap_or(0) == 0; - - // Verify holders list is cleared - let holders_result = tokenization::get_token_holders(&env, asset_id); - let holders_cleared = holders_result.is_err(); - - ( - before_exists, - after_exists, - balance_cleared, - holders_cleared, - ) - }); - - // Assert asset existed before - assert!(before_exists); - // Assert asset no longer exists after detokenization - assert!(!after_exists); - // Assert balances are cleared - assert!(balance_cleared); - // Assert holders list is cleared - assert!(holders_cleared); -} - -#[test] -fn test_cannot_propose_after_execution() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let proposer = Address::generate(&env); - let asset_id = 1000u64; + // Propose + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); - let second_proposal_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + // Only holder2 votes (40%) + voting::cast_vote(&env, asset_id, proposal_id, holder2).unwrap(); - // Propose and execute detokenization - let proposal_id = - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); - detokenization::execute_detokenization(&env, asset_id, proposal_id).unwrap(); + // Should fail execution + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_err()); - // Try to propose again after execution - should fail because asset is not tokenized - detokenization::propose_detokenization(&env, asset_id, proposer.clone()).is_err() - }); + // Now tokenizer also votes (100% total) + voting::cast_vote(&env, asset_id, proposal_id, tokenizer).unwrap(); - assert!(second_proposal_err); + // Should succeed + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_ok()); } diff --git a/contracts/assetsup/src/tests/dividends.rs b/contracts/assetsup/src/tests/dividends.rs deleted file mode 100644 index dae3f9e7..00000000 --- a/contracts/assetsup/src/tests/dividends.rs +++ /dev/null @@ -1,245 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::AssetType; -use soroban_sdk::String; - -#[test] -fn test_enable_revenue_sharing() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Initially disabled - let asset = client.get_tokenized_asset(&1u64); - assert!(!asset.revenue_sharing_enabled); - - // Enable revenue sharing - client.enable_revenue_sharing(&1u64); - - let asset = client.get_tokenized_asset(&1u64); - assert!(asset.revenue_sharing_enabled); -} - -#[test] -fn test_disable_revenue_sharing() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.enable_revenue_sharing(&1u64); - client.disable_revenue_sharing(&1u64); - - let asset = client.get_tokenized_asset(&1u64); - assert!(!asset.revenue_sharing_enabled); -} - -#[test] -fn test_distribute_dividends_success() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Enable revenue sharing - client.enable_revenue_sharing(&1u64); - - // Transfer 30% to user2 - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - - // Distribute 10000 in dividends - client.distribute_dividends(&1u64, &10000i128); - - // Check unclaimed dividends - let unclaimed1 = client.get_unclaimed_dividends(&1u64, &user1); - let unclaimed2 = client.get_unclaimed_dividends(&1u64, &user2); - - assert_eq!(unclaimed1, 7000); // 70% of 10000 - assert_eq!(unclaimed2, 3000); // 30% of 10000 -} - -#[test] -#[should_panic(expected = "Error(Contract, #27)")] -fn test_distribute_dividends_invalid_amount() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.enable_revenue_sharing(&1u64); - - // Should panic with InvalidDividendAmount error - client.distribute_dividends(&1u64, &0i128); -} - -#[test] -#[should_panic(expected = "Error(Contract, #27)")] -fn test_distribute_dividends_not_enabled() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Revenue sharing not enabled - should panic with InvalidDividendAmount - client.distribute_dividends(&1u64, &10000i128); -} - -#[test] -fn test_claim_dividends_success() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.enable_revenue_sharing(&1u64); - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - client.distribute_dividends(&1u64, &10000i128); - - // Claim dividends - let claimed = client.claim_dividends(&1u64, &user2); - assert_eq!(claimed, 3000); - - // After claiming, unclaimed should be 0 - let unclaimed = client.get_unclaimed_dividends(&1u64, &user2); - assert_eq!(unclaimed, 0); -} - -#[test] -#[should_panic(expected = "Error(Contract, #26)")] -fn test_claim_dividends_none_to_claim() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Should panic with NoDividendsToClaim error - client.claim_dividends(&1u64, &user1); -} - -#[test] -fn test_multiple_dividend_distributions() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.enable_revenue_sharing(&1u64); - client.transfer_tokens(&1u64, &user1, &user2, &500000i128); - - // First distribution - client.distribute_dividends(&1u64, &10000i128); - - // Second distribution - client.distribute_dividends(&1u64, &5000i128); - - // Total unclaimed should be sum of both distributions - let unclaimed1 = client.get_unclaimed_dividends(&1u64, &user1); - let unclaimed2 = client.get_unclaimed_dividends(&1u64, &user2); - - assert_eq!(unclaimed1, 7500); // 50% of 15000 - assert_eq!(unclaimed2, 7500); // 50% of 15000 -} diff --git a/contracts/assetsup/src/tests/dividends_new.rs b/contracts/assetsup/src/tests/dividends_new.rs index d9cd68fb..421d9d59 100644 --- a/contracts/assetsup/src/tests/dividends_new.rs +++ b/contracts/assetsup/src/tests/dividends_new.rs @@ -2,23 +2,21 @@ extern crate std; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, BigInt, Env, String}; -use crate::dividends; -use crate::error::Error; use crate::tokenization; use crate::types::AssetType; -use crate::AssetUpContract; +use crate::dividends; -fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { - tokenization::tokenize_asset( +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 800u64; + let _ = tokenization::tokenize_asset( env, asset_id, String::from_str(env, "DIV"), - 1000, + BigInt::from_i128(env, 1000), 2, - 100, + BigInt::from_i128(env, 100), tokenizer.clone(), crate::types::TokenMetadata { name: String::from_str(env, "Dividend Test"), @@ -30,444 +28,111 @@ fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { accredited_investor_required: false, geographic_restrictions: soroban_sdk::Vec::new(env), }, - ) - .unwrap(); + ); + asset_id } -// ─── distribute_dividends ──────────────────────────────────────────────────── - #[test] fn test_distribute_dividends_no_revenue_sharing() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 800u64; - - let result_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::distribute_dividends(&env, asset_id, 1000).is_err() - }); - - assert!(result_err); -} - -#[test] -fn test_distribute_dividends_zero_amount_fails() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 801u64; - - let err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, 0).unwrap_err() - }); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - assert_eq!(err, Error::InvalidDividendAmount); + // Try to distribute without enabling revenue sharing + let result = dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)); + assert!(result.is_err()); } #[test] -fn test_distribute_dividends_negative_amount_fails() { +fn test_distribute_dividends() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 802u64; + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, -500).unwrap_err() - }); + // Enable revenue sharing + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - assert_eq!(err, Error::InvalidDividendAmount); -} + // Transfer tokens to holder2 (500 out of 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 500)) + .unwrap(); -#[test] -fn test_distribute_dividends_untokenized_asset_fails() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let asset_id = 803u64; + // Distribute 1000 tokens as dividend + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)).unwrap(); - let err = env.as_contract(&contract_id, || { - dividends::distribute_dividends(&env, asset_id, 1000).unwrap_err() - }); + // Tokenizer should have 500 unclaimed (50% of 1000) + let tokenizer_dividend = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + assert_eq!(tokenizer_dividend, BigInt::from_i128(&env, 500)); - assert_eq!(err, Error::AssetNotTokenized); + // Holder2 should have 500 unclaimed (50% of 1000) + let holder2_dividend = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); + assert_eq!(holder2_dividend, BigInt::from_i128(&env, 500)); } -#[test] -fn test_distribute_dividends_two_holders_equal_split() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let asset_id = 800u64; - - let (tokenizer_dividend, holder2_dividend) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 500) - .unwrap(); - - dividends::distribute_dividends(&env, asset_id, 1000).unwrap(); - - let t_div = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let h2_div = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); - (t_div, h2_div) - }); - - assert_eq!(tokenizer_dividend, 500_i128); - assert_eq!(holder2_dividend, 500_i128); -} - -#[test] -fn test_distribute_dividends_accumulates_across_rounds() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 810u64; - - let unclaimed = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - - // Two distribution rounds without claiming in between - dividends::distribute_dividends(&env, asset_id, 400).unwrap(); - dividends::distribute_dividends(&env, asset_id, 600).unwrap(); - - dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap() - }); - - // Total: 400 + 600 = 1000 (sole holder owns 100%) - assert_eq!(unclaimed, 1000_i128); -} - -#[test] -fn test_distribute_dividends_after_disable_fails() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 811u64; - - let err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::disable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, 1000).unwrap_err() - }); - - assert_eq!(err, Error::InvalidDividendAmount); -} - -// ─── claim_dividends ───────────────────────────────────────────────────────── - #[test] fn test_claim_dividends() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 800u64; - - let (claimed, remaining) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, 500).unwrap(); - - let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let remaining = - dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - (claimed, remaining) - }); - - assert_eq!(claimed, 500_i128); - assert_eq!(remaining, 0_i128); -} - -#[test] -fn test_claim_dividends_insufficient() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 800u64; - - let result_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::claim_dividends(&env, asset_id, tokenizer.clone()).is_err() - }); - - assert!(result_err); -} - -#[test] -fn test_claim_dividends_no_double_claim() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 820u64; - - let second_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, 500).unwrap(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - // Second claim must fail - dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap_err() - }); + // Enable revenue sharing and distribute + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 500)).unwrap(); - assert_eq!(second_err, Error::NoDividendsToClaim); -} - -#[test] -fn test_claim_dividends_partial_holder() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let asset_id = 821u64; + // Claim dividends + let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let (claimed, h2_remaining) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + // Should have claimed full 500 + assert_eq!(claimed, BigInt::from_i128(&env, 500)); - // tokenizer keeps 750, holder2 gets 250 - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 250) - .unwrap(); - - dividends::distribute_dividends(&env, asset_id, 1000).unwrap(); - - // Only tokenizer claims - let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let h2_remaining = - dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); - (claimed, h2_remaining) - }); - - assert_eq!(claimed, 750_i128); - assert_eq!(h2_remaining, 250_i128); + // Should have 0 unclaimed now + let remaining = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer).unwrap(); + assert_eq!(remaining, BigInt::from_i128(&env, 0)); } #[test] -fn test_claim_resets_then_accumulates_again() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 822u64; - - let (first_claim, second_claim) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - - dividends::distribute_dividends(&env, asset_id, 300).unwrap(); - let first = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - - dividends::distribute_dividends(&env, asset_id, 700).unwrap(); - let second = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - - (first, second) - }); - - assert_eq!(first_claim, 300_i128); - assert_eq!(second_claim, 700_i128); -} - -// ─── get_unclaimed_dividends ───────────────────────────────────────────────── - -#[test] -fn test_get_unclaimed_dividends_unknown_holder_returns_zero() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let stranger = Address::generate(&env); - let asset_id = 830u64; - - let amount = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::get_unclaimed_dividends(&env, asset_id, stranger).unwrap() - }); - - assert_eq!(amount, 0_i128); -} - -#[test] -fn test_get_unclaimed_dividends_untokenized_asset_fails() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let stranger = Address::generate(&env); - let asset_id = 831u64; - - let err = env.as_contract(&contract_id, || { - dividends::get_unclaimed_dividends(&env, asset_id, stranger).unwrap_err() - }); - - assert_eq!(err, Error::AssetNotTokenized); -} - -// ─── revenue sharing toggle ────────────────────────────────────────────────── - -#[test] -fn test_enable_revenue_sharing() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 840u64; - - let ok = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).is_ok() - }); - - assert!(ok); -} - -#[test] -fn test_disable_revenue_sharing() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 841u64; - - let ok = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::disable_revenue_sharing(&env, asset_id).is_ok() - }); - - assert!(ok); -} - -#[test] -fn test_enable_revenue_sharing_untokenized_fails() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let asset_id = 842u64; - - let err = env.as_contract(&contract_id, || { - dividends::enable_revenue_sharing(&env, asset_id).unwrap_err() - }); - - assert_eq!(err, Error::AssetNotTokenized); -} - -#[test] -fn test_disable_revenue_sharing_untokenized_fails() { +fn test_claim_dividends_insufficient() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let asset_id = 843u64; + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let err = env.as_contract(&contract_id, || { - dividends::disable_revenue_sharing(&env, asset_id).unwrap_err() - }); + // Enable revenue sharing but don't distribute + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - assert_eq!(err, Error::AssetNotTokenized); + // Try to claim (should have 0) + let result = dividends::claim_dividends(&env, asset_id, tokenizer); + assert!(result.is_err()); } -#[test] -fn test_revenue_sharing_toggle_enables_distribution() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 844u64; - - let (disabled_err, enabled_ok) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - let disabled_err = dividends::distribute_dividends(&env, asset_id, 100).is_err(); - - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - let enabled_ok = dividends::distribute_dividends(&env, asset_id, 100).is_ok(); - - (disabled_err, enabled_ok) - }); - - assert!(disabled_err); - assert!(enabled_ok); -} - -// ─── proportional distribution ─────────────────────────────────────────────── - #[test] fn test_proportional_dividend_distribution() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let holder3 = Address::generate(&env); - let asset_id = 800u64; - - let (t_div, h2_div, h3_div) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - - // tokenizer: 400, holder2: 300, holder3: 300 - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 300) - .unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), 300) - .unwrap(); - - dividends::distribute_dividends(&env, asset_id, 1000).unwrap(); - - let t = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let h2 = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); - let h3 = dividends::get_unclaimed_dividends(&env, asset_id, holder3.clone()).unwrap(); - (t, h2, h3) - }); - - assert_eq!(t_div, 400_i128); - assert_eq!(h2_div, 300_i128); - assert_eq!(h3_div, 300_i128); -} - -#[test] -fn test_sole_holder_receives_full_amount() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 850u64; - - let unclaimed = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, 999).unwrap(); - dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap() - }); - - assert_eq!(unclaimed, 999_i128); -} - -#[test] -fn test_four_equal_holders_distribution() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let h2 = Address::generate(&env); - let h3 = Address::generate(&env); - let h4 = Address::generate(&env); - let asset_id = 851u64; - - let (d1, d2, d3, d4) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - - // 250 each - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), h2.clone(), 250).unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), h3.clone(), 250).unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), h4.clone(), 250).unwrap(); - - dividends::distribute_dividends(&env, asset_id, 1000).unwrap(); - - let d1 = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let d2 = dividends::get_unclaimed_dividends(&env, asset_id, h2.clone()).unwrap(); - let d3 = dividends::get_unclaimed_dividends(&env, asset_id, h3.clone()).unwrap(); - let d4 = dividends::get_unclaimed_dividends(&env, asset_id, h4.clone()).unwrap(); - (d1, d2, d3, d4) - }); - - assert_eq!(d1, 250_i128); - assert_eq!(d2, 250_i128); - assert_eq!(d3, 250_i128); - assert_eq!(d4, 250_i128); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let holder3 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Enable revenue sharing + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + + // Transfer: tokenizer 400, holder2 300, holder3 300 (total 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 300)) + .unwrap(); + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), BigInt::from_i128(&env, 300)) + .unwrap(); + + // Distribute 1000 as dividend + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)).unwrap(); + + // Verify proportional distribution + let t_div = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer).unwrap(); + let h2_div = dividends::get_unclaimed_dividends(&env, asset_id, holder2).unwrap(); + let h3_div = dividends::get_unclaimed_dividends(&env, asset_id, holder3).unwrap(); + + // Tokenizer: 400/1000 * 1000 = 400 + // Holder2: 300/1000 * 1000 = 300 + // Holder3: 300/1000 * 1000 = 300 + assert_eq!(t_div, BigInt::from_i128(&env, 400)); + assert_eq!(h2_div, BigInt::from_i128(&env, 300)); + assert_eq!(h3_div, BigInt::from_i128(&env, 300)); } diff --git a/contracts/assetsup/src/tests/helpers.rs b/contracts/assetsup/src/tests/helpers.rs deleted file mode 100644 index c527d67d..00000000 --- a/contracts/assetsup/src/tests/helpers.rs +++ /dev/null @@ -1,155 +0,0 @@ -use crate::asset::Asset; -use crate::insurance::{ClaimStatus, ClaimType, InsuranceClaim, InsurancePolicy, PolicyStatus, PolicyType}; -use crate::types::{AssetStatus, AssetType, CustomAttribute, TokenMetadata}; -use crate::{AssetUpContract, AssetUpContractClient}; -use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Vec}; - -/// Create a fresh test environment -pub fn create_env() -> Env { - Env::default() -} - -/// Create mock addresses for testing -pub fn create_mock_addresses(env: &Env) -> (Address, Address, Address, Address) { - let admin = Address::generate(env); - let user1 = Address::generate(env); - let user2 = Address::generate(env); - let user3 = Address::generate(env); - (admin, user1, user2, user3) -} - -/// Initialize contract with admin -pub fn initialize_contract<'a>(env: &'a Env, admin: &Address) -> AssetUpContractClient<'a> { - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(env, &contract_id); - - env.mock_all_auths(); - client.initialize(admin); - client -} - -/// Create a test asset -pub fn create_test_asset(env: &Env, owner: &Address, id: BytesN<32>) -> Asset { - let timestamp = env.ledger().timestamp(); - - Asset { - id, - name: String::from_str(env, "Test Asset"), - description: String::from_str(env, "A test asset for unit testing"), - category: String::from_str(env, "Electronics"), - owner: owner.clone(), - registration_timestamp: timestamp, - last_transfer_timestamp: timestamp, - status: AssetStatus::Active, - metadata_uri: String::from_str(env, "ipfs://QmTest123456789"), - purchase_value: 1000, - custom_attributes: Vec::new(env), - } -} - -/// Create a test asset with custom attributes -#[allow(dead_code)] -pub fn create_test_asset_with_attributes( - env: &Env, - owner: &Address, - id: BytesN<32>, - name: &str, - value: i128, -) -> Asset { - let timestamp = env.ledger().timestamp(); - let mut attributes = Vec::new(env); - attributes.push_back(CustomAttribute { - key: String::from_str(env, "serial_number"), - value: String::from_str(env, "SN123456"), - }); - - Asset { - id, - name: String::from_str(env, name), - description: String::from_str(env, "Test asset with attributes"), - category: String::from_str(env, "Equipment"), - owner: owner.clone(), - registration_timestamp: timestamp, - last_transfer_timestamp: timestamp, - status: AssetStatus::Active, - metadata_uri: String::from_str(env, "ipfs://QmTestWithAttrs"), - purchase_value: value, - custom_attributes: attributes, - } -} - -/// Generate a unique asset ID -pub fn generate_asset_id(env: &Env, seed: u32) -> BytesN<32> { - let mut bytes = [0u8; 32]; - bytes[0] = (seed >> 24) as u8; - bytes[1] = (seed >> 16) as u8; - bytes[2] = (seed >> 8) as u8; - bytes[3] = seed as u8; - BytesN::from_array(env, &bytes) -} - -/// Create token metadata for testing -#[allow(dead_code)] -pub fn create_test_token_metadata(env: &Env) -> TokenMetadata { - TokenMetadata { - name: String::from_str(env, "Test Token"), - description: String::from_str(env, "Test tokenized asset"), - asset_type: AssetType::Physical, - ipfs_uri: Some(String::from_str(env, "ipfs://QmTokenMetadata")), - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: Vec::new(env), - } -} - -/// Create a test insurance policy -pub fn create_test_policy( - env: &Env, - policy_id: BytesN<32>, - holder: &Address, - insurer: &Address, - asset_id: BytesN<32>, -) -> InsurancePolicy { - let current_time = env.ledger().timestamp(); - - InsurancePolicy { - policy_id, - holder: holder.clone(), - insurer: insurer.clone(), - asset_id, - policy_type: PolicyType::Property, - coverage_amount: 10000, - deductible: 500, - premium: 100, - start_date: current_time, - end_date: current_time + 31536000, // 1 year - status: PolicyStatus::Active, - auto_renew: false, - last_payment: current_time, - } -} - -/// Create a test insurance claim -#[allow(dead_code)] -pub fn create_test_claim( - env: &Env, - claim_id: BytesN<32>, - policy_id: BytesN<32>, - asset_id: BytesN<32>, - claimant: &Address, -) -> InsuranceClaim { - let current_time = env.ledger().timestamp(); - - InsuranceClaim { - claim_id, - policy_id, - asset_id, - claimant: claimant.clone(), - claim_type: ClaimType::Damage, - amount: 5000, - status: ClaimStatus::Submitted, - filed_at: current_time, - approved_amount: 0, - } -} diff --git a/contracts/assetsup/src/tests/initialization.rs b/contracts/assetsup/src/tests/initialization.rs deleted file mode 100644 index 44305274..00000000 --- a/contracts/assetsup/src/tests/initialization.rs +++ /dev/null @@ -1,80 +0,0 @@ -use crate::tests::helpers::*; -use crate::{AssetUpContract, AssetUpContractClient}; -use soroban_sdk::{testutils::Address as _, Address}; - -#[test] -fn test_initialize_success() { - let env = create_env(); - let admin = Address::generate(&env); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - client.initialize(&admin); - - // Verify admin is set - let stored_admin = client.get_admin(); - assert_eq!(stored_admin, admin); - - // Verify contract is not paused - assert!(!client.is_paused()); - - // Verify total asset count is 0 - assert_eq!(client.get_total_asset_count(), 0); - - // Verify admin is authorized registrar - assert!(client.is_authorized_registrar(&admin)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #1)")] -fn test_initialize_already_initialized() { - let env = create_env(); - let admin = Address::generate(&env); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - client.initialize(&admin); - - // Try to initialize again - should panic with AlreadyInitialized error - client.initialize(&admin); -} - -#[test] -fn test_get_contract_metadata() { - let env = create_env(); - let admin = Address::generate(&env); - let client = initialize_contract(&env, &admin); - - let metadata = client.get_contract_metadata(); - // Just verify metadata exists and has expected structure - assert!(!metadata.version.is_empty()); - assert!(!metadata.name.is_empty()); -} - -#[test] -#[should_panic(expected = "Error(Contract, #35)")] -fn test_get_contract_metadata_not_initialized() { - let env = create_env(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - // Should panic with ContractNotInitialized error - client.get_contract_metadata(); -} - -#[test] -#[should_panic(expected = "Error(Contract, #2)")] -fn test_get_admin_not_found() { - let env = create_env(); - - let contract_id = env.register(AssetUpContract, ()); - let client = AssetUpContractClient::new(&env, &contract_id); - - // Should panic with AdminNotFound error - client.get_admin(); -} diff --git a/contracts/assetsup/src/tests/insurance.rs b/contracts/assetsup/src/tests/insurance.rs deleted file mode 100644 index 677982b7..00000000 --- a/contracts/assetsup/src/tests/insurance.rs +++ /dev/null @@ -1,338 +0,0 @@ -use crate::insurance::PolicyStatus; -use crate::tests::helpers::*; -use soroban_sdk::testutils::Ledger; - -#[test] -fn test_create_insurance_policy_success() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Verify policy was created - let stored_policy = client.get_insurance_policy(&policy_id); - assert!(stored_policy.is_some()); - - let stored = stored_policy.unwrap(); - assert_eq!(stored.policy_id, policy_id); - assert_eq!(stored.holder, user1); - assert_eq!(stored.status, PolicyStatus::Active); -} - -#[test] -#[should_panic(expected = "Error(Contract, #3)")] -fn test_create_insurance_policy_already_exists() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Try to create again - should panic with AssetAlreadyExists - client.create_insurance_policy(&policy); -} - -#[test] -#[should_panic(expected = "Error(Contract, #9)")] -fn test_create_insurance_policy_invalid_coverage() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let mut policy = create_test_policy(&env, policy_id, &user1, &insurer, asset_id); - - // Invalid: deductible >= coverage_amount - policy.deductible = 10000; - policy.coverage_amount = 10000; - - env.mock_all_auths(); - - // Should panic with InvalidPayment error - client.create_insurance_policy(&policy); -} - -#[test] -#[should_panic(expected = "Error(Contract, #9)")] -fn test_create_insurance_policy_invalid_dates() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let mut policy = create_test_policy(&env, policy_id, &user1, &insurer, asset_id); - - // Invalid: start_date >= end_date - policy.start_date = 1000; - policy.end_date = 1000; - - env.mock_all_auths(); - - // Should panic with InvalidPayment error - client.create_insurance_policy(&policy); -} - -#[test] -fn test_cancel_insurance_policy_by_holder() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Cancel by holder - client.cancel_insurance_policy(&policy_id, &user1); - - // Verify policy was cancelled - let stored_policy = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored_policy.status, PolicyStatus::Cancelled); -} - -#[test] -fn test_cancel_insurance_policy_by_insurer() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Cancel by insurer - client.cancel_insurance_policy(&policy_id, &insurer); - - // Verify policy was cancelled - let stored_policy = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored_policy.status, PolicyStatus::Cancelled); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_cancel_insurance_policy_unauthorized() { - let env = create_env(); - let (admin, user1, insurer, user3) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // user3 is neither holder nor insurer - should panic with Unauthorized - client.cancel_insurance_policy(&policy_id, &user3); -} - -#[test] -fn test_suspend_insurance_policy() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Suspend policy - client.suspend_insurance_policy(&policy_id, &insurer); - - // Verify policy was suspended - let stored_policy = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored_policy.status, PolicyStatus::Suspended); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_suspend_insurance_policy_unauthorized() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Holder cannot suspend - should panic with Unauthorized - client.suspend_insurance_policy(&policy_id, &user1); -} - -#[test] -fn test_expire_insurance_policy() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let mut policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - // Set current time to 5000 - env.ledger().with_mut(|li| li.timestamp = 5000); - - // Set dates: start now, end in 1000 seconds - policy.start_date = 5000; - policy.end_date = 6000; - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Advance time past end_date - env.ledger().with_mut(|li| li.timestamp = 7000); - - // Expire policy (permissionless) - client.expire_insurance_policy(&policy_id); - - // Verify policy was expired - let stored_policy = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored_policy.status, PolicyStatus::Expired); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_expire_insurance_policy_not_yet_expired() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Should panic with Unauthorized error - end date hasn't passed - client.expire_insurance_policy(&policy_id); -} - -#[test] -fn test_renew_insurance_policy() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - // Renew policy - let new_end_date = env.ledger().timestamp() + 63072000; // 2 years - let new_premium = 150i128; - - client.renew_insurance_policy(&policy_id, &new_end_date, &new_premium, &insurer); - - // Verify policy was renewed - let stored_policy = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored_policy.end_date, new_end_date); - assert_eq!(stored_policy.premium, new_premium); - assert_eq!(stored_policy.status, PolicyStatus::Active); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_renew_insurance_policy_unauthorized() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - client.create_insurance_policy(&policy); - - let new_end_date = env.ledger().timestamp() + 63072000; - let new_premium = 150i128; - - // Holder cannot renew - should panic with Unauthorized - client.renew_insurance_policy(&policy_id, &new_end_date, &new_premium, &user1); -} - -#[test] -fn test_get_asset_insurance_policies() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let asset_id = generate_asset_id(&env, 100); - let policy_id1 = generate_asset_id(&env, 1); - let policy_id2 = generate_asset_id(&env, 2); - - let policy1 = create_test_policy(&env, policy_id1.clone(), &user1, &insurer, asset_id.clone()); - let policy2 = create_test_policy(&env, policy_id2.clone(), &user1, &insurer, asset_id.clone()); - - env.mock_all_auths(); - client.create_insurance_policy(&policy1); - client.create_insurance_policy(&policy2); - - // Get all policies for asset - let policies = client.get_asset_insurance_policies(&asset_id); - assert_eq!(policies.len(), 2); -} - -#[test] -fn test_policy_lifecycle_full() { - let env = create_env(); - let (admin, user1, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - let policy_id = generate_asset_id(&env, 1); - let asset_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &user1, &insurer, asset_id); - - env.mock_all_auths(); - - // Create - client.create_insurance_policy(&policy); - let stored = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored.status, PolicyStatus::Active); - - // Renew while active - let new_end_date = env.ledger().timestamp() + 63072000; - client.renew_insurance_policy(&policy_id, &new_end_date, &150i128, &insurer); - let stored = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored.status, PolicyStatus::Active); - assert_eq!(stored.premium, 150); - - // Suspend - client.suspend_insurance_policy(&policy_id, &insurer); - let stored = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored.status, PolicyStatus::Suspended); - - // Cancel from suspended state - client.cancel_insurance_policy(&policy_id, &user1); - let stored = client.get_insurance_policy(&policy_id).unwrap(); - assert_eq!(stored.status, PolicyStatus::Cancelled); -} diff --git a/contracts/assetsup/src/tests/insurance_new.rs b/contracts/assetsup/src/tests/insurance_new.rs deleted file mode 100644 index d2f3b9a6..00000000 --- a/contracts/assetsup/src/tests/insurance_new.rs +++ /dev/null @@ -1,435 +0,0 @@ -#![cfg(test)] - -extern crate std; - -use soroban_sdk::testutils::{Address as _, Ledger}; -use soroban_sdk::{Address, BytesN, Env}; - -use crate::insurance::{self, InsurancePolicy, PolicyStatus, PolicyType}; -use crate::AssetUpContract; - -fn create_test_policy( - env: &Env, - policy_id: BytesN<32>, - holder: Address, - insurer: Address, - asset_id: BytesN<32>, -) -> InsurancePolicy { - let current_time = env.ledger().timestamp(); - InsurancePolicy { - policy_id, - holder, - insurer, - asset_id, - policy_type: PolicyType::Comprehensive, - coverage_amount: 100000, - deductible: 5000, - premium: 1000, - start_date: current_time, - end_date: current_time + 365 * 24 * 60 * 60, // 1 year from now - status: PolicyStatus::Active, - auto_renew: false, - last_payment: current_time, - } -} - -#[test] -fn test_create_policy_success() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let result = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - insurance::create_policy(env.clone(), policy) - }); - - assert!(result.is_ok()); -} - -#[test] -fn test_create_policy_invalid_dates() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let result = env.as_contract(&contract_id, || { - let mut policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - // Set end_date same as start_date (invalid - must be after) - policy.end_date = policy.start_date; - insurance::create_policy(env.clone(), policy) - }); - - assert!(result.is_err()); -} - -#[test] -fn test_create_policy_invalid_coverage() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let result = env.as_contract(&contract_id, || { - let mut policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - // Set deductible >= coverage_amount (invalid) - policy.deductible = policy.coverage_amount; - insurance::create_policy(env.clone(), policy) - }); - - assert!(result.is_err()); -} - -#[test] -fn test_cancel_policy_by_holder() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (create_ok, cancel_ok, final_status) = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder.clone(), insurer, asset_id); - let create_result = insurance::create_policy(env.clone(), policy).is_ok(); - - let cancel_result = - insurance::cancel_policy(env.clone(), policy_id.clone(), holder.clone()).is_ok(); - - let final_policy = insurance::get_policy(env.clone(), policy_id.clone()); - let status = final_policy.map(|p| p.status); - - (create_result, cancel_result, status) - }); - - assert!(create_ok); - assert!(cancel_ok); - assert_eq!(final_status, Some(PolicyStatus::Cancelled)); -} - -#[test] -fn test_cancel_policy_by_insurer() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (create_ok, cancel_ok, final_status) = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer.clone(), asset_id); - let create_result = insurance::create_policy(env.clone(), policy).is_ok(); - - let cancel_result = - insurance::cancel_policy(env.clone(), policy_id.clone(), insurer.clone()).is_ok(); - - let final_policy = insurance::get_policy(env.clone(), policy_id.clone()); - let status = final_policy.map(|p| p.status); - - (create_result, cancel_result, status) - }); - - assert!(create_ok); - assert!(cancel_ok); - assert_eq!(final_status, Some(PolicyStatus::Cancelled)); -} - -#[test] -fn test_cancel_policy_unauthorized() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let unauthorized = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let cancel_err = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - insurance::create_policy(env.clone(), policy).unwrap(); - - insurance::cancel_policy(env.clone(), policy_id.clone(), unauthorized.clone()).is_err() - }); - - assert!(cancel_err); -} - -#[test] -fn test_suspend_policy() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (create_ok, suspend_ok, final_status) = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer.clone(), asset_id); - let create_result = insurance::create_policy(env.clone(), policy).is_ok(); - - let suspend_result = - insurance::suspend_policy(env.clone(), policy_id.clone(), insurer.clone()).is_ok(); - - let final_policy = insurance::get_policy(env.clone(), policy_id.clone()); - let status = final_policy.map(|p| p.status); - - (create_result, suspend_result, status) - }); - - assert!(create_ok); - assert!(suspend_ok); - assert_eq!(final_status, Some(PolicyStatus::Suspended)); -} - -#[test] -fn test_suspend_policy_unauthorized() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let unauthorized = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let suspend_err = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - insurance::create_policy(env.clone(), policy).unwrap(); - - insurance::suspend_policy(env.clone(), policy_id.clone(), unauthorized.clone()).is_err() - }); - - assert!(suspend_err); -} - -#[test] -fn test_expire_policy_before_end_date() { - let env = Env::default(); - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let expire_err = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - insurance::create_policy(env.clone(), policy).unwrap(); - - // Try to expire before end_date (should fail) - insurance::expire_policy(env.clone(), policy_id.clone()).is_err() - }); - - assert!(expire_err); -} - -#[test] -fn test_expire_policy_after_end_date() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (expire_ok, final_status) = env.as_contract(&contract_id, || { - // Set initial time - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - - let mut policy = create_test_policy(&env, policy_id.clone(), holder, insurer, asset_id); - // Set policy to expire at timestamp 2000 - policy.start_date = 1000; - policy.end_date = 2000; - insurance::create_policy(env.clone(), policy).unwrap(); - - // Advance time past end_date - env.ledger().with_mut(|li| { - li.timestamp = 2500; - }); - - let expire_result = insurance::expire_policy(env.clone(), policy_id.clone()).is_ok(); - - let final_policy = insurance::get_policy(env.clone(), policy_id.clone()); - let status = final_policy.map(|p| p.status); - - (expire_result, status) - }); - - assert!(expire_ok); - assert_eq!(final_status, Some(PolicyStatus::Expired)); -} - -#[test] -fn test_renew_policy() { - let env = Env::default(); - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (renew_ok, final_status, new_end_date, new_premium) = env.as_contract(&contract_id, || { - let policy = create_test_policy(&env, policy_id.clone(), holder, insurer.clone(), asset_id); - insurance::create_policy(env.clone(), policy).unwrap(); - - let new_end = 2000u64; - let new_prem = 1500i128; - let renew_result = insurance::renew_policy( - env.clone(), - policy_id.clone(), - new_end, - new_prem, - insurer.clone(), - ) - .is_ok(); - - let final_policy = insurance::get_policy(env.clone(), policy_id.clone()).unwrap(); - - ( - renew_result, - final_policy.status, - final_policy.end_date, - final_policy.premium, - ) - }); - - assert!(renew_ok); - assert_eq!(final_status, PolicyStatus::Active); - assert_eq!(new_end_date, 2000u64); - assert_eq!(new_premium, 1500i128); -} - -#[test] -fn test_renew_expired_policy() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (renew_ok, final_status) = env.as_contract(&contract_id, || { - // Set initial time - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - - let mut policy = - create_test_policy(&env, policy_id.clone(), holder, insurer.clone(), asset_id); - // Set policy to expire at timestamp 2000 - policy.start_date = 1000; - policy.end_date = 2000; - insurance::create_policy(env.clone(), policy).unwrap(); - - // Advance time past end_date - env.ledger().with_mut(|li| { - li.timestamp = 2500; - }); - - // Expire the policy first - insurance::expire_policy(env.clone(), policy_id.clone()).unwrap(); - - // Now renew it to timestamp 3500 - let renew_result = - insurance::renew_policy(env.clone(), policy_id.clone(), 3500, 1500, insurer.clone()) - .is_ok(); - - let final_policy = insurance::get_policy(env.clone(), policy_id.clone()).unwrap(); - - (renew_result, final_policy.status) - }); - - assert!(renew_ok); - assert_eq!(final_status, PolicyStatus::Active); -} - -#[test] -fn test_get_asset_policies() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id1 = BytesN::from_array(&env, &[2u8; 32]); - let policy_id2 = BytesN::from_array(&env, &[3u8; 32]); - - let policy_count = env.as_contract(&contract_id, || { - // Create two policies for the same asset - let policy1 = create_test_policy( - &env, - policy_id1.clone(), - holder.clone(), - insurer.clone(), - asset_id.clone(), - ); - let policy2 = create_test_policy( - &env, - policy_id2.clone(), - holder.clone(), - insurer.clone(), - asset_id.clone(), - ); - - insurance::create_policy(env.clone(), policy1).unwrap(); - insurance::create_policy(env.clone(), policy2).unwrap(); - - let policies = insurance::get_asset_policies(env.clone(), asset_id.clone()); - policies.len() - }); - - assert_eq!(policy_count, 2); -} - -#[test] -fn test_status_transition_validation() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let holder = Address::generate(&env); - let insurer = Address::generate(&env); - let asset_id = BytesN::from_array(&env, &[1u8; 32]); - let policy_id = BytesN::from_array(&env, &[2u8; 32]); - - let (suspend_ok, cancel_after_suspend_ok, suspend_cancelled_err) = - env.as_contract(&contract_id, || { - let policy = create_test_policy( - &env, - policy_id.clone(), - holder.clone(), - insurer.clone(), - asset_id, - ); - insurance::create_policy(env.clone(), policy).unwrap(); - - // Active -> Suspended (should work) - let suspend_result = - insurance::suspend_policy(env.clone(), policy_id.clone(), insurer.clone()).is_ok(); - - // Suspended -> Cancelled (should work) - let cancel_result = - insurance::cancel_policy(env.clone(), policy_id.clone(), holder.clone()).is_ok(); - - // Cancelled -> Suspended (should fail) - let suspend_again_result = - insurance::suspend_policy(env.clone(), policy_id.clone(), insurer.clone()).is_err(); - - (suspend_result, cancel_result, suspend_again_result) - }); - - assert!(suspend_ok); - assert!(cancel_after_suspend_ok); - assert!(suspend_cancelled_err); -} diff --git a/contracts/assetsup/src/tests/integration.rs b/contracts/assetsup/src/tests/integration.rs index 188139fd..f1116749 100644 --- a/contracts/assetsup/src/tests/integration.rs +++ b/contracts/assetsup/src/tests/integration.rs @@ -2,257 +2,229 @@ extern crate std; -use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, BigInt, Env, String}; -use crate::detokenization; -use crate::dividends; use crate::tokenization; -use crate::transfer_restrictions; use crate::types::AssetType; use crate::voting; -use crate::AssetUpContract; +use crate::detokenization; +use crate::dividends; +use crate::transfer_restrictions; /// Integration test: Full tokenization workflow #[test] fn test_full_tokenization_workflow() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let holder3 = Address::generate(&env); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let holder3 = Address::random(&env); let asset_id = 5000u64; - env.as_contract(&contract_id, || { - // Step 1: Tokenize asset - let tokenized = tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "INTEGRATION"), - 1000, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Integration Test"), - description: String::from_str(&env, "Full workflow test"), - asset_type: AssetType::Physical, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) + // Step 1: Tokenize asset + let tokenized = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "INTEGRATION"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Integration Test"), + description: String::from_str(&env, "Full workflow test"), + asset_type: AssetType::Physical, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + assert_eq!(tokenized.total_supply, BigInt::from_i128(&env, 1000)); + + // Step 2: Transfer tokens to other holders + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 400)) + .unwrap(); + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), BigInt::from_i128(&env, 200)) .unwrap(); - assert_eq!(tokenized.total_supply, 1000_i128); - - // Step 2: Transfer tokens to other holders - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 400) - .unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), 200) - .unwrap(); - - // Verify balances - let tokenizer_balance = - tokenization::get_token_balance(&env, asset_id, tokenizer.clone()).unwrap(); - let holder2_balance = - tokenization::get_token_balance(&env, asset_id, holder2.clone()).unwrap(); - let holder3_balance = - tokenization::get_token_balance(&env, asset_id, holder3.clone()).unwrap(); - - assert_eq!(tokenizer_balance, 400_i128); // 1000 - 400 - 200 - assert_eq!(holder2_balance, 400_i128); - assert_eq!(holder3_balance, 200_i128); - - // Step 3: Calculate ownership percentages - let tokenizer_pct = - tokenization::calculate_ownership_percentage(&env, asset_id, tokenizer.clone()) - .unwrap(); - let holder2_pct = - tokenization::calculate_ownership_percentage(&env, asset_id, holder2.clone()).unwrap(); - let holder3_pct = - tokenization::calculate_ownership_percentage(&env, asset_id, holder3.clone()).unwrap(); - - // Percentages in basis points: 40% = 4000, 40% = 4000, 20% = 2000 - assert_eq!(tokenizer_pct, 4000_i128); - assert_eq!(holder2_pct, 4000_i128); - assert_eq!(holder3_pct, 2000_i128); - - // Step 4: Set transfer restrictions - let restriction = crate::types::TransferRestriction { - require_accredited: false, - geographic_allowed: soroban_sdk::Vec::new(&env), - }; - transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction).unwrap(); - - // Step 5: Enable dividends and distribute - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - dividends::distribute_dividends(&env, asset_id, 1000).unwrap(); - - // Verify dividend distribution - let tokenizer_div = - dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let holder2_div = - dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); - let holder3_div = - dividends::get_unclaimed_dividends(&env, asset_id, holder3.clone()).unwrap(); - - // Should be proportional to ownership - assert_eq!(tokenizer_div, 400_i128); - assert_eq!(holder2_div, 400_i128); - assert_eq!(holder3_div, 200_i128); - - // Step 6: Claim dividends - let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - assert_eq!(claimed, 400_i128); - - // Step 7: Propose detokenization - let proposer = Address::generate(&env); - let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); - - // Step 8: Vote on detokenization - voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, proposal_id, holder2.clone()).unwrap(); - - // Step 9: Check vote tally - let tally = voting::get_vote_tally(&env, asset_id, proposal_id).unwrap(); - // 400 + 400 = 800 (> 500 which is 50%) - assert_eq!(tally, 800_i128); - - // Step 10: Check if passed and execute - let passed = voting::proposal_passed(&env, asset_id, proposal_id).unwrap(); - assert!(passed); - - let execute_result = detokenization::execute_detokenization(&env, asset_id, proposal_id); - assert!(execute_result.is_ok()); - }); + // Verify balances + let tokenizer_balance = tokenization::get_token_balance(&env, asset_id, tokenizer.clone()).unwrap(); + let holder2_balance = tokenization::get_token_balance(&env, asset_id, holder2.clone()).unwrap(); + let holder3_balance = tokenization::get_token_balance(&env, asset_id, holder3.clone()).unwrap(); + + assert_eq!(tokenizer_balance, BigInt::from_i128(&env, 400)); // 1000 - 400 - 200 + assert_eq!(holder2_balance, BigInt::from_i128(&env, 400)); + assert_eq!(holder3_balance, BigInt::from_i128(&env, 200)); + + // Step 3: Calculate ownership percentages + let tokenizer_pct = tokenization::calculate_ownership_percentage(&env, asset_id, tokenizer.clone()).unwrap(); + let holder2_pct = tokenization::calculate_ownership_percentage(&env, asset_id, holder2.clone()).unwrap(); + let holder3_pct = tokenization::calculate_ownership_percentage(&env, asset_id, holder3.clone()).unwrap(); + + // Percentages in basis points: 40% = 4000, 40% = 4000, 20% = 2000 + assert_eq!(tokenizer_pct, BigInt::from_i128(&env, 4000)); + assert_eq!(holder2_pct, BigInt::from_i128(&env, 4000)); + assert_eq!(holder3_pct, BigInt::from_i128(&env, 2000)); + + // Step 4: Set transfer restrictions + let restriction = crate::types::TransferRestriction { + require_accredited: false, + geographic_allowed: soroban_sdk::Vec::new(&env), + }; + transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction).unwrap(); + + // Step 5: Enable dividends and distribute + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)).unwrap(); + + // Verify dividend distribution + let tokenizer_div = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + let holder2_div = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); + let holder3_div = dividends::get_unclaimed_dividends(&env, asset_id, holder3.clone()).unwrap(); + + // Should be proportional to ownership + assert_eq!(tokenizer_div, BigInt::from_i128(&env, 400)); + assert_eq!(holder2_div, BigInt::from_i128(&env, 400)); + assert_eq!(holder3_div, BigInt::from_i128(&env, 200)); + + // Step 6: Claim dividends + let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + assert_eq!(claimed, BigInt::from_i128(&env, 400)); + + // Step 7: Propose detokenization + let proposer = Address::random(&env); + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); + + // Step 8: Vote on detokenization + voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); + voting::cast_vote(&env, asset_id, proposal_id, holder2.clone()).unwrap(); + + // Step 9: Check vote tally + let tally = voting::get_vote_tally(&env, asset_id, proposal_id).unwrap(); + // 400 + 400 = 800 (> 500 which is 50%) + assert_eq!(tally, BigInt::from_i128(&env, 800)); + + // Step 10: Check if passed and execute + let passed = voting::proposal_passed(&env, asset_id, proposal_id).unwrap(); + assert!(passed); + + let execute_result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(execute_result.is_ok()); } /// Test: Multiple dividend distributions #[test] fn test_multiple_dividend_distributions() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); let asset_id = 5001u64; - let (unclaimed, unclaimed2) = env.as_contract(&contract_id, || { - // Setup - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "MULTIDIV"), - 1000, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Multiple Dividends"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) + // Setup + tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "MULTIDIV"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Multiple Dividends"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Transfer 500 to holder2 + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 500)) .unwrap(); - // Transfer 500 to holder2 - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 500) - .unwrap(); + // Enable dividends + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); - // Enable dividends - dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + // First distribution + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 500)).unwrap(); - // First distribution - dividends::distribute_dividends(&env, asset_id, 500).unwrap(); - // Second distribution - dividends::distribute_dividends(&env, asset_id, 500).unwrap(); + // Second distribution + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 500)).unwrap(); - // Should accumulate - let u1 = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); - let u2 = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); - (u1, u2) - }); + // Should accumulate + let unclaimed = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + assert_eq!(unclaimed, BigInt::from_i128(&env, 500)); // 250 + 250 - assert_eq!(unclaimed, 500_i128); // 250 + 250 - assert_eq!(unclaimed2, 500_i128); // 250 + 250 + let unclaimed2 = dividends::get_unclaimed_dividends(&env, asset_id, holder2).unwrap(); + assert_eq!(unclaimed2, BigInt::from_i128(&env, 500)); // 250 + 250 } /// Test: Token locking and voting #[test] fn test_locked_tokens_with_voting() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); env.ledger().with_mut(|li| { li.timestamp = 1000; }); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); let asset_id = 5002u64; - let (transfer_blocked, vote_ok) = env.as_contract(&contract_id, || { - // Setup - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "LOCKV"), - 1000, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Locked Voting"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) + // Setup + tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "LOCKV"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Locked Voting"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Transfer to holder2 + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 600)) .unwrap(); - // Transfer to holder2 - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 600) - .unwrap(); + // Lock holder2's tokens until timestamp 5000 + tokenization::lock_tokens(&env, asset_id, holder2.clone(), 5000).unwrap(); - // Lock holder2's tokens until timestamp 5000 (tokenizer is the caller/authorizer) - tokenization::lock_tokens(&env, asset_id, holder2.clone(), 5000, tokenizer.clone()) - .unwrap(); + // Try to transfer (should fail) + let transfer_result = + tokenization::transfer_tokens(&env, asset_id, holder2.clone(), tokenizer.clone(), BigInt::from_i128(&env, 100)); + assert!(transfer_result.is_err()); - // Try to transfer (should fail because locked) - let transfer_blocked = - tokenization::transfer_tokens(&env, asset_id, holder2.clone(), tokenizer.clone(), 100) - .is_err(); - - // But can still vote (locked tokens still count for voting) - let vote_ok = voting::cast_vote(&env, asset_id, 1, holder2.clone()).is_ok(); - - (transfer_blocked, vote_ok) - }); - - assert!(transfer_blocked); - assert!(vote_ok); // Locked tokens still count for voting + // But can still vote + let vote_result = voting::cast_vote(&env, asset_id, 1, holder2.clone()); + assert!(vote_result.is_ok()); // Locked tokens still count for voting // Advance time past lock env.ledger().with_mut(|li| { li.timestamp = 6000; }); - env.as_contract(&contract_id, || { - // Unlock and try transfer again - tokenization::unlock_tokens(&env, asset_id, holder2.clone()).unwrap(); - let transfer_result = - tokenization::transfer_tokens(&env, asset_id, holder2.clone(), tokenizer.clone(), 100); - assert!(transfer_result.is_ok()); - }); + // Unlock and try transfer again + tokenization::unlock_tokens(&env, asset_id, holder2.clone()).unwrap(); + let transfer_result = + tokenization::transfer_tokens(&env, asset_id, holder2.clone(), tokenizer.clone(), BigInt::from_i128(&env, 100)); + assert!(transfer_result.is_ok()); } diff --git a/contracts/assetsup/src/tests/integration_full.rs b/contracts/assetsup/src/tests/integration_full.rs deleted file mode 100644 index a3527da9..00000000 --- a/contracts/assetsup/src/tests/integration_full.rs +++ /dev/null @@ -1,255 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::AssetType; -use soroban_sdk::String; - -#[test] -fn test_full_asset_tokenization_workflow() { - let env = create_env(); - let (admin, owner, investor1, investor2) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Step 1: Register asset - let asset_id_bytes = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &owner, asset_id_bytes.clone()); - client.register_asset(&asset, &admin); - - // Step 2: Tokenize asset - let asset_id = 1u64; - client.tokenize_asset( - &asset_id, - &String::from_str(&env, "PROP"), - &1000000i128, - &6u32, - &1000i128, - &owner, - &String::from_str(&env, "Property Token"), - &String::from_str(&env, "Tokenized real estate"), - &AssetType::Physical, - ); - - // Step 3: Distribute tokens to investors - client.transfer_tokens(&asset_id, &owner, &investor1, &400000i128); - client.transfer_tokens(&asset_id, &owner, &investor2, &300000i128); - - // Verify ownership distribution - assert_eq!(client.get_token_balance(&asset_id, &owner), 300000); - assert_eq!(client.get_token_balance(&asset_id, &investor1), 400000); - assert_eq!(client.get_token_balance(&asset_id, &investor2), 300000); - - // Step 4: Enable revenue sharing and distribute dividends - client.enable_revenue_sharing(&asset_id); - client.distribute_dividends(&asset_id, &10000i128); - - // Verify dividend distribution - assert_eq!(client.get_unclaimed_dividends(&asset_id, &owner), 3000); - assert_eq!(client.get_unclaimed_dividends(&asset_id, &investor1), 4000); - assert_eq!(client.get_unclaimed_dividends(&asset_id, &investor2), 3000); - - // Step 5: Claim dividends - let claimed = client.claim_dividends(&asset_id, &investor1); - assert_eq!(claimed, 4000); - assert_eq!(client.get_unclaimed_dividends(&asset_id, &investor1), 0); -} - -#[test] -fn test_governance_and_detokenization_workflow() { - let env = create_env(); - let (admin, owner, investor1, investor2) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Setup: Tokenize asset - let asset_id = 1u64; - client.tokenize_asset( - &asset_id, - &String::from_str(&env, "GOV"), - &1000000i128, - &6u32, - &1000i128, - &owner, - &String::from_str(&env, "Governance Token"), - &String::from_str(&env, "Token with voting"), - &AssetType::Physical, - ); - - // Distribute tokens - client.transfer_tokens(&asset_id, &owner, &investor1, &600000i128); - client.transfer_tokens(&asset_id, &owner, &investor2, &200000i128); - - // Propose detokenization - let proposal_id = client.propose_detokenization(&asset_id, &owner); - - // Vote on proposal - client.cast_vote(&asset_id, &proposal_id, &investor1); - - // Check if proposal passed - assert!(client.proposal_passed(&asset_id, &proposal_id)); - - // Execute detokenization - client.execute_detokenization(&asset_id, &proposal_id); - - // Verify asset is detokenized - assert!(!client.is_detokenization_active(&asset_id)); -} - -#[test] -fn test_transfer_restrictions_workflow() { - let env = create_env(); - let (admin, owner, investor1, investor2) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Setup: Tokenize asset - let asset_id = 1u64; - client.tokenize_asset( - &asset_id, - &String::from_str(&env, "REST"), - &1000000i128, - &6u32, - &1000i128, - &owner, - &String::from_str(&env, "Restricted Token"), - &String::from_str(&env, "Token with restrictions"), - &AssetType::Physical, - ); - - // Set transfer restrictions - client.set_transfer_restriction(&asset_id, &true); - - // Add investor1 to whitelist - client.add_to_whitelist(&asset_id, &investor1); - - // Transfer to whitelisted address should succeed - client.transfer_tokens(&asset_id, &owner, &investor1, &100000i128); - assert_eq!(client.get_token_balance(&asset_id, &investor1), 100000); - - // Verify whitelist - assert!(client.is_whitelisted(&asset_id, &investor1)); - assert!(!client.is_whitelisted(&asset_id, &investor2)); -} - -#[test] -fn test_multi_asset_management() { - let env = create_env(); - let (admin, owner, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Register multiple assets - for i in 1..=5 { - let asset_id = generate_asset_id(&env, i); - let asset = create_test_asset(&env, &owner, asset_id); - client.register_asset(&asset, &admin); - } - - // Verify total count - assert_eq!(client.get_total_asset_count(), 5); - - // Verify owner has all assets - let owner_assets = client.get_assets_by_owner(&owner); - assert_eq!(owner_assets.len(), 5); -} - -#[test] -fn test_token_locking_workflow() { - let env = create_env(); - let (admin, owner, investor, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Setup: Tokenize and transfer - let asset_id = 1u64; - client.tokenize_asset( - &asset_id, - &String::from_str(&env, "LOCK"), - &1000000i128, - &6u32, - &1000i128, - &owner, - &String::from_str(&env, "Lockable Token"), - &String::from_str(&env, "Token with locking"), - &AssetType::Physical, - ); - - client.transfer_tokens(&asset_id, &owner, &investor, &500000i128); - - // Lock investor's tokens - let lock_until = env.ledger().timestamp() + 1000; - client.lock_tokens(&asset_id, &investor, &lock_until, &owner); - - // Verify tokens are locked - assert!(client.is_tokens_locked(&asset_id, &investor)); - - // Unlock tokens - client.unlock_tokens(&asset_id, &investor); - - // Verify tokens are unlocked - assert!(!client.is_tokens_locked(&asset_id, &investor)); - - // Transfer should now succeed - client.transfer_tokens(&asset_id, &investor, &owner, &100000i128); -} - -#[test] -fn test_insurance_and_asset_integration() { - let env = create_env(); - let (admin, owner, insurer, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Register asset - let asset_id = generate_asset_id(&env, 1); - let asset = create_test_asset(&env, &owner, asset_id.clone()); - client.register_asset(&asset, &admin); - - // Create insurance policy for asset - let policy_id = generate_asset_id(&env, 100); - let policy = create_test_policy(&env, policy_id.clone(), &owner, &insurer, asset_id.clone()); - client.create_insurance_policy(&policy); - - // Verify policy exists - let stored_policy = client.get_insurance_policy(&policy_id); - assert!(stored_policy.is_some()); - - // Verify asset has policy - let asset_policies = client.get_asset_insurance_policies(&asset_id); - assert_eq!(asset_policies.len(), 1); -} - -#[test] -fn test_admin_operations_workflow() { - let env = create_env(); - let (admin, new_admin, registrar, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Add authorized registrar - client.add_authorized_registrar(®istrar); - assert!(client.is_authorized_registrar(®istrar)); - - // Pause contract - client.pause_contract(); - assert!(client.is_paused()); - - // Unpause contract - client.unpause_contract(); - assert!(!client.is_paused()); - - // Update admin - client.update_admin(&new_admin); - assert_eq!(client.get_admin(), new_admin); - - // Verify old admin is no longer authorized registrar - assert!(!client.is_authorized_registrar(&admin)); - - // Verify new admin is authorized registrar - assert!(client.is_authorized_registrar(&new_admin)); -} diff --git a/contracts/assetsup/src/tests/leasing.rs b/contracts/assetsup/src/tests/leasing.rs new file mode 100644 index 00000000..b72ba48f --- /dev/null +++ b/contracts/assetsup/src/tests/leasing.rs @@ -0,0 +1,312 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BytesN, Env}; + +use crate::error::Error; +use crate::leasing::{self, LeaseStatus}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn make_id(env: &Env, seed: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[31] = seed; + BytesN::from_array(env, &bytes) +} + +/// Build and store a default active lease; returns (asset_id, lease_id, lessor, lessee). +fn create_default_lease( + env: &Env, +) -> (BytesN<32>, BytesN<32>, Address, Address) { + let asset_id = make_id(env, 1); + let lease_id = make_id(env, 2); + let lessor = Address::random(env); + let lessee = Address::random(env); + + leasing::create_lease( + env, + asset_id.clone(), + lease_id.clone(), + lessor.clone(), + lessee.clone(), + /* start */ 500, + /* end */ 2000, + /* rent */ 100, + /* dep */ 500, + ) + .unwrap(); + + (asset_id, lease_id, lessor, lessee) +} + +// ── create_lease ───────────────────────────────────────────────────────────── + +#[test] +fn test_create_lease_success() { + let env = Env::default(); + let (asset_id, lease_id, lessor, lessee) = create_default_lease(&env); + + let lease = leasing::get_lease(&env, lease_id.clone()).unwrap(); + + assert_eq!(lease.lease_id, lease_id); + assert_eq!(lease.asset_id, asset_id); + assert_eq!(lease.lessor, lessor); + assert_eq!(lease.lessee, lessee); + assert_eq!(lease.start_timestamp, 500); + assert_eq!(lease.end_timestamp, 2000); + assert_eq!(lease.rent_per_period, 100); + assert_eq!(lease.deposit, 500); + assert_eq!(lease.status, LeaseStatus::Active); + assert_eq!(lease.returned_at, 0); +} + +#[test] +fn test_create_lease_registers_active_lease_for_asset() { + let env = Env::default(); + let (asset_id, lease_id, _, _) = create_default_lease(&env); + + let active = leasing::get_asset_active_lease(&env, asset_id.clone()); + assert_eq!(active, Some(lease_id)); +} + +#[test] +fn test_create_lease_records_in_lessee_history() { + let env = Env::default(); + let (_, lease_id, _, lessee) = create_default_lease(&env); + + let leases = leasing::get_lessee_leases(&env, lessee); + assert_eq!(leases.len(), 1); + assert_eq!(leases.get(0).unwrap(), lease_id); +} + +#[test] +fn test_create_lease_duplicate_lease_id_fails() { + let env = Env::default(); + let (asset_id, lease_id, lessor, lessee) = create_default_lease(&env); + + // Use a different asset so AssetAlreadyLeased doesn't fire first. + let other_asset = make_id(&env, 99); + let err = leasing::create_lease( + &env, other_asset, lease_id, lessor, lessee, 500, 2000, 100, 0, + ) + .unwrap_err(); + + assert_eq!(err, Error::LeaseAlreadyExists); + let _ = asset_id; +} + +#[test] +fn test_create_lease_asset_already_leased_fails() { + let env = Env::default(); + let (asset_id, _, lessor, lessee) = create_default_lease(&env); + + let second_lease_id = make_id(&env, 50); + let err = leasing::create_lease( + &env, asset_id, second_lease_id, lessor, lessee, 500, 2000, 100, 0, + ) + .unwrap_err(); + + assert_eq!(err, Error::AssetAlreadyLeased); +} + +#[test] +fn test_create_lease_invalid_period_fails() { + let env = Env::default(); + let asset_id = make_id(&env, 10); + let lease_id = make_id(&env, 11); + let lessor = Address::random(&env); + let lessee = Address::random(&env); + + // end == start → invalid + let err = + leasing::create_lease(&env, asset_id.clone(), lease_id.clone(), lessor.clone(), lessee.clone(), 1000, 1000, 100, 0) + .unwrap_err(); + assert_eq!(err, Error::InvalidLeasePeriod); + + // end < start → invalid + let err2 = + leasing::create_lease(&env, asset_id, lease_id, lessor, lessee, 2000, 1000, 100, 0) + .unwrap_err(); + assert_eq!(err2, Error::InvalidLeasePeriod); +} + +#[test] +fn test_create_lease_zero_rent_fails() { + let env = Env::default(); + let asset_id = make_id(&env, 20); + let lease_id = make_id(&env, 21); + let lessor = Address::random(&env); + let lessee = Address::random(&env); + + let err = + leasing::create_lease(&env, asset_id, lease_id, lessor, lessee, 500, 2000, 0, 0) + .unwrap_err(); + assert_eq!(err, Error::InvalidRentAmount); +} + +// ── return_asset ───────────────────────────────────────────────────────────── + +#[test] +fn test_return_asset_by_lessee_success() { + let env = Env::default(); + let (asset_id, lease_id, _, lessee) = create_default_lease(&env); + + leasing::return_asset(&env, lease_id.clone(), lessee).unwrap(); + + let lease = leasing::get_lease(&env, lease_id).unwrap(); + assert_eq!(lease.status, LeaseStatus::Returned); + + // Active lease marker must be cleared. + assert_eq!(leasing::get_asset_active_lease(&env, asset_id), None); +} + +#[test] +fn test_return_asset_by_lessor_success() { + let env = Env::default(); + let (_, lease_id, lessor, _) = create_default_lease(&env); + + leasing::return_asset(&env, lease_id.clone(), lessor).unwrap(); + + let lease = leasing::get_lease(&env, lease_id).unwrap(); + assert_eq!(lease.status, LeaseStatus::Returned); +} + +#[test] +fn test_return_asset_by_stranger_fails() { + let env = Env::default(); + let (_, lease_id, _, _) = create_default_lease(&env); + let stranger = Address::random(&env); + + let err = leasing::return_asset(&env, lease_id, stranger).unwrap_err(); + assert_eq!(err, Error::Unauthorized); +} + +#[test] +fn test_return_asset_already_returned_fails() { + let env = Env::default(); + let (_, lease_id, _, lessee) = create_default_lease(&env); + + leasing::return_asset(&env, lease_id.clone(), lessee.clone()).unwrap(); + + let err = leasing::return_asset(&env, lease_id, lessee).unwrap_err(); + assert_eq!(err, Error::LeaseNotActive); +} + +#[test] +fn test_return_asset_not_found_fails() { + let env = Env::default(); + let nonexistent = make_id(&env, 200); + let caller = Address::random(&env); + + let err = leasing::return_asset(&env, nonexistent, caller).unwrap_err(); + assert_eq!(err, Error::LeaseNotFound); +} + +// ── cancel_lease ───────────────────────────────────────────────────────────── + +#[test] +fn test_cancel_lease_before_start_success() { + let env = Env::default(); + // Default ledger timestamp is 0; start_timestamp is 500, so it hasn't started yet. + let (asset_id, lease_id, lessor, _) = create_default_lease(&env); + + leasing::cancel_lease(&env, lease_id.clone(), lessor).unwrap(); + + let lease = leasing::get_lease(&env, lease_id).unwrap(); + assert_eq!(lease.status, LeaseStatus::Cancelled); + assert_eq!(leasing::get_asset_active_lease(&env, asset_id), None); +} + +#[test] +fn test_cancel_lease_after_start_fails() { + let env = Env::default(); + let (_, lease_id, lessor, _) = create_default_lease(&env); + + // Advance ledger past start_timestamp (500). + env.ledger().with_mut(|li| li.timestamp = 600); + + let err = leasing::cancel_lease(&env, lease_id, lessor).unwrap_err(); + assert_eq!(err, Error::LeaseAlreadyStarted); +} + +#[test] +fn test_cancel_lease_by_lessee_fails() { + let env = Env::default(); + let (_, lease_id, _, lessee) = create_default_lease(&env); + + let err = leasing::cancel_lease(&env, lease_id, lessee).unwrap_err(); + assert_eq!(err, Error::Unauthorized); +} + +// ── expire_lease ───────────────────────────────────────────────────────────── + +#[test] +fn test_expire_lease_after_end_success() { + let env = Env::default(); + let (asset_id, lease_id, _, _) = create_default_lease(&env); + + // Advance past end_timestamp (2000). + env.ledger().with_mut(|li| li.timestamp = 2001); + + leasing::expire_lease(&env, lease_id.clone()).unwrap(); + + let lease = leasing::get_lease(&env, lease_id).unwrap(); + assert_eq!(lease.status, LeaseStatus::Expired); + assert_eq!(leasing::get_asset_active_lease(&env, asset_id), None); +} + +#[test] +fn test_expire_lease_before_end_fails() { + let env = Env::default(); + let (_, lease_id, _, _) = create_default_lease(&env); + + // Ledger is at 0, end_timestamp is 2000 — not yet expired. + let err = leasing::expire_lease(&env, lease_id).unwrap_err(); + assert_eq!(err, Error::LeaseNotExpired); +} + +#[test] +fn test_expire_already_returned_fails() { + let env = Env::default(); + let (_, lease_id, _, lessee) = create_default_lease(&env); + + leasing::return_asset(&env, lease_id.clone(), lessee).unwrap(); + + env.ledger().with_mut(|li| li.timestamp = 3000); + + let err = leasing::expire_lease(&env, lease_id).unwrap_err(); + assert_eq!(err, Error::LeaseNotActive); +} + +// ── get_lessee_leases ───────────────────────────────────────────────────────── + +#[test] +fn test_lessee_accumulates_multiple_leases() { + let env = Env::default(); + let lessor = Address::random(&env); + let lessee = Address::random(&env); + + // First lease + let asset1 = make_id(&env, 30); + let id1 = make_id(&env, 31); + leasing::create_lease(&env, asset1, id1.clone(), lessor.clone(), lessee.clone(), 100, 200, 10, 0).unwrap(); + + // Second lease on a different asset + let asset2 = make_id(&env, 32); + let id2 = make_id(&env, 33); + leasing::create_lease(&env, asset2, id2.clone(), lessor, lessee.clone(), 100, 200, 10, 0).unwrap(); + + let leases = leasing::get_lessee_leases(&env, lessee); + assert_eq!(leases.len(), 2); + assert_eq!(leases.get(0).unwrap(), id1); + assert_eq!(leases.get(1).unwrap(), id2); +} + +#[test] +fn test_get_lessee_leases_empty_for_unknown_address() { + let env = Env::default(); + let nobody = Address::random(&env); + let leases = leasing::get_lessee_leases(&env, nobody); + assert_eq!(leases.len(), 0); +} diff --git a/contracts/assetsup/src/tests/mod.rs b/contracts/assetsup/src/tests/mod.rs index a44a81fd..1a179f1c 100644 --- a/contracts/assetsup/src/tests/mod.rs +++ b/contracts/assetsup/src/tests/mod.rs @@ -1,30 +1,13 @@ -// Test helper functions and utilities -mod helpers; - -// Core contract tests -mod admin; +mod access_control; mod asset; -mod audit_trail; -mod initialization; - -// Tokenization and ownership tests -mod detokenization; -mod dividends; -mod tokenization; -mod transfer_restrictions; -mod voting; - -// Insurance tests -mod insurance; - -// Integration tests -mod integration_full; - -// Legacy test modules (if still needed) -mod detokenization_new; -mod dividends_new; -mod insurance_new; -mod integration; +mod branch; +mod initialize; +mod transfer; +mod types; mod tokenization_new; -mod transfer_restrictions_new; mod voting_new; +mod dividends_new; +mod transfer_restrictions_new; +mod detokenization_new; +mod integration; +mod leasing; diff --git a/contracts/assetsup/src/tests/tokenization.rs b/contracts/assetsup/src/tests/tokenization.rs deleted file mode 100644 index bc1a0245..00000000 --- a/contracts/assetsup/src/tests/tokenization.rs +++ /dev/null @@ -1,463 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::AssetType; -use soroban_sdk::String; - -#[test] -fn test_tokenize_asset_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - let result = client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - assert_eq!(result.asset_id, 1); - assert_eq!(result.total_supply, 1000000); - assert_eq!(result.tokenizer, user1); - assert_eq!(result.tokens_in_circulation, 1000000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #10)")] -fn test_tokenize_asset_already_tokenized() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Try to tokenize again - should panic with AssetAlreadyTokenized - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST2"), - &500000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token 2"), - &String::from_str(&env, "Another test"), - &AssetType::Physical, - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #12)")] -fn test_tokenize_asset_invalid_supply() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Should panic with InvalidTokenSupply error - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &0i128, // Invalid: zero supply - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); -} - -#[test] -fn test_mint_tokens_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - let result = client.mint_tokens(&1u64, &500000i128, &user1); - - assert_eq!(result.total_supply, 1500000); - assert_eq!(result.tokens_in_circulation, 1500000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_mint_tokens_unauthorized() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // user2 is not tokenizer - should panic with Unauthorized - client.mint_tokens(&1u64, &500000i128, &user2); -} - -#[test] -#[should_panic(expected = "Error(Contract, #11)")] -fn test_mint_tokens_not_tokenized() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Should panic with AssetNotTokenized error - client.mint_tokens(&999u64, &500000i128, &user1); -} - -#[test] -fn test_burn_tokens_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - let result = client.burn_tokens(&1u64, &200000i128, &user1); - - assert_eq!(result.total_supply, 800000); - assert_eq!(result.tokens_in_circulation, 800000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #14)")] -fn test_burn_tokens_insufficient_balance() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Should panic with InsufficientBalance error - client.burn_tokens(&1u64, &2000000i128, &user1); -} - -#[test] -fn test_transfer_tokens_success() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - - // Verify balances - let balance1 = client.get_token_balance(&1u64, &user1); - let balance2 = client.get_token_balance(&1u64, &user2); - - assert_eq!(balance1, 700000); - assert_eq!(balance2, 300000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #14)")] -fn test_transfer_tokens_insufficient_balance() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Should panic with InsufficientBalance error - client.transfer_tokens(&1u64, &user1, &user2, &2000000i128); -} - -#[test] -#[should_panic(expected = "Error(Contract, #16)")] -fn test_transfer_tokens_locked() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Lock tokens - let future_time = env.ledger().timestamp() + 1000; - client.lock_tokens(&1u64, &user1, &future_time, &user1); - - // Should panic with TokensAreLocked error - client.transfer_tokens(&1u64, &user1, &user2, &100000i128); -} - -#[test] -fn test_lock_unlock_tokens() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Initially not locked - assert!(!client.is_tokens_locked(&1u64, &user1)); - - // Lock tokens - let future_time = env.ledger().timestamp() + 1000; - client.lock_tokens(&1u64, &user1, &future_time, &user1); - - assert!(client.is_tokens_locked(&1u64, &user1)); - - // Unlock tokens - client.unlock_tokens(&1u64, &user1); - - assert!(!client.is_tokens_locked(&1u64, &user1)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn test_lock_tokens_unauthorized() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - let future_time = env.ledger().timestamp() + 1000; - - // user2 is not tokenizer - should panic with Unauthorized - client.lock_tokens(&1u64, &user1, &future_time, &user2); -} - -#[test] -fn test_get_ownership_percentage() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Transfer 30% to user2 - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - - // Check ownership percentages (in basis points) - let percentage1 = client.get_ownership_percentage(&1u64, &user1); - let percentage2 = client.get_ownership_percentage(&1u64, &user2); - - assert_eq!(percentage1, 7000); // 70% - assert_eq!(percentage2, 3000); // 30% -} - -#[test] -fn test_get_token_holders() { - let env = create_env(); - let (admin, user1, user2, user3) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Initially only user1 - let holders = client.get_token_holders(&1u64); - assert_eq!(holders.len(), 1); - - // Transfer to user2 and user3 - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - client.transfer_tokens(&1u64, &user1, &user3, &200000i128); - - // Now should have 3 holders - let holders = client.get_token_holders(&1u64); - assert_eq!(holders.len(), 3); -} - -#[test] -fn test_update_valuation() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.update_valuation(&1u64, &2000000i128); - - let asset = client.get_tokenized_asset(&1u64); - assert_eq!(asset.valuation, 2000000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #30)")] -fn test_update_valuation_invalid() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Should panic with InvalidValuation error - client.update_valuation(&1u64, &0i128); -} diff --git a/contracts/assetsup/src/tests/tokenization_new.rs b/contracts/assetsup/src/tests/tokenization_new.rs index 4fbed2b6..6d1ec94a 100644 --- a/contracts/assetsup/src/tests/tokenization_new.rs +++ b/contracts/assetsup/src/tests/tokenization_new.rs @@ -2,56 +2,33 @@ extern crate std; -use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, BigInt, Env, String}; +use crate::types::{AssetType, TokenizedAsset}; use crate::tokenization; -use crate::types::AssetType; -use crate::AssetUpContract; fn make_asset_id(seed: u64) -> u64 { seed } -fn setup_tokenized(env: &Env, asset_id: u64, tokenizer: &Address) { - tokenization::tokenize_asset( - env, - asset_id, - String::from_str(env, "TOKEN"), - 1000, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(env, "Lock Test Asset"), - description: String::from_str(env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(env), - }, - ) - .unwrap(); -} - #[test] fn test_tokenize_asset() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); + let tokenizer = Address::random(&env); let asset_id = make_asset_id(100); let symbol = String::from_str(&env, "ASSET100"); - let total_supply = 1000_i128; + let total_supply = BigInt::from_i128(&env, 1000); let decimals = 2u32; - let min_voting_threshold = 100_i128; + let name = String::from_str(&env, "Test Asset"); + let description = String::from_str(&env, "Testing tokenization"); + let asset_type = AssetType::Digital; + let min_voting_threshold = BigInt::from_i128(&env, 100); let metadata = crate::types::TokenMetadata { - name: String::from_str(&env, "Test Asset"), - description: String::from_str(&env, "Testing tokenization"), - asset_type: AssetType::Digital, + name, + description, + asset_type, ipfs_uri: None, legal_docs_hash: None, valuation_report_hash: None, @@ -59,19 +36,17 @@ fn test_tokenize_asset() { geographic_restrictions: soroban_sdk::Vec::new(&env), }; - let tokenized_asset = env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - asset_id, - symbol.clone(), - total_supply, - decimals, - min_voting_threshold, - tokenizer.clone(), - metadata, - ) - .unwrap() - }); + let tokenized_asset = tokenization::tokenize_asset( + &env, + asset_id, + symbol.clone(), + total_supply.clone(), + decimals, + min_voting_threshold, + tokenizer.clone(), + metadata, + ) + .unwrap(); assert_eq!(tokenized_asset.asset_id, asset_id); assert_eq!(tokenized_asset.symbol, symbol); @@ -84,30 +59,27 @@ fn test_tokenize_asset() { #[test] fn test_tokenize_asset_invalid_supply() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - - let result = env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - 100, - String::from_str(&env, "ASSET100"), - 0, // Invalid supply - 2, - 100, - tokenizer, - crate::types::TokenMetadata { - name: String::from_str(&env, "Test"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) - }); + let tokenizer = Address::random(&env); + + let result = tokenization::tokenize_asset( + &env, + 100, + String::from_str(&env, "ASSET100"), + BigInt::from_i128(&env, 0), // Invalid supply + 2, + BigInt::from_i128(&env, 100), + tokenizer, + crate::types::TokenMetadata { + name: String::from_str(&env, "Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ); assert!(result.is_err()); } @@ -115,383 +87,212 @@ fn test_tokenize_asset_invalid_supply() { #[test] fn test_mint_tokens() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); + let tokenizer = Address::random(&env); let asset_id = make_asset_id(200); - let initial_supply = 500_i128; - let mint_amount = 200_i128; - - let (updated_supply, balance) = env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "AST200"), - initial_supply, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Mint Test"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) - .unwrap(); + let initial_supply = BigInt::from_i128(&env, 500); + let mint_amount = BigInt::from_i128(&env, 200); - let updated = - tokenization::mint_tokens(&env, asset_id, mint_amount, tokenizer.clone()).unwrap(); - let bal = tokenization::get_token_balance(&env, asset_id, tokenizer.clone()).unwrap(); - (updated.total_supply, bal) - }); + // Tokenize first + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST200"), + initial_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Mint Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Mint tokens + let updated_asset = tokenization::mint_tokens(&env, asset_id, mint_amount.clone(), tokenizer.clone()).unwrap(); + + // Verify supply increased + assert_eq!(updated_asset.total_supply, &initial_supply + &mint_amount); - assert_eq!(updated_supply, initial_supply + mint_amount); - assert_eq!(balance, initial_supply + mint_amount); + // Verify tokenizer's balance updated + let balance = tokenization::get_token_balance(&env, asset_id, tokenizer).unwrap(); + assert_eq!(balance, &initial_supply + &mint_amount); } #[test] fn test_burn_tokens() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); + let tokenizer = Address::random(&env); let asset_id = make_asset_id(300); - let initial_supply = 1000_i128; - let burn_amount = 400_i128; - - let new_supply = env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "AST300"), - initial_supply, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Burn Test"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) - .unwrap(); + let initial_supply = BigInt::from_i128(&env, 1000); + let burn_amount = BigInt::from_i128(&env, 400); - let updated = - tokenization::burn_tokens(&env, asset_id, burn_amount, tokenizer.clone()).unwrap(); - updated.total_supply - }); + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST300"), + initial_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Burn Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); - assert_eq!(new_supply, 1000_i128 - burn_amount); + // Burn tokens + let updated_asset = tokenization::burn_tokens(&env, asset_id, burn_amount.clone(), tokenizer.clone()).unwrap(); + + // Verify supply decreased + assert_eq!(updated_asset.total_supply, &BigInt::from_i128(&env, 1000) - &burn_amount); } #[test] fn test_transfer_tokens() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let recipient = Address::generate(&env); + let tokenizer = Address::random(&env); + let recipient = Address::random(&env); let asset_id = make_asset_id(400); - let total_supply = 1000_i128; - let transfer_amount = 300_i128; - - let (tokenizer_balance, recipient_balance) = env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "AST400"), - total_supply, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Transfer Test"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) - .unwrap(); + let total_supply = BigInt::from_i128(&env, 1000); + let transfer_amount = BigInt::from_i128(&env, 300); - tokenization::transfer_tokens( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - transfer_amount, - ) + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST400"), + total_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Transfer Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Transfer + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), recipient.clone(), transfer_amount.clone()) .unwrap(); - let tb = tokenization::get_token_balance(&env, asset_id, tokenizer.clone()).unwrap(); - let rb = tokenization::get_token_balance(&env, asset_id, recipient.clone()).unwrap(); - (tb, rb) - }); + // Verify balances + let tokenizer_balance = tokenization::get_token_balance(&env, asset_id, tokenizer).unwrap(); + let recipient_balance = tokenization::get_token_balance(&env, asset_id, recipient).unwrap(); - assert_eq!(tokenizer_balance, 1000_i128 - transfer_amount); + assert_eq!(tokenizer_balance, &BigInt::from_i128(&env, 1000) - &transfer_amount); assert_eq!(recipient_balance, transfer_amount); } #[test] fn test_lock_tokens() { let env = Env::default(); - env.ledger().with_mut(|li| li.timestamp = 1000); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let recipient = Address::generate(&env); + let tokenizer = Address::random(&env); let asset_id = make_asset_id(500); - env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "AST500"), - 1000, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Lock Test"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) - .unwrap(); + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST500"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Lock Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); - tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 5000, tokenizer.clone()) - .unwrap(); - - // Try to transfer (should fail) - let result = tokenization::transfer_tokens( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - 100, - ); - assert!(result.is_err()); - }); + // Lock tokens until timestamp 5000 + tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 5000).unwrap(); + + // Try to transfer (should fail) + let recipient = Address::random(&env); + let result = + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), recipient.clone(), BigInt::from_i128(&env, 100)); + + assert!(result.is_err()); // Advance time past lock period - env.ledger().with_mut(|li| li.timestamp = 6000); - - env.as_contract(&contract_id, || { - // Transfer should now succeed - let result = tokenization::transfer_tokens( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - 100, - ); - assert!(result.is_ok()); + env.ledger().with_mut(|li| { + li.timestamp = 6000; }); + + // Transfer should now succeed + let result = tokenization::transfer_tokens(&env, asset_id, tokenizer, recipient, BigInt::from_i128(&env, 100)); + assert!(result.is_ok()); } #[test] fn test_ownership_percentage() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); + let tokenizer = Address::random(&env); let asset_id = make_asset_id(600); + let total_supply = BigInt::from_i128(&env, 1000); - let percentage = env.as_contract(&contract_id, || { - tokenization::tokenize_asset( - &env, - asset_id, - String::from_str(&env, "AST600"), - 1000, - 2, - 100, - tokenizer.clone(), - crate::types::TokenMetadata { - name: String::from_str(&env, "Percentage Test"), - description: String::from_str(&env, "Test"), - asset_type: AssetType::Digital, - ipfs_uri: None, - legal_docs_hash: None, - valuation_report_hash: None, - accredited_investor_required: false, - geographic_restrictions: soroban_sdk::Vec::new(&env), - }, - ) - .unwrap(); + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST600"), + total_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Percentage Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); - tokenization::calculate_ownership_percentage(&env, asset_id, tokenizer.clone()).unwrap() - }); + // Tokenizer should have 100% ownership + let percentage = tokenization::calculate_ownership_percentage(&env, asset_id, tokenizer).unwrap(); // 100% = 10000 basis points - assert_eq!(percentage, 10000_i128); -} - -// ===================== -// Token Lock Tests -// ===================== - -#[test] -fn test_is_tokens_locked_when_active() { - let env = Env::default(); - env.ledger().with_mut(|li| li.timestamp = 1000); - - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = make_asset_id(700); - - let locked = env.as_contract(&contract_id, || { - setup_tokenized(&env, asset_id, &tokenizer); - // Lock until 5000; current timestamp is 1000 — should be locked - tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 5000, tokenizer.clone()) - .unwrap(); - tokenization::is_tokens_locked(&env, asset_id, tokenizer.clone()) - }); - - assert!(locked); -} - -#[test] -fn test_is_tokens_locked_after_expiry() { - let env = Env::default(); - env.ledger().with_mut(|li| li.timestamp = 1000); - - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let recipient = Address::generate(&env); - let asset_id = make_asset_id(800); - - env.as_contract(&contract_id, || { - setup_tokenized(&env, asset_id, &tokenizer); - // Lock until 2000 - tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 2000, tokenizer.clone()) - .unwrap(); - }); - - // Advance time past the lock - env.ledger().with_mut(|li| li.timestamp = 3000); - - env.as_contract(&contract_id, || { - // Lock has expired — is_tokens_locked should return false - assert!(!tokenization::is_tokens_locked( - &env, - asset_id, - tokenizer.clone() - )); - - // Transfer should also succeed because lock expired - let result = tokenization::transfer_tokens( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - 100, - ); - assert!(result.is_ok()); - }); -} - -#[test] -fn test_unlock_tokens_clears_lock_regardless_of_timestamp() { - let env = Env::default(); - env.ledger().with_mut(|li| li.timestamp = 1000); - - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let recipient = Address::generate(&env); - let asset_id = make_asset_id(900); - - env.as_contract(&contract_id, || { - setup_tokenized(&env, asset_id, &tokenizer); - - // Lock until far future - tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 99999, tokenizer.clone()) - .unwrap(); - assert!(tokenization::is_tokens_locked( - &env, - asset_id, - tokenizer.clone() - )); - - // Unlock while still inside the lock window - tokenization::unlock_tokens(&env, asset_id, tokenizer.clone()).unwrap(); - - // Lock should be gone - assert!(!tokenization::is_tokens_locked( - &env, - asset_id, - tokenizer.clone() - )); - - // Transfer should now succeed even though original lock hasn't "expired" - let result = tokenization::transfer_tokens( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - 100, - ); - assert!(result.is_ok()); - }); -} - -#[test] -fn test_is_tokens_locked_no_lock_returns_false() { - let env = Env::default(); - env.ledger().with_mut(|li| li.timestamp = 1000); - - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = make_asset_id(1000); - - let locked = env.as_contract(&contract_id, || { - setup_tokenized(&env, asset_id, &tokenizer); - // No lock set — should return false - tokenization::is_tokens_locked(&env, asset_id, tokenizer.clone()) - }); - - assert!(!locked); -} - -#[test] -fn test_lock_tokens_unauthorized() { - let env = Env::default(); - env.ledger().with_mut(|li| li.timestamp = 1000); - - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let intruder = Address::generate(&env); - let asset_id = make_asset_id(1100); - - let (lock_result, still_unlocked) = env.as_contract(&contract_id, || { - setup_tokenized(&env, asset_id, &tokenizer); - - // Non-tokenizer tries to lock — should fail - let r = - tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 5000, intruder.clone()); - - // Holder is still unlocked - let unlocked = !tokenization::is_tokens_locked(&env, asset_id, tokenizer.clone()); - (r, unlocked) - }); - - assert!(lock_result.is_err()); - assert!(still_unlocked); + assert_eq!(percentage, BigInt::from_i128(&env, 10000)); } diff --git a/contracts/assetsup/src/tests/transfer_restrictions.rs b/contracts/assetsup/src/tests/transfer_restrictions.rs deleted file mode 100644 index 96fd0523..00000000 --- a/contracts/assetsup/src/tests/transfer_restrictions.rs +++ /dev/null @@ -1,253 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::AssetType; -use soroban_sdk::String; - -#[test] -fn test_add_to_whitelist() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Initially not whitelisted - assert!(!client.is_whitelisted(&1u64, &user2)); - - // Add to whitelist - client.add_to_whitelist(&1u64, &user2); - - assert!(client.is_whitelisted(&1u64, &user2)); -} - -#[test] -fn test_remove_from_whitelist() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Add to whitelist - client.add_to_whitelist(&1u64, &user2); - assert!(client.is_whitelisted(&1u64, &user2)); - - // Remove from whitelist - client.remove_from_whitelist(&1u64, &user2); - assert!(!client.is_whitelisted(&1u64, &user2)); -} - -#[test] -fn test_get_whitelist() { - let env = create_env(); - let (admin, user1, user2, user3) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Add multiple addresses to whitelist - client.add_to_whitelist(&1u64, &user2); - client.add_to_whitelist(&1u64, &user3); - - let whitelist = client.get_whitelist(&1u64); - assert_eq!(whitelist.len(), 2); -} - -#[test] -fn test_add_duplicate_to_whitelist() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Add to whitelist twice - client.add_to_whitelist(&1u64, &user2); - client.add_to_whitelist(&1u64, &user2); - - // Should still only have one entry - let whitelist = client.get_whitelist(&1u64); - assert_eq!(whitelist.len(), 1); -} - -#[test] -fn test_set_transfer_restriction() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Set transfer restriction - client.set_transfer_restriction(&1u64, &true); - - // Restriction should be set (no error means success) -} - -#[test] -fn test_transfer_with_whitelist() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Add user2 to whitelist - client.add_to_whitelist(&1u64, &user2); - - // Transfer should succeed - client.transfer_tokens(&1u64, &user1, &user2, &100000i128); - - let balance = client.get_token_balance(&1u64, &user2); - assert_eq!(balance, 100000); -} - -#[test] -fn test_empty_whitelist() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - let whitelist = client.get_whitelist(&1u64); - assert_eq!(whitelist.len(), 0); -} - -#[test] -#[should_panic] -fn test_transfer_to_non_whitelisted_fails() { - use soroban_sdk::testutils::Address as _; - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let user3 = soroban_sdk::Address::generate(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &2u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Only user2 is whitelisted - client.add_to_whitelist(&2u64, &user2); - - // Transfer to user3 (not whitelisted) should panic with TransferRestricted - client.transfer_tokens(&2u64, &user1, &user3, &100000i128); -} - -#[test] -fn test_empty_whitelist_allows_transfer() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &3u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // No whitelist — transfer should succeed - client.transfer_tokens(&3u64, &user1, &user2, &100000i128); - assert_eq!(client.get_token_balance(&3u64, &user2), 100000); -} diff --git a/contracts/assetsup/src/tests/transfer_restrictions_new.rs b/contracts/assetsup/src/tests/transfer_restrictions_new.rs index c7baed2d..85169706 100644 --- a/contracts/assetsup/src/tests/transfer_restrictions_new.rs +++ b/contracts/assetsup/src/tests/transfer_restrictions_new.rs @@ -2,22 +2,21 @@ extern crate std; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, BigInt, Env, String}; use crate::tokenization; -use crate::transfer_restrictions; use crate::types::{AssetType, TransferRestriction}; -use crate::AssetUpContract; +use crate::transfer_restrictions; -fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { - tokenization::tokenize_asset( +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 900u64; + let _ = tokenization::tokenize_asset( env, asset_id, String::from_str(env, "RESTR"), - 1000, + BigInt::from_i128(env, 1000), 2, - 100, + BigInt::from_i128(env, 100), tokenizer.clone(), crate::types::TokenMetadata { name: String::from_str(env, "Restriction Test"), @@ -29,241 +28,101 @@ fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { accredited_investor_required: false, geographic_restrictions: soroban_sdk::Vec::new(env), }, - ) - .unwrap(); + ); + asset_id } #[test] fn test_set_transfer_restriction() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 900u64; - - let (set_ok, has_restrictions) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let restriction = TransferRestriction { - require_accredited: true, - geographic_allowed: soroban_sdk::Vec::new(&env), - }; + let restriction = TransferRestriction { + require_accredited: true, + geographic_allowed: soroban_sdk::Vec::new(&env), + }; - let ok = - transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction).is_ok(); - let has = transfer_restrictions::has_transfer_restrictions(&env, asset_id).unwrap(); - (ok, has) - }); + let result = transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction); + assert!(result.is_ok()); - assert!(set_ok); + // Verify restriction was set + let has_restrictions = transfer_restrictions::has_transfer_restrictions(&env, asset_id).unwrap(); assert!(has_restrictions); } #[test] fn test_whitelist_operations() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let whitelisted = Address::generate(&env); - let asset_id = 900u64; - - let (is_wl_after_add, list_len, is_wl_after_remove) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + let tokenizer = Address::random(&env); + let whitelisted = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - // Add to whitelist - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + // Add to whitelist + transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - let is_wl_add = - transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted.clone()).unwrap(); - let whitelist = transfer_restrictions::get_whitelist(&env, asset_id).unwrap(); - let len = whitelist.len(); + // Check if whitelisted + let is_wl = transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted.clone()).unwrap(); + assert!(is_wl); - // Remove from whitelist - transfer_restrictions::remove_from_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + // Get whitelist + let whitelist = transfer_restrictions::get_whitelist(&env, asset_id).unwrap(); + assert_eq!(whitelist.len(), 1); - let is_wl_rem = - transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted.clone()).unwrap(); - (is_wl_add, len, is_wl_rem) - }); + // Remove from whitelist + transfer_restrictions::remove_from_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - assert!(is_wl_after_add); - assert_eq!(list_len, 1); - assert!(!is_wl_after_remove); + // Verify removed + let is_wl = transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted).unwrap(); + assert!(!is_wl); } #[test] fn test_whitelist_duplicate_prevention() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let whitelisted = Address::generate(&env); - let asset_id = 900u64; + let tokenizer = Address::random(&env); + let whitelisted = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let list_len = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + // Add to whitelist twice + transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - // Add to whitelist twice - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - - // Should still have only 1 entry - transfer_restrictions::get_whitelist(&env, asset_id) - .unwrap() - .len() - }); - - assert_eq!(list_len, 1); + // Should still have only 1 entry + let whitelist = transfer_restrictions::get_whitelist(&env, asset_id).unwrap(); + assert_eq!(whitelist.len(), 1); } #[test] fn test_validate_transfer_no_restrictions() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let recipient = Address::generate(&env); - let asset_id = 900u64; - - let valid = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Validate transfer when no restrictions exist - transfer_restrictions::validate_transfer( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - ) - .unwrap() - }); + let tokenizer = Address::random(&env); + let recipient = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + // Validate transfer when no restrictions exist + let valid = transfer_restrictions::validate_transfer(&env, asset_id, tokenizer, recipient).unwrap(); assert!(valid); } #[test] fn test_get_transfer_restriction() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 900u64; - - let (before_err, after_require_accredited) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Should fail initially (no restriction) - let before = transfer_restrictions::get_transfer_restriction(&env, asset_id).is_err(); - - // Set restriction - let new_restriction = TransferRestriction { - require_accredited: true, - geographic_allowed: soroban_sdk::Vec::new(&env), - }; - transfer_restrictions::set_transfer_restriction(&env, asset_id, new_restriction).unwrap(); - - // Should now exist - let after = transfer_restrictions::get_transfer_restriction(&env, asset_id).unwrap(); - (before, after.require_accredited) - }); - - assert!(before_err); - assert!(after_require_accredited); -} - -#[test] -fn test_validate_transfer_blocked_when_not_whitelisted() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let whitelisted = Address::generate(&env); - let not_whitelisted = Address::generate(&env); - let asset_id = 901u64; - - let (allowed_result, blocked_result) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Add only `whitelisted` to the whitelist - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - - // Transfer to whitelisted address should be allowed - let allowed = transfer_restrictions::validate_transfer( - &env, - asset_id, - tokenizer.clone(), - whitelisted.clone(), - ); - - // Transfer to non-whitelisted address should be blocked - let blocked = transfer_restrictions::validate_transfer( - &env, - asset_id, - tokenizer.clone(), - not_whitelisted.clone(), - ); - - (allowed, blocked) - }); - - assert!(allowed_result.is_ok()); - assert!(blocked_result.is_err()); -} - -#[test] -fn test_validate_transfer_empty_whitelist_allows_all() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let recipient = Address::generate(&env); - let asset_id = 902u64; - - let result = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // No whitelist entries — transfer should be allowed - transfer_restrictions::validate_transfer( - &env, - asset_id, - tokenizer.clone(), - recipient.clone(), - ) - }); - - assert!(result.is_ok()); - assert!(result.unwrap()); -} - -#[test] -fn test_validate_transfer_accredited_required_uses_whitelist() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let accredited = Address::generate(&env); - let non_accredited = Address::generate(&env); - let asset_id = 903u64; - - let (ok_result, err_result) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Set accredited requirement; whitelist acts as the accredited registry - let restriction = TransferRestriction { - require_accredited: true, - geographic_allowed: soroban_sdk::Vec::new(&env), - }; - transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction).unwrap(); - transfer_restrictions::add_to_whitelist(&env, asset_id, accredited.clone()).unwrap(); - - let ok = transfer_restrictions::validate_transfer( - &env, - asset_id, - tokenizer.clone(), - accredited.clone(), - ); - let err = transfer_restrictions::validate_transfer( - &env, - asset_id, - tokenizer.clone(), - non_accredited.clone(), - ); - (ok, err) - }); - - assert!(ok_result.is_ok()); - assert!(err_result.is_err()); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Should fail initially (no restriction) + let restriction = transfer_restrictions::get_transfer_restriction(&env, asset_id); + assert!(restriction.is_err()); + + // Set restriction + let new_restriction = TransferRestriction { + require_accredited: true, + geographic_allowed: soroban_sdk::Vec::new(&env), + }; + transfer_restrictions::set_transfer_restriction(&env, asset_id, new_restriction.clone()).unwrap(); + + // Should now exist + let restriction = transfer_restrictions::get_transfer_restriction(&env, asset_id).unwrap(); + assert_eq!(restriction.require_accredited, true); } diff --git a/contracts/assetsup/src/tests/voting.rs b/contracts/assetsup/src/tests/voting.rs deleted file mode 100644 index 08568707..00000000 --- a/contracts/assetsup/src/tests/voting.rs +++ /dev/null @@ -1,187 +0,0 @@ -use crate::tests::helpers::*; -use crate::types::AssetType; -use soroban_sdk::String; - -#[test] -fn test_cast_vote_success() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Tokenize asset - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Cast vote - client.cast_vote(&1u64, &1u64, &user1); - - // Verify vote was recorded - assert!(client.has_voted(&1u64, &1u64, &user1)); - - // Verify vote tally - let tally = client.get_vote_tally(&1u64, &1u64); - assert_eq!(tally, 1000000); // Full balance -} - -#[test] -#[should_panic(expected = "Error(Contract, #22)")] -fn test_cast_vote_already_voted() { - let env = create_env(); - let (admin, user1, _, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - client.cast_vote(&1u64, &1u64, &user1); - - // Try to vote again - should panic with AlreadyVoted - client.cast_vote(&1u64, &1u64, &user1); -} - -#[test] -#[should_panic(expected = "Error(Contract, #21)")] -fn test_cast_vote_insufficient_voting_power() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - // Tokenize with high voting threshold - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &500000i128, // 50% threshold - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Transfer small amount to user2 - client.transfer_tokens(&1u64, &user1, &user2, &10000i128); - - // user2 doesn't have enough tokens - should panic with InsufficientVotingPower - client.cast_vote(&1u64, &1u64, &user2); -} - -#[test] -fn test_proposal_passed() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Transfer 60% to user2 - client.transfer_tokens(&1u64, &user1, &user2, &600000i128); - - // user2 votes (60% of supply) - client.cast_vote(&1u64, &1u64, &user2); - - // Proposal should pass (>50% threshold) - assert!(client.proposal_passed(&1u64, &1u64)); -} - -#[test] -fn test_proposal_not_passed() { - let env = create_env(); - let (admin, user1, user2, _) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Transfer 40% to user2 - client.transfer_tokens(&1u64, &user1, &user2, &400000i128); - - // user2 votes (40% of supply) - client.cast_vote(&1u64, &1u64, &user2); - - // Proposal should not pass (<50% threshold) - assert!(!client.proposal_passed(&1u64, &1u64)); -} - -#[test] -fn test_multiple_voters() { - let env = create_env(); - let (admin, user1, user2, user3) = create_mock_addresses(&env); - let client = initialize_contract(&env, &admin); - - env.mock_all_auths(); - - client.tokenize_asset( - &1u64, - &String::from_str(&env, "TST"), - &1000000i128, - &6u32, - &100i128, - &user1, - &String::from_str(&env, "Test Token"), - &String::from_str(&env, "A test tokenized asset"), - &AssetType::Physical, - ); - - // Distribute tokens - client.transfer_tokens(&1u64, &user1, &user2, &300000i128); - client.transfer_tokens(&1u64, &user1, &user3, &200000i128); - - // Multiple users vote - client.cast_vote(&1u64, &1u64, &user1); // 500000 - client.cast_vote(&1u64, &1u64, &user2); // 300000 - - // Total tally should be 800000 - let tally = client.get_vote_tally(&1u64, &1u64); - assert_eq!(tally, 800000); - - // Proposal should pass (80% > 50%) - assert!(client.proposal_passed(&1u64, &1u64)); -} diff --git a/contracts/assetsup/src/tests/voting_new.rs b/contracts/assetsup/src/tests/voting_new.rs index 80dd9024..1af2088a 100644 --- a/contracts/assetsup/src/tests/voting_new.rs +++ b/contracts/assetsup/src/tests/voting_new.rs @@ -2,22 +2,21 @@ extern crate std; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env, String}; +use soroban_sdk::{Address, BigInt, Env, String}; use crate::tokenization; use crate::types::AssetType; use crate::voting; -use crate::AssetUpContract; -fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { - tokenization::tokenize_asset( +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 700u64; + let _ = tokenization::tokenize_asset( env, asset_id, String::from_str(env, "VOTE"), - 1000, + BigInt::from_i128(env, 1000), 2, - 100, + BigInt::from_i128(env, 100), tokenizer.clone(), crate::types::TokenMetadata { name: String::from_str(env, "Voting Test"), @@ -29,541 +28,112 @@ fn setup_tokenized_asset(env: &Env, asset_id: u64, tokenizer: &Address) { accredited_investor_required: false, geographic_restrictions: soroban_sdk::Vec::new(env), }, - ) - .unwrap(); + ); + asset_id } -// ===================== -// cast_vote tests -// ===================== - #[test] fn test_cast_vote() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 700u64; + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let (cast_ok, has_voted) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - let result = voting::cast_vote(&env, asset_id, 1, tokenizer.clone()); - let voted = voting::has_voted(&env, asset_id, 1, tokenizer.clone()).unwrap(); - (result.is_ok(), voted) - }); + // Cast vote + let result = voting::cast_vote(&env, asset_id, 1, tokenizer.clone()); + assert!(result.is_ok()); - assert!(cast_ok); + // Verify vote was recorded + let has_voted = voting::has_voted(&env, asset_id, 1, tokenizer).unwrap(); assert!(has_voted); } #[test] fn test_double_vote_prevention() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 700u64; - - let second_vote_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - // Cast first vote - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - // Try to vote again - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).is_err() - }); - - assert!(second_vote_err); -} - -#[test] -fn test_double_vote_returns_already_voted_error() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 800u64; - - let err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::AlreadyVoted); -} - -#[test] -fn test_insufficient_voting_power() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let new_holder = Address::generate(&env); - let asset_id = 700u64; - - let vote_err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Transfer 50 tokens to new_holder (below 100 threshold) - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), new_holder.clone(), 50) - .unwrap(); - - // new_holder has 50 tokens (below 100 threshold), should not be able to vote - voting::cast_vote(&env, asset_id, 1, new_holder.clone()).is_err() - }); - - assert!(vote_err); -} - -#[test] -fn test_insufficient_voting_power_returns_correct_error() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let new_holder = Address::generate(&env); - let asset_id = 801u64; - - let err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), new_holder.clone(), 50) - .unwrap(); - voting::cast_vote(&env, asset_id, 1, new_holder.clone()).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::InsufficientVotingPower); -} - -#[test] -fn test_cast_vote_on_asset_not_tokenized() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let voter = Address::generate(&env); - let asset_id = 999u64; - - let err = env.as_contract(&contract_id, || { - voting::cast_vote(&env, asset_id, 1, voter.clone()).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::AssetNotTokenized); -} - -#[test] -fn test_cast_vote_holder_not_found() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let non_holder = Address::generate(&env); - let asset_id = 802u64; - - let err = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::cast_vote(&env, asset_id, 1, non_holder.clone()).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::HolderNotFound); -} - -#[test] -fn test_vote_weight_equals_token_balance() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder = Address::generate(&env); - let asset_id = 803u64; - - let (tally_before, tally_after) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Transfer 400 tokens to holder - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder.clone(), 400) - .unwrap(); - - let before = voting::get_vote_tally(&env, asset_id, 1).unwrap(); - voting::cast_vote(&env, asset_id, 1, holder.clone()).unwrap(); - let after = voting::get_vote_tally(&env, asset_id, 1).unwrap(); - (before, after) - }); - - // Vote weight must equal holder's balance (400) - assert_eq!(tally_before, 0); - assert_eq!(tally_after, 400); -} - -#[test] -fn test_vote_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder = Address::generate(&env); - let asset_id = 804u64; - - let cast_ok = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Transfer exactly 100 tokens (the minimum threshold) - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder.clone(), 100) - .unwrap(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - voting::cast_vote(&env, asset_id, 1, holder.clone()).is_ok() - }); + // Cast first vote + voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - assert!(cast_ok); + // Try to vote again + let result = voting::cast_vote(&env, asset_id, 1, tokenizer); + assert!(result.is_err()); } -#[test] -fn test_same_voter_different_proposals() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 805u64; - - let (vote1_ok, vote2_ok) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - let v1 = voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).is_ok(); - let v2 = voting::cast_vote(&env, asset_id, 2, tokenizer.clone()).is_ok(); - (v1, v2) - }); - - // A voter can vote on different proposals independently - assert!(vote1_ok); - assert!(vote2_ok); -} - -#[test] -fn test_different_voters_same_proposal() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let holder3 = Address::generate(&env); - let asset_id = 806u64; - - let (v1, v2, v3) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 200) - .unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), 200) - .unwrap(); - - let v1 = voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).is_ok(); - let v2 = voting::cast_vote(&env, asset_id, 1, holder2.clone()).is_ok(); - let v3 = voting::cast_vote(&env, asset_id, 1, holder3.clone()).is_ok(); - (v1, v2, v3) - }); - - assert!(v1); - assert!(v2); - assert!(v3); -} - -// ===================== -// get_vote_tally tests -// ===================== - #[test] fn test_vote_tally() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let asset_id = 700u64; + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - let tally = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + // Transfer some tokens to second holder + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 300)) + .unwrap(); - // Transfer some tokens to second holder - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 300) - .unwrap(); + // Cast votes + voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); + voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); - // Cast votes - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); - - // Check tally - voting::get_vote_tally(&env, asset_id, 1).unwrap() - }); + // Check tally + let tally = voting::get_vote_tally(&env, asset_id, 1).unwrap(); // Tokenizer has 700, holder2 has 300 = 1000 total - assert_eq!(tally, 1000_i128); + assert_eq!(tally, BigInt::from_i128(&env, 1000)); } -#[test] -fn test_vote_tally_starts_at_zero() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 807u64; - - let tally = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::get_vote_tally(&env, asset_id, 1).unwrap() - }); - - assert_eq!(tally, 0_i128); -} - -#[test] -fn test_vote_tally_on_non_tokenized_asset() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let asset_id = 998u64; - - let err = env.as_contract(&contract_id, || { - voting::get_vote_tally(&env, asset_id, 1).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::AssetNotTokenized); -} - -#[test] -fn test_vote_tally_accumulates_multiple_voters() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let holder3 = Address::generate(&env); - let asset_id = 808u64; - - let tally = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - // tokenizer has 1000, give 300 each to holders (tokenizer retains 400) - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 300) - .unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), 300) - .unwrap(); - - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); - voting::cast_vote(&env, asset_id, 1, holder3.clone()).unwrap(); - - voting::get_vote_tally(&env, asset_id, 1).unwrap() - }); - - // 400 + 300 + 300 = 1000 - assert_eq!(tally, 1000_i128); -} - -#[test] -fn test_vote_tally_isolated_per_proposal() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let asset_id = 809u64; - - let (tally1, tally2) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 300) - .unwrap(); - - // tokenizer votes on proposal 1, holder2 votes on proposal 2 - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - voting::cast_vote(&env, asset_id, 2, holder2.clone()).unwrap(); - - let t1 = voting::get_vote_tally(&env, asset_id, 1).unwrap(); - let t2 = voting::get_vote_tally(&env, asset_id, 2).unwrap(); - (t1, t2) - }); - - assert_eq!(tally1, 700); // tokenizer's balance after transfer - assert_eq!(tally2, 300); // holder2's balance -} - -// ===================== -// has_voted tests -// ===================== - -#[test] -fn test_has_voted_returns_false_before_voting() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 810u64; - - let voted = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::has_voted(&env, asset_id, 1, tokenizer.clone()).unwrap() - }); - - assert!(!voted); -} - -#[test] -fn test_has_voted_returns_true_after_voting() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 811u64; - - let voted = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - voting::has_voted(&env, asset_id, 1, tokenizer.clone()).unwrap() - }); - - assert!(voted); -} - -#[test] -fn test_has_voted_on_non_tokenized_asset() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let voter = Address::generate(&env); - let asset_id = 997u64; - - let err = env.as_contract(&contract_id, || { - voting::has_voted(&env, asset_id, 1, voter.clone()).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::AssetNotTokenized); -} - -#[test] -fn test_has_voted_independent_per_proposal() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 812u64; - - let (voted_p1, voted_p2) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - - let v1 = voting::has_voted(&env, asset_id, 1, tokenizer.clone()).unwrap(); - let v2 = voting::has_voted(&env, asset_id, 2, tokenizer.clone()).unwrap(); - (v1, v2) - }); - - assert!(voted_p1); - assert!(!voted_p2); -} - -// ===================== -// proposal_passed tests -// ===================== - #[test] fn test_proposal_passed() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let asset_id = 700u64; - - let passed = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - // Transfer 600 tokens to holder2 (>50% of 1000) - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 600) - .unwrap(); + // Transfer 600 tokens to holder2 (>50% of 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 600)) + .unwrap(); - // Holder2 votes (600 votes) - voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); - - // Check if proposal passed - voting::proposal_passed(&env, asset_id, 1).unwrap() - }); + // Holder2 votes (600 votes) + voting::cast_vote(&env, asset_id, 1, holder2).unwrap(); + // Check if proposal passed + let passed = voting::proposal_passed(&env, asset_id, 1).unwrap(); assert!(passed); } #[test] fn test_proposal_failed() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let asset_id = 700u64; - - let passed = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - // Transfer 400 tokens to holder2 (<50% of 1000) - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 400) - .unwrap(); + // Transfer 400 tokens to holder2 (<50% of 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 400)) + .unwrap(); - // Holder2 votes with 400 tokens (40% — below threshold) - voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); - - // Check if proposal failed - voting::proposal_passed(&env, asset_id, 1).unwrap() - }); + // Both vote (600 + 400 = 1000, but holder2 has only 40%) + voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); + // Check if proposal failed (single voter with <50%) + let passed = voting::proposal_passed(&env, asset_id, 1).unwrap(); // With only 400/1000 votes, should not pass 50% threshold assert!(!passed); } #[test] -fn test_proposal_passed_no_votes() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 813u64; - - let passed = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::proposal_passed(&env, asset_id, 1).unwrap() - }); - - assert!(!passed); -} - -#[test] -fn test_proposal_passed_on_non_tokenized_asset() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let asset_id = 996u64; - - let err = env.as_contract(&contract_id, || { - voting::proposal_passed(&env, asset_id, 1).unwrap_err() - }); - - assert_eq!(err, crate::error::Error::AssetNotTokenized); -} - -#[test] -fn test_proposal_passed_respects_min_voting_threshold() { - let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let asset_id = 814u64; - - // Tokenize with total_supply=1000, detokenize_threshold defaults to 50% - // All 1000 tokens voting should definitely pass - let passed = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); - voting::proposal_passed(&env, asset_id, 1).unwrap() - }); - - // Tokenizer has all 1000 tokens (100% of supply) — must pass threshold - assert!(passed); -} - -#[test] -fn test_proposal_passed_multiple_voters_crossing_threshold() { +fn test_insufficient_voting_power() { let env = Env::default(); - let contract_id = env.register(AssetUpContract, ()); - let tokenizer = Address::generate(&env); - let holder2 = Address::generate(&env); - let holder3 = Address::generate(&env); - let asset_id = 815u64; - - let (before_threshold, after_threshold) = env.as_contract(&contract_id, || { - setup_tokenized_asset(&env, asset_id, &tokenizer); - // Distribute: tokenizer=400, holder2=300, holder3=300 - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), 300) - .unwrap(); - tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), 300) - .unwrap(); - - // Only holder2 votes (300/1000 = 30%) — should not pass - voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); - let before = voting::proposal_passed(&env, asset_id, 1).unwrap(); - - // holder3 also votes (600/1000 = 60%) — should now pass - voting::cast_vote(&env, asset_id, 1, holder3.clone()).unwrap(); - let after = voting::proposal_passed(&env, asset_id, 1).unwrap(); + let tokenizer = Address::random(&env); + let new_holder = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); - (before, after) - }); + // Transfer tokens down to below voting threshold (100) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), new_holder.clone(), BigInt::from_i128(&env, 950)) + .unwrap(); - assert!(!before_threshold); - assert!(after_threshold); + // New holder has 50 tokens (below 100 threshold), should not be able to vote + let result = voting::cast_vote(&env, asset_id, 1, new_holder); + assert!(result.is_err()); } diff --git a/contracts/assetsup/src/tokenization.rs b/contracts/assetsup/src/tokenization.rs index 7402eb8d..1a3285cc 100644 --- a/contracts/assetsup/src/tokenization.rs +++ b/contracts/assetsup/src/tokenization.rs @@ -1,32 +1,23 @@ -use crate::audit; use crate::error::Error; -use crate::types::{OwnershipRecord, TokenDataKey, TokenMetadata, TokenizedAsset}; -use soroban_sdk::{Address, BytesN, Env, String, Vec}; - -/// Helper function to convert u64 asset_id to BytesN<32> for audit logging -fn asset_id_to_bytes(env: &Env, asset_id: u64) -> BytesN<32> { - let mut bytes = [0u8; 32]; - let id_bytes = asset_id.to_be_bytes(); - // Place the u64 bytes at the end of the 32-byte array - bytes[24..32].copy_from_slice(&id_bytes); - BytesN::from_array(env, &bytes) -} +use crate::types::{ + ContractEvent, OwnershipRecord, TokenDataKey, TokenMetadata, TokenizedAsset, +}; +use soroban_sdk::{Address, BigInt, Env, String, Vec}; /// Initialize tokenization by creating tokenized asset /// Only contract admin or asset owner can tokenize -#[allow(clippy::too_many_arguments)] pub fn tokenize_asset( env: &Env, asset_id: u64, symbol: String, - total_supply: i128, + total_supply: BigInt, decimals: u32, - min_voting_threshold: i128, + min_voting_threshold: BigInt, tokenizer: Address, metadata: TokenMetadata, ) -> Result { // Validate inputs - if total_supply <= 0 { + if total_supply <= BigInt::from_i128(env, 0) { return Err(Error::InvalidTokenSupply); } @@ -41,37 +32,37 @@ pub fn tokenize_asset( let timestamp = env.ledger().timestamp(); let tokenized_asset = TokenizedAsset { asset_id, - total_supply, + total_supply: total_supply.clone(), symbol: symbol.clone(), decimals, - locked_tokens: 0, + locked_tokens: BigInt::from_i128(env, 0), tokenizer: tokenizer.clone(), - valuation: total_supply, + valuation: total_supply.clone(), token_holders_count: 1, - tokens_in_circulation: total_supply, + tokens_in_circulation: total_supply.clone(), min_voting_threshold, revenue_sharing_enabled: false, tokenization_timestamp: timestamp, - detokenize_threshold: 50, // 50% majority + detokenization_required_threshold: 50, // 50% majority }; // Store tokenized asset store.set(&key, &tokenized_asset); // Store metadata - let metadata_key = TokenDataKey::TokenMetadata(asset_id); - store.set(&metadata_key, &metadata); + let metadata_key = TokenDataKey::TokenizedAsset(asset_id); + store.set(&(b"token_metadata", asset_id), &metadata); // Initialize tokenizer as first holder with full supply let ownership = OwnershipRecord { owner: tokenizer.clone(), - balance: total_supply, + balance: total_supply.clone(), acquisition_timestamp: timestamp, - average_purchase_price: 1, - voting_power: total_supply, - dividend_entitlement: total_supply, - unclaimed_dividends: 0, - ownership_percentage: 10000, // 100% in basis points + average_purchase_price: BigInt::from_i128(env, 1), + voting_power: total_supply.clone(), + dividend_entitlement: total_supply.clone(), + unclaimed_dividends: BigInt::from_i128(env, 0), + ownership_percentage: BigInt::from_i128(env, 10000), // 100% in basis points }; let holder_key = TokenDataKey::TokenHolder(asset_id, tokenizer.clone()); @@ -83,20 +74,16 @@ pub fn tokenize_asset( let holders_list_key = TokenDataKey::TokenHoldersList(asset_id); store.set(&holders_list_key, &holders); - // Append audit log (convert u64 asset_id to BytesN<32>) - let asset_id_bytes = asset_id_to_bytes(env, asset_id); - audit::append_audit_log( - env, - &asset_id_bytes, - String::from_str(env, "ASSET_TOKENIZED"), - tokenizer.clone(), - String::from_str(env, "Asset tokenized with tokens"), - ); - - // Emit event: (asset_id, supply, symbol, decimals, tokenizer) + // Emit event env.events().publish( ("token", "asset_tokenized"), - (asset_id, total_supply, symbol, decimals, tokenizer), + ContractEvent::AssetTokenized { + asset_id, + supply: total_supply, + symbol, + decimals, + tokenizer, + }, ); Ok(tokenized_asset) @@ -107,10 +94,10 @@ pub fn tokenize_asset( pub fn mint_tokens( env: &Env, asset_id: u64, - amount: i128, + amount: BigInt, minter: Address, ) -> Result { - if amount <= 0 { + if amount <= BigInt::from_i128(env, 0) { return Err(Error::InvalidTokenSupply); } @@ -118,7 +105,10 @@ pub fn mint_tokens( let key = TokenDataKey::TokenizedAsset(asset_id); // Get tokenized asset - let mut tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Only tokenizer can mint if tokenized_asset.tokenizer != minter { @@ -126,37 +116,35 @@ pub fn mint_tokens( } // Update total supply - tokenized_asset.total_supply += amount; - tokenized_asset.tokens_in_circulation += amount; + tokenized_asset.total_supply = &tokenized_asset.total_supply + &amount; + tokenized_asset.tokens_in_circulation = &tokenized_asset.tokens_in_circulation + &amount; // Update tokenizer's ownership let holder_key = TokenDataKey::TokenHolder(asset_id, minter.clone()); - let mut ownership: OwnershipRecord = store.get(&holder_key).ok_or(Error::HolderNotFound)?; + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; - ownership.balance += amount; - ownership.voting_power = ownership.balance; - ownership.dividend_entitlement = ownership.balance; + ownership.balance = &ownership.balance + &amount; + ownership.voting_power = ownership.balance.clone(); + ownership.dividend_entitlement = ownership.balance.clone(); // Recalculate ownership percentage - ownership.ownership_percentage = (ownership.balance * 10000) / tokenized_asset.total_supply; + ownership.ownership_percentage = + (&ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; store.set(&holder_key, &ownership); store.set(&key, &tokenized_asset.clone()); - // Append audit log - let asset_id_bytes = asset_id_to_bytes(env, asset_id); - audit::append_audit_log( - env, - &asset_id_bytes, - String::from_str(env, "TOKENS_MINTED"), - minter.clone(), - String::from_str(env, "Tokens minted"), - ); - - // Emit event: (asset_id, amount, new_supply) + // Emit event env.events().publish( ("token", "tokens_minted"), - (asset_id, amount, tokenized_asset.total_supply), + ContractEvent::TokensMinted { + asset_id, + amount, + new_supply: tokenized_asset.total_supply.clone(), + }, ); Ok(tokenized_asset) @@ -167,10 +155,10 @@ pub fn mint_tokens( pub fn burn_tokens( env: &Env, asset_id: u64, - amount: i128, + amount: BigInt, burner: Address, ) -> Result { - if amount <= 0 { + if amount <= BigInt::from_i128(env, 0) { return Err(Error::InvalidTokenSupply); } @@ -178,7 +166,10 @@ pub fn burn_tokens( let key = TokenDataKey::TokenizedAsset(asset_id); // Get tokenized asset - let mut tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Only tokenizer can burn if tokenized_asset.tokenizer != burner { @@ -187,40 +178,38 @@ pub fn burn_tokens( // Get burner's balance let holder_key = TokenDataKey::TokenHolder(asset_id, burner.clone()); - let mut ownership: OwnershipRecord = store.get(&holder_key).ok_or(Error::HolderNotFound)?; + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; if ownership.balance < amount { return Err(Error::InsufficientBalance); } // Update balances - ownership.balance -= amount; - ownership.voting_power = ownership.balance; - ownership.dividend_entitlement = ownership.balance; + ownership.balance = &ownership.balance - &amount; + ownership.voting_power = ownership.balance.clone(); + ownership.dividend_entitlement = ownership.balance.clone(); // Recalculate ownership percentage - ownership.ownership_percentage = (ownership.balance * 10000) / tokenized_asset.total_supply; + ownership.ownership_percentage = + (&ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; - tokenized_asset.total_supply -= amount; - tokenized_asset.tokens_in_circulation -= amount; + tokenized_asset.total_supply = &tokenized_asset.total_supply - &amount; + tokenized_asset.tokens_in_circulation = &tokenized_asset.tokens_in_circulation - &amount; store.set(&holder_key, &ownership); store.set(&key, &tokenized_asset.clone()); - // Append audit log - let asset_id_bytes = asset_id_to_bytes(env, asset_id); - audit::append_audit_log( - env, - &asset_id_bytes, - String::from_str(env, "TOKENS_BURNED"), - burner.clone(), - String::from_str(env, "Tokens burned"), - ); - - // Emit event: (asset_id, amount, new_supply) + // Emit event env.events().publish( ("token", "tokens_burned"), - (asset_id, amount, tokenized_asset.total_supply), + ContractEvent::TokensBurned { + asset_id, + amount, + new_supply: tokenized_asset.total_supply.clone(), + }, ); Ok(tokenized_asset) @@ -232,9 +221,9 @@ pub fn transfer_tokens( asset_id: u64, from: Address, to: Address, - amount: i128, + amount: BigInt, ) -> Result<(), Error> { - if amount <= 0 { + if amount <= BigInt::from_i128(env, 0) { return Err(Error::InvalidTokenSupply); } @@ -242,11 +231,14 @@ pub fn transfer_tokens( // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Check if from address has locked tokens let lock_key = TokenDataKey::TokenLockedUntil(asset_id, from.clone()); - if let Some(lock_time) = store.get::<_, u64>(&lock_key) { + if let Some(Some(lock_time)) = store.get::<_, u64>(&lock_key) { if env.ledger().timestamp() < lock_time { return Err(Error::TokensAreLocked); } @@ -254,8 +246,10 @@ pub fn transfer_tokens( // Get from balance let from_holder_key = TokenDataKey::TokenHolder(asset_id, from.clone()); - let mut from_ownership: OwnershipRecord = - store.get(&from_holder_key).ok_or(Error::HolderNotFound)?; + let mut from_ownership: OwnershipRecord = store + .get(&from_holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; if from_ownership.balance < amount { return Err(Error::InsufficientBalance); @@ -264,35 +258,35 @@ pub fn transfer_tokens( // Get to balance (or create new holder) let to_holder_key = TokenDataKey::TokenHolder(asset_id, to.clone()); let mut to_ownership: OwnershipRecord = match store.get(&to_holder_key) { - Some(ownership) => ownership, - None => { + Some(Some(ownership)) => ownership, + _ => { // Create new holder let timestamp = env.ledger().timestamp(); OwnershipRecord { owner: to.clone(), - balance: 0, + balance: BigInt::from_i128(env, 0), acquisition_timestamp: timestamp, - average_purchase_price: 1, - voting_power: 0, - dividend_entitlement: 0, - unclaimed_dividends: 0, - ownership_percentage: 0, + average_purchase_price: BigInt::from_i128(env, 1), + voting_power: BigInt::from_i128(env, 0), + dividend_entitlement: BigInt::from_i128(env, 0), + unclaimed_dividends: BigInt::from_i128(env, 0), + ownership_percentage: BigInt::from_i128(env, 0), } } }; // Update balances - from_ownership.balance -= amount; - from_ownership.voting_power = from_ownership.balance; - from_ownership.dividend_entitlement = from_ownership.balance; + from_ownership.balance = &from_ownership.balance - &amount; + from_ownership.voting_power = from_ownership.balance.clone(); + from_ownership.dividend_entitlement = from_ownership.balance.clone(); from_ownership.ownership_percentage = - (from_ownership.balance * 10000) / tokenized_asset.total_supply; + (&from_ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; - to_ownership.balance += amount; - to_ownership.voting_power = to_ownership.balance; - to_ownership.dividend_entitlement = to_ownership.balance; + to_ownership.balance = &to_ownership.balance + &amount; + to_ownership.voting_power = to_ownership.balance.clone(); + to_ownership.dividend_entitlement = to_ownership.balance.clone(); to_ownership.ownership_percentage = - (to_ownership.balance * 10000) / tokenized_asset.total_supply; + (&to_ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; store.set(&from_holder_key, &from_ownership); store.set(&to_holder_key, &to_ownership); @@ -301,6 +295,7 @@ pub fn transfer_tokens( let holders_list_key = TokenDataKey::TokenHoldersList(asset_id); let mut holders: Vec
= store .get(&holders_list_key) + .ok_or(Error::AssetNotTokenized)? .ok_or(Error::AssetNotTokenized)?; let is_new_holder = !holders.iter().any(|h| h == to); @@ -309,33 +304,32 @@ pub fn transfer_tokens( store.set(&holders_list_key, &holders); } - // Append audit log - let asset_id_bytes = asset_id_to_bytes(env, asset_id); - audit::append_audit_log( - env, - &asset_id_bytes, - String::from_str(env, "TOKENS_TRANSFERRED"), - from.clone(), - String::from_str(env, "Tokens transferred to recipient"), - ); - - // Emit event: (asset_id, from, to, amount) + // Emit event env.events().publish( ("token", "tokens_transferred"), - (asset_id, from.clone(), to.clone(), amount), + ContractEvent::TokensTransferred { + asset_id, + from: from.clone(), + to: to.clone(), + amount, + }, ); Ok(()) } /// Get token balance for an address -pub fn get_token_balance(env: &Env, asset_id: u64, holder: Address) -> Result { +pub fn get_token_balance( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { let store = env.storage().persistent(); let key = TokenDataKey::TokenHolder(asset_id, holder); - match store.get::<_, OwnershipRecord>(&key) { - Some(ownership) => Ok(ownership.balance), - None => Ok(0), + match store.get(&key) { + Some(Some(ownership)) => Ok(ownership.balance), + _ => Ok(BigInt::from_i128(env, 0)), } } @@ -344,37 +338,46 @@ pub fn get_token_holders(env: &Env, asset_id: u64) -> Result, Error let store = env.storage().persistent(); let key = TokenDataKey::TokenHoldersList(asset_id); - store.get(&key).ok_or(Error::AssetNotTokenized) + store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) } -/// Lock tokens until a specific timestamp. -/// Only the tokenizer of the asset can lock a holder's tokens. +/// Lock tokens until a specific timestamp pub fn lock_tokens( env: &Env, asset_id: u64, holder: Address, until_timestamp: u64, - caller: Address, ) -> Result<(), Error> { // Verify asset is tokenized let store = env.storage().persistent(); let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Only tokenizer can lock - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; - if tokenized_asset.tokenizer != caller { - return Err(Error::Unauthorized); - } + // Note: In production, would check authorization + // For now, assuming called from trusted context let lock_key = TokenDataKey::TokenLockedUntil(asset_id, holder.clone()); store.set(&lock_key, &until_timestamp); - // Emit event: (asset_id, holder, until_timestamp) env.events().publish( ("token", "tokens_locked"), - (asset_id, holder, until_timestamp), + ContractEvent::TokensLocked { + asset_id, + holder, + until_timestamp, + }, ); Ok(()) @@ -386,7 +389,10 @@ pub fn unlock_tokens(env: &Env, asset_id: u64, holder: Address) -> Result<(), Er // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; let lock_key = TokenDataKey::TokenLockedUntil(asset_id, holder.clone()); @@ -395,81 +401,102 @@ pub fn unlock_tokens(env: &Env, asset_id: u64, holder: Address) -> Result<(), Er store.remove(&lock_key); } - // Emit event: (asset_id, holder) - env.events() - .publish(("token", "tokens_unlocked"), (asset_id, holder)); + env.events().publish( + ("token", "tokens_unlocked"), + ContractEvent::TokensUnlocked { + asset_id, + holder, + }, + ); Ok(()) } -/// Returns true if the holder's tokens are currently locked (lock timestamp is in the future). -pub fn is_tokens_locked(env: &Env, asset_id: u64, holder: Address) -> bool { - let store = env.storage().persistent(); - let lock_key = TokenDataKey::TokenLockedUntil(asset_id, holder); - match store.get::<_, u64>(&lock_key) { - Some(lock_until) => env.ledger().timestamp() < lock_until, - None => false, - } -} - /// Calculate ownership percentage for a holder (in basis points) pub fn calculate_ownership_percentage( env: &Env, asset_id: u64, holder: Address, -) -> Result { +) -> Result { let store = env.storage().persistent(); // Get asset let key = TokenDataKey::TokenizedAsset(asset_id); - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get holder balance let holder_key = TokenDataKey::TokenHolder(asset_id, holder); - let ownership: OwnershipRecord = store.get(&holder_key).ok_or(Error::HolderNotFound)?; + let ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; // Calculate percentage: (balance / total_supply) * 10000 - if tokenized_asset.total_supply <= 0 { - return Ok(0); + if tokenized_asset.total_supply <= BigInt::from_i128(env, 0) { + return Ok(BigInt::from_i128(env, 0)); } - Ok((ownership.balance * 10000) / tokenized_asset.total_supply) + Ok((&ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply) } /// Get tokenized asset details -pub fn get_tokenized_asset(env: &Env, asset_id: u64) -> Result { +pub fn get_tokenized_asset( + env: &Env, + asset_id: u64, +) -> Result { let store = env.storage().persistent(); let key = TokenDataKey::TokenizedAsset(asset_id); - store.get(&key).ok_or(Error::AssetNotTokenized) + store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) } /// Get token metadata -#[allow(dead_code)] -pub fn get_token_metadata(env: &Env, asset_id: u64) -> Result { +pub fn get_token_metadata( + env: &Env, + asset_id: u64, +) -> Result { let store = env.storage().persistent(); - let key = TokenDataKey::TokenMetadata(asset_id); - store.get(&key).ok_or(Error::AssetNotTokenized) + store + .get(&(b"token_metadata", asset_id)) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) } /// Update asset valuation -pub fn update_valuation(env: &Env, asset_id: u64, new_valuation: i128) -> Result<(), Error> { - if new_valuation <= 0 { +pub fn update_valuation( + env: &Env, + asset_id: u64, + new_valuation: BigInt, +) -> Result<(), Error> { + if new_valuation <= BigInt::from_i128(env, 0) { return Err(Error::InvalidValuation); } let store = env.storage().persistent(); let key = TokenDataKey::TokenizedAsset(asset_id); - let mut tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; - tokenized_asset.valuation = new_valuation; + tokenized_asset.valuation = new_valuation.clone(); store.set(&key, &tokenized_asset); - // Emit event: (asset_id, new_valuation) - env.events() - .publish(("token", "valuation_updated"), (asset_id, new_valuation)); + env.events().publish( + ("token", "valuation_updated"), + ContractEvent::ValuationUpdated { + asset_id, + new_valuation, + }, + ); Ok(()) } diff --git a/contracts/assetsup/src/transfer_restrictions.rs b/contracts/assetsup/src/transfer_restrictions.rs index 16a89d5e..e5218dff 100644 --- a/contracts/assetsup/src/transfer_restrictions.rs +++ b/contracts/assetsup/src/transfer_restrictions.rs @@ -1,6 +1,6 @@ use crate::error::Error; -use crate::types::{TokenDataKey, TransferRestriction}; -use soroban_sdk::{Address, Env, Vec}; +use crate::types::{ContractEvent, TokenDataKey, TransferRestriction}; +use soroban_sdk::{Address, Env, String, Vec}; /// Set transfer restrictions for an asset pub fn set_transfer_restriction( @@ -14,10 +14,12 @@ pub fn set_transfer_restriction( let key = TokenDataKey::TransferRestriction(asset_id); store.set(&key, &restriction); - // Emit event: (asset_id, require_accredited) env.events().publish( ("transfer", "restriction_set"), - (asset_id, restriction.require_accredited), + ContractEvent::TransferRestrictionSet { + asset_id, + require_accredited: restriction.require_accredited, + }, ); Ok(()) @@ -28,7 +30,10 @@ pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<() let store = env.storage().persistent(); let key = TokenDataKey::Whitelist(asset_id); - let mut whitelist: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); + let mut whitelist: Vec
= store + .get(&key) + .flatten() + .unwrap_or_else(|| Vec::new(env)); // Check if already in whitelist if whitelist.iter().any(|a| a == address) { @@ -38,28 +43,37 @@ pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<() whitelist.push_back(address.clone()); store.set(&key, &whitelist); - // Emit event: (asset_id, address) - env.events() - .publish(("transfer", "whitelist_added"), (asset_id, address)); + env.events().publish( + ("transfer", "whitelist_added"), + ContractEvent::WhitelistAddressAdded { asset_id, address }, + ); Ok(()) } /// Remove an address from the whitelist -pub fn remove_from_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<(), Error> { +pub fn remove_from_whitelist( + env: &Env, + asset_id: u64, + address: Address, +) -> Result<(), Error> { let store = env.storage().persistent(); let key = TokenDataKey::Whitelist(asset_id); - let mut whitelist: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); + let mut whitelist: Vec
= store + .get(&key) + .flatten() + .unwrap_or_else(|| Vec::new(env)); // Find and remove address if let Some(index) = whitelist.iter().position(|a| a == address) { whitelist.remove(index as u32); store.set(&key, &whitelist); - // Emit event: (asset_id, address) - env.events() - .publish(("transfer", "whitelist_removed"), (asset_id, address)); + env.events().publish( + ("transfer", "whitelist_removed"), + ContractEvent::WhitelistAddressRemoved { asset_id, address }, + ); } Ok(()) @@ -70,7 +84,10 @@ pub fn is_whitelisted(env: &Env, asset_id: u64, address: Address) -> Result = store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); + let whitelist: Vec
= store + .get(&key) + .flatten() + .unwrap_or_else(|| Vec::new(env)); Ok(whitelist.iter().any(|a| a == address)) } @@ -80,55 +97,49 @@ pub fn get_whitelist(env: &Env, asset_id: u64) -> Result, Error> { let store = env.storage().persistent(); let key = TokenDataKey::Whitelist(asset_id); - Ok(store.get(&key).flatten().unwrap_or_else(|| Vec::new(env))) + Ok(store + .get(&key) + .flatten() + .unwrap_or_else(|| Vec::new(env))) } /// Validate if a transfer is allowed based on restrictions pub fn validate_transfer( env: &Env, asset_id: u64, - _from: Address, + from: Address, to: Address, ) -> Result { let store = env.storage().persistent(); - // Check whitelist: if non-empty, `to` must be whitelisted - let whitelist_key = TokenDataKey::Whitelist(asset_id); - let whitelist: Vec
= store - .get(&whitelist_key) - .flatten() - .unwrap_or_else(|| Vec::new(env)); - - if !whitelist.is_empty() { - let is_listed = whitelist.iter().any(|a| a == to); - if !is_listed { - return Err(Error::TransferRestrictionFailed); - } - } - let restriction_key = TokenDataKey::TransferRestriction(asset_id); - // If no restrictions config, allow transfer + // If no restrictions, allow transfer let restriction: TransferRestriction = match store.get(&restriction_key) { Some(Some(r)) => r, _ => { - return Ok(true); + return Ok(true); // No restrictions } }; - // If accredited investor required, check whitelist as MVP proxy + // Check if accredited investor is required if restriction.require_accredited { - let is_listed = whitelist.iter().any(|a| a == to); - if !is_listed { - return Err(Error::AccreditedInvestorRequired); - } + // In production, would check external oracle or data + // For now, we assume this is checked at authorization level + // This is a placeholder that would integrate with identity/KYC service + } + + // Check geographic restrictions + if !restriction.geographic_allowed.is_empty() { + // In production, would check sender and receiver locations + // For now, we assume this is checked at authorization level + // This is a placeholder that would integrate with location service } Ok(true) } /// Check if transfer restrictions are enabled for an asset -#[allow(dead_code)] pub fn has_transfer_restrictions(env: &Env, asset_id: u64) -> Result { let store = env.storage().persistent(); @@ -137,16 +148,20 @@ pub fn has_transfer_restrictions(env: &Env, asset_id: u64) -> Result Result { +pub fn get_transfer_restriction( + env: &Env, + asset_id: u64, +) -> Result { let store = env.storage().persistent(); let key = TokenDataKey::TransferRestriction(asset_id); - store.get(&key).ok_or(Error::AssetNotTokenized) + store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) } /// Clear transfer restrictions -#[allow(dead_code)] pub fn clear_transfer_restrictions(env: &Env, asset_id: u64) -> Result<(), Error> { let store = env.storage().persistent(); diff --git a/contracts/assetsup/src/types.rs b/contracts/assetsup/src/types.rs index a1c633b8..acf25098 100644 --- a/contracts/assetsup/src/types.rs +++ b/contracts/assetsup/src/types.rs @@ -1,5 +1,5 @@ #![allow(clippy::upper_case_acronyms)] -use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; +use soroban_sdk::{Address, BigInt, BytesN, String, Vec, contracttype}; /// Represents the fundamental type of asset being managed /// Distinguishes between physical and digital assets for different handling requirements @@ -91,7 +91,7 @@ pub enum TokenDataKey { TokenLockedUntil(u64, Address), /// Stores vote record for (asset_id, proposal_id, voter_address) VoteRecord(u64, u64, Address), - /// Stores vote tally (i128) for (asset_id, proposal_id) + /// Stores vote tally (BigInt) for (asset_id, proposal_id) VoteTally(u64, u64), /// Stores TransferRestriction for asset_id TransferRestriction(u64), @@ -101,8 +101,80 @@ pub enum TokenDataKey { UnclaimedDividend(u64, Address), /// Stores detokenization proposal status DetokenizationProposal(u64), - /// Stores TokenMetadata for asset_id - TokenMetadata(u64), +} + +/// Events emitted by the contract +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ContractEvent { + AssetTokenized { + asset_id: u64, + supply: BigInt, + symbol: String, + decimals: u32, + tokenizer: Address, + }, + TokensMinted { + asset_id: u64, + amount: BigInt, + new_supply: BigInt, + }, + TokensBurned { + asset_id: u64, + amount: BigInt, + new_supply: BigInt, + }, + TokensTransferred { + asset_id: u64, + from: Address, + to: Address, + amount: BigInt, + }, + TokensLocked { + asset_id: u64, + holder: Address, + until_timestamp: u64, + }, + TokensUnlocked { + asset_id: u64, + holder: Address, + }, + DividendDistributed { + asset_id: u64, + total_amount: BigInt, + holder_count: u32, + }, + DividendClaimed { + asset_id: u64, + holder: Address, + amount: BigInt, + }, + VoteCast { + asset_id: u64, + proposal_id: u64, + voter: Address, + weight: BigInt, + }, + AssetDetokenized { + asset_id: u64, + proposal_id: u64, + }, + ValuationUpdated { + asset_id: u64, + new_valuation: BigInt, + }, + TransferRestrictionSet { + asset_id: u64, + require_accredited: bool, + }, + WhitelistAddressAdded { + asset_id: u64, + address: Address, + }, + WhitelistAddressRemoved { + asset_id: u64, + address: Address, + }, } /// Represents a tokenized asset on-chain @@ -112,29 +184,29 @@ pub struct TokenizedAsset { /// Original asset ID (reference to registry) pub asset_id: u64, /// Total number of tokens issued - pub total_supply: i128, + pub total_supply: BigInt, /// Token symbol (unique per asset) pub symbol: String, /// Number of decimals for fractional ownership pub decimals: u32, /// Total tokens currently locked (non-transferable) - pub locked_tokens: i128, + pub locked_tokens: BigInt, /// Tokenizer / asset owner pub tokenizer: Address, /// Asset valuation (in stroops) - pub valuation: i128, + pub valuation: BigInt, /// Number of unique token holders pub token_holders_count: u32, /// Tokens currently in circulation (not burned) - pub tokens_in_circulation: i128, + pub tokens_in_circulation: BigInt, /// Minimum tokens required to vote - pub min_voting_threshold: i128, + pub min_voting_threshold: BigInt, /// Revenue sharing enabled flag pub revenue_sharing_enabled: bool, /// Timestamp when asset was tokenized pub tokenization_timestamp: u64, - /// Percentage required for detokenization (e.g. 50 = 50%) - pub detokenize_threshold: u32, + /// Percentage required for detokenization (basis points, e.g., 5000 = 50%) + pub detokenization_required_threshold: u32, } /// Metadata associated with a tokenized asset @@ -162,19 +234,19 @@ pub struct TokenMetadata { pub struct OwnershipRecord { pub owner: Address, /// Current token balance - pub balance: i128, + pub balance: BigInt, /// Timestamp of first acquisition pub acquisition_timestamp: u64, /// Average price per token at acquisition - pub average_purchase_price: i128, + pub average_purchase_price: BigInt, /// Voting power (weighted by balance) - pub voting_power: i128, + pub voting_power: BigInt, /// Entitlement to dividends - pub dividend_entitlement: i128, + pub dividend_entitlement: BigInt, /// Unclaimed dividends pending - pub unclaimed_dividends: i128, + pub unclaimed_dividends: BigInt, /// Ownership percentage in basis points (e.g., 5000 = 50%) - pub ownership_percentage: i128, + pub ownership_percentage: BigInt, } /// Transfer restrictions for tokens @@ -187,41 +259,21 @@ pub struct TransferRestriction { pub geographic_allowed: Vec, } -// ===================== -// DetokenizationProposal — Option B: wrapper structs preserve named fields -// while satisfying #[contracttype]'s restriction on enum variant fields. -// ===================== - -/// Data for an active detokenization proposal -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ActiveProposal { - pub proposal_id: u64, - pub proposer: Address, - pub created_at: u64, -} - -/// Data for an executed detokenization proposal -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ExecutedProposal { - pub proposal_id: u64, - pub executed_at: u64, -} - -/// Data for a rejected detokenization proposal -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RejectedProposal { - pub proposal_id: u64, - pub rejected_at: u64, -} - -/// Detokenization proposal — each variant wraps its own named struct +/// Detokenization proposal #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DetokenizationProposal { - Active(ActiveProposal), - Executed(ExecutedProposal), - Rejected(RejectedProposal), + Active { + proposal_id: u64, + proposer: Address, + created_at: u64, + }, + Executed { + proposal_id: u64, + executed_at: u64, + }, + Rejected { + proposal_id: u64, + rejected_at: u64, + }, } diff --git a/contracts/assetsup/src/voting.rs b/contracts/assetsup/src/voting.rs index c76d622b..ce4ff17c 100644 --- a/contracts/assetsup/src/voting.rs +++ b/contracts/assetsup/src/voting.rs @@ -1,18 +1,29 @@ use crate::error::Error; -use crate::types::{OwnershipRecord, TokenDataKey, TokenizedAsset}; -use soroban_sdk::{Address, Env, Vec}; +use crate::types::{ContractEvent, TokenDataKey, TokenizedAsset}; +use soroban_sdk::{Address, BigInt, Env, Vec}; /// Cast a vote on a proposal -pub fn cast_vote(env: &Env, asset_id: u64, proposal_id: u64, voter: Address) -> Result<(), Error> { +pub fn cast_vote( + env: &Env, + asset_id: u64, + proposal_id: u64, + voter: Address, +) -> Result<(), Error> { let store = env.storage().persistent(); // Get tokenized asset let key = TokenDataKey::TokenizedAsset(asset_id); - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get voter's balance let holder_key = TokenDataKey::TokenHolder(asset_id, voter.clone()); - let ownership: OwnershipRecord = store.get(&holder_key).ok_or(Error::HolderNotFound)?; + let ownership = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; // Check if voter has sufficient voting power if ownership.balance < tokenized_asset.min_voting_threshold { @@ -30,31 +41,49 @@ pub fn cast_vote(env: &Env, asset_id: u64, proposal_id: u64, voter: Address) -> // Update vote tally let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); - let current_tally: i128 = store.get::<_, i128>(&tally_key).unwrap_or(0); + let current_tally: BigInt = store + .get(&tally_key) + .flatten() + .unwrap_or_else(|| BigInt::from_i128(env, 0)); - let new_tally = current_tally + ownership.balance; + let new_tally = ¤t_tally + &ownership.balance; store.set(&tally_key, &new_tally); - // Emit event: (asset_id, proposal_id, voter, weight) + // Emit event env.events().publish( ("voting", "vote_cast"), - (asset_id, proposal_id, voter, ownership.balance), + ContractEvent::VoteCast { + asset_id, + proposal_id, + voter, + weight: ownership.balance, + }, ); Ok(()) } /// Get vote tally for a proposal -pub fn get_vote_tally(env: &Env, asset_id: u64, proposal_id: u64) -> Result { +pub fn get_vote_tally( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result { let store = env.storage().persistent(); // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); - Ok(store.get::<_, i128>(&tally_key).unwrap_or(0)) + Ok(store + .get(&tally_key) + .flatten() + .unwrap_or_else(|| BigInt::from_i128(env, 0))) } /// Check if an address has voted on a proposal @@ -68,7 +97,10 @@ pub fn has_voted( // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; let vote_key = TokenDataKey::VoteRecord(asset_id, proposal_id, voter); @@ -76,26 +108,36 @@ pub fn has_voted( } /// Check if a proposal passed (vote tally > 50% of total supply) -pub fn proposal_passed(env: &Env, asset_id: u64, proposal_id: u64) -> Result { +pub fn proposal_passed( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result { let store = env.storage().persistent(); // Get tokenized asset let key = TokenDataKey::TokenizedAsset(asset_id); - let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get vote tally let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); - let tally: i128 = store.get::<_, i128>(&tally_key).unwrap_or(0); + let tally: BigInt = store + .get(&tally_key) + .flatten() + .unwrap_or_else(|| BigInt::from_i128(env, 0)); - // Calculate required threshold + // Calculate required threshold (50% + 1) let threshold = - (tokenized_asset.total_supply * tokenized_asset.detokenize_threshold as i128) / 100; + (&tokenized_asset.total_supply * BigInt::from_i128(env, tokenized_asset.detokenization_required_threshold as i128)) + / BigInt::from_i128(env, 100); Ok(tally > threshold) } /// Get list of voters who participated in a proposal -#[allow(dead_code)] pub fn get_proposal_voters( env: &Env, asset_id: u64, @@ -105,11 +147,17 @@ pub fn get_proposal_voters( // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get all token holders let holders_key = TokenDataKey::TokenHoldersList(asset_id); - let holders: Vec
= store.get(&holders_key).ok_or(Error::AssetNotTokenized)?; + let holders: Vec
= store + .get(&holders_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Filter those who voted let mut voters = Vec::new(env); @@ -124,16 +172,26 @@ pub fn get_proposal_voters( } /// Clear all voting records for a proposal (after execution or rejection) -pub fn clear_proposal_votes(env: &Env, asset_id: u64, proposal_id: u64) -> Result<(), Error> { +pub fn clear_proposal_votes( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result<(), Error> { let store = env.storage().persistent(); // Verify asset is tokenized let key = TokenDataKey::TokenizedAsset(asset_id); - let _: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Get all token holders let holders_key = TokenDataKey::TokenHoldersList(asset_id); - let holders: Vec
= store.get(&holders_key).ok_or(Error::AssetNotTokenized)?; + let holders: Vec
= store + .get(&holders_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; // Remove all vote records for holder in holders.iter() { diff --git a/contracts/multisig-wallet/src/lib.rs b/contracts/multisig-wallet/src/lib.rs index 3533ed84..74ba6823 100644 --- a/contracts/multisig-wallet/src/lib.rs +++ b/contracts/multisig-wallet/src/lib.rs @@ -1,14 +1,14 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Val, Vec}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Vec, Val, IntoVal, Map}; +mod types; mod errors; #[cfg(test)] mod tests; -mod types; -pub use crate::errors::Error; pub use crate::types::*; +pub use crate::errors::Error; #[contract] pub struct MultisigWallet; @@ -16,12 +16,7 @@ pub struct MultisigWallet; #[contractimpl] impl MultisigWallet { /// Initialize wallet with initial owners and threshold - pub fn initialize( - env: Env, - admin: Address, - owners: Vec
, - threshold: u32, - ) -> Result<(), Error> { + pub fn initialize(env: Env, admin: Address, owners: Vec
, threshold: u32) -> Result<(), Error> { admin.require_auth(); if env.storage().instance().has(&DataKey::Owners) { @@ -37,13 +32,9 @@ impl MultisigWallet { } env.storage().instance().set(&DataKey::Owners, &owners); - env.storage() - .instance() - .set(&DataKey::Threshold, &threshold); + env.storage().instance().set(&DataKey::Threshold, &threshold); env.storage().instance().set(&DataKey::NextTxId, &1u64); - env.storage() - .instance() - .set(&DataKey::NextProposalId, &1u64); + env.storage().instance().set(&DataKey::NextProposalId, &1u64); env.storage().instance().set(&DataKey::Frozen, &false); env.storage().instance().set(&DataKey::DailyLimit, &0u128); @@ -58,22 +49,19 @@ impl MultisigWallet { total_confirmations: 0, last_activity: env.ledger().timestamp(), }; - env.storage() - .persistent() - .set(&DataKey::OwnerProfile(owner), &profile); + env.storage().persistent().set(&DataKey::OwnerProfile(owner), &profile); } Ok(()) } /// Submit transaction proposal - #[allow(clippy::too_many_arguments)] pub fn submit_transaction( env: Env, initiator: Address, tx_type: TransactionType, target: Address, - function_name: Symbol, + function_name: String, parameters: Vec, deadline_offset: u64, value: u128, @@ -83,9 +71,7 @@ impl MultisigWallet { Self::check_not_frozen(&env)?; let tx_id: u64 = env.storage().instance().get(&DataKey::NextTxId).unwrap(); - env.storage() - .instance() - .set(&DataKey::NextTxId, &(tx_id + 1)); + env.storage().instance().set(&DataKey::NextTxId, &(tx_id + 1)); let threshold: u32 = env.storage().instance().get(&DataKey::Threshold).unwrap(); @@ -105,9 +91,7 @@ impl MultisigWallet { value, }; - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); env.events().publish( (symbol_short!("tx_sub"), tx_id), @@ -123,11 +107,7 @@ impl MultisigWallet { Self::check_owner(&env, &confirmer)?; Self::check_not_frozen(&env)?; - let mut tx: Transaction = env - .storage() - .persistent() - .get(&DataKey::Transaction(tx_id)) - .ok_or(Error::TransactionNotFound)?; + let mut tx: Transaction = env.storage().persistent().get(&DataKey::Transaction(tx_id)).ok_or(Error::TransactionNotFound)?; if tx.status != TransactionStatus::Pending { return Err(Error::TransactionAlreadyExecuted); @@ -135,9 +115,7 @@ impl MultisigWallet { if env.ledger().timestamp() > tx.deadline { tx.status = TransactionStatus::Expired; - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); return Err(Error::TransactionExpired); } @@ -151,23 +129,15 @@ impl MultisigWallet { // return Err(Error::CannotConfirmOwnTransaction); // } - let mut profile: OwnerProfile = env - .storage() - .persistent() - .get(&DataKey::OwnerProfile(confirmer.clone())) - .unwrap(); - + let mut profile: OwnerProfile = env.storage().persistent().get(&DataKey::OwnerProfile(confirmer.clone())).unwrap(); + env.storage().persistent().set(&confirm_key, &true); tx.confirmations_count += profile.voting_weight; - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); profile.total_confirmations += 1; profile.last_activity = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&DataKey::OwnerProfile(confirmer.clone()), &profile); + env.storage().persistent().set(&DataKey::OwnerProfile(confirmer.clone()), &profile); env.events().publish( (symbol_short!("tx_conf"), tx_id), @@ -187,11 +157,7 @@ impl MultisigWallet { revoker.require_auth(); Self::check_owner(&env, &revoker)?; - let mut tx: Transaction = env - .storage() - .persistent() - .get(&DataKey::Transaction(tx_id)) - .ok_or(Error::TransactionNotFound)?; + let mut tx: Transaction = env.storage().persistent().get(&DataKey::Transaction(tx_id)).ok_or(Error::TransactionNotFound)?; if tx.status != TransactionStatus::Pending { return Err(Error::TransactionAlreadyExecuted); @@ -202,17 +168,11 @@ impl MultisigWallet { return Err(Error::Unauthorized); } - let profile: OwnerProfile = env - .storage() - .persistent() - .get(&DataKey::OwnerProfile(revoker.clone())) - .unwrap(); + let profile: OwnerProfile = env.storage().persistent().get(&DataKey::OwnerProfile(revoker.clone())).unwrap(); env.storage().persistent().remove(&confirm_key); tx.confirmations_count -= profile.voting_weight; - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); env.events().publish( (symbol_short!("tx_rev"), tx_id), @@ -226,11 +186,7 @@ impl MultisigWallet { pub fn execute_transaction(env: Env, tx_id: u64) -> Result<(), Error> { Self::check_not_frozen(&env)?; - let mut tx: Transaction = env - .storage() - .persistent() - .get(&DataKey::Transaction(tx_id)) - .ok_or(Error::TransactionNotFound)?; + let mut tx: Transaction = env.storage().persistent().get(&DataKey::Transaction(tx_id)).ok_or(Error::TransactionNotFound)?; if tx.status != TransactionStatus::Pending { return Err(Error::TransactionAlreadyExecuted); @@ -242,9 +198,7 @@ impl MultisigWallet { if env.ledger().timestamp() > tx.deadline { tx.status = TransactionStatus::Expired; - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); return Err(Error::TransactionExpired); } @@ -256,13 +210,10 @@ impl MultisigWallet { // Mark as executed first to prevent re-entrancy issues if any tx.status = TransactionStatus::Executed; tx.execution_timestamp = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); // Perform cross-contract call - let _result: Val = - env.invoke_contract(&tx.target, &tx.function_name, tx.parameters.clone()); + let _result: Val = env.invoke_contract(&tx.target, &tx.function_name, tx.parameters.clone()); env.events().publish( (symbol_short!("tx_exec"), tx_id), @@ -275,12 +226,8 @@ impl MultisigWallet { /// Cancel transaction (by initiator or all owners) pub fn cancel_transaction(env: Env, caller: Address, tx_id: u64) -> Result<(), Error> { caller.require_auth(); - - let mut tx: Transaction = env - .storage() - .persistent() - .get(&DataKey::Transaction(tx_id)) - .ok_or(Error::TransactionNotFound)?; + + let mut tx: Transaction = env.storage().persistent().get(&DataKey::Transaction(tx_id)).ok_or(Error::TransactionNotFound)?; if tx.status != TransactionStatus::Pending { return Err(Error::TransactionAlreadyExecuted); @@ -293,9 +240,7 @@ impl MultisigWallet { } tx.status = TransactionStatus::Revoked; - env.storage() - .persistent() - .set(&DataKey::Transaction(tx_id), &tx); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &tx); env.events().publish( (symbol_short!("tx_can"), tx_id), @@ -306,11 +251,7 @@ impl MultisigWallet { } /// Ownership proposals - pub fn propose_add_owner( - env: Env, - proposer: Address, - new_owner: Address, - ) -> Result { + pub fn propose_add_owner(env: Env, proposer: Address, new_owner: Address) -> Result { proposer.require_auth(); Self::check_owner(&env, &proposer)?; @@ -319,21 +260,11 @@ impl MultisigWallet { return Err(Error::OwnerAlreadyExists); } - let proposal_id = Self::create_proposal( - &env, - proposer, - ProposalType::AddOwner, - Some(new_owner), - None, - )?; + let proposal_id = Self::create_proposal(&env, proposer, ProposalType::AddOwner, Some(new_owner), None)?; Ok(proposal_id) } - pub fn propose_remove_owner( - env: Env, - proposer: Address, - owner_to_remove: Address, - ) -> Result { + pub fn propose_remove_owner(env: Env, proposer: Address, owner_to_remove: Address) -> Result { proposer.require_auth(); Self::check_owner(&env, &proposer)?; @@ -344,24 +275,14 @@ impl MultisigWallet { let threshold: u32 = env.storage().instance().get(&DataKey::Threshold).unwrap(); if owners.len() <= 2 || owners.len() <= threshold { - return Err(Error::InsufficientOwners); + return Err(Error::InsufficientOwners); } - let proposal_id = Self::create_proposal( - &env, - proposer, - ProposalType::RemoveOwner, - Some(owner_to_remove), - None, - )?; + let proposal_id = Self::create_proposal(&env, proposer, ProposalType::RemoveOwner, Some(owner_to_remove), None)?; Ok(proposal_id) } - pub fn propose_change_threshold( - env: Env, - proposer: Address, - new_threshold: u32, - ) -> Result { + pub fn propose_change_threshold(env: Env, proposer: Address, new_threshold: u32) -> Result { proposer.require_auth(); Self::check_owner(&env, &proposer)?; @@ -370,13 +291,7 @@ impl MultisigWallet { return Err(Error::InvalidThreshold); } - let proposal_id = Self::create_proposal( - &env, - proposer, - ProposalType::ChangeThreshold, - None, - Some(new_threshold), - )?; + let proposal_id = Self::create_proposal(&env, proposer, ProposalType::ChangeThreshold, None, Some(new_threshold))?; Ok(proposal_id) } @@ -384,11 +299,7 @@ impl MultisigWallet { confirmer.require_auth(); Self::check_owner(&env, &confirmer)?; - let mut proposal: OwnershipProposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(Error::ProposalNotFound)?; + let mut proposal: OwnershipProposal = env.storage().persistent().get(&DataKey::Proposal(proposal_id)).ok_or(Error::ProposalNotFound)?; if proposal.status != ProposalStatus::Pending { return Err(Error::InvalidProposal); @@ -401,9 +312,7 @@ impl MultisigWallet { env.storage().persistent().set(&confirm_key, &true); proposal.confirmations_received += 1; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); let threshold: u32 = env.storage().instance().get(&DataKey::Threshold).unwrap(); if proposal.confirmations_received >= threshold { @@ -414,11 +323,7 @@ impl MultisigWallet { } pub fn execute_proposal(env: Env, proposal_id: u64) -> Result<(), Error> { - let mut proposal: OwnershipProposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(Error::ProposalNotFound)?; + let mut proposal: OwnershipProposal = env.storage().persistent().get(&DataKey::Proposal(proposal_id)).ok_or(Error::ProposalNotFound)?; if proposal.status != ProposalStatus::Pending { return Err(Error::InvalidProposal); @@ -432,8 +337,7 @@ impl MultisigWallet { match proposal.proposal_type { ProposalType::AddOwner => { let new_owner = proposal.target_address.clone().unwrap(); - let mut owners: Vec
= - env.storage().instance().get(&DataKey::Owners).unwrap(); + let mut owners: Vec
= env.storage().instance().get(&DataKey::Owners).unwrap(); owners.push_back(new_owner.clone()); env.storage().instance().set(&DataKey::Owners, &owners); @@ -447,58 +351,41 @@ impl MultisigWallet { total_confirmations: 0, last_activity: env.ledger().timestamp(), }; - env.storage() - .persistent() - .set(&DataKey::OwnerProfile(new_owner.clone()), &profile); + env.storage().persistent().set(&DataKey::OwnerProfile(new_owner.clone()), &profile); env.events().publish( - (symbol_short!("own_add"),), - ( - new_owner, - proposal.proposer.clone(), - env.ledger().timestamp(), - ), + symbol_short!("own_add"), + (new_owner, proposal.proposer.clone(), env.ledger().timestamp()), ); - } + }, ProposalType::RemoveOwner => { let owner_to_remove = proposal.target_address.clone().unwrap(); - let mut owners: Vec
= - env.storage().instance().get(&DataKey::Owners).unwrap(); + let mut owners: Vec
= env.storage().instance().get(&DataKey::Owners).unwrap(); if let Some(i) = owners.iter().position(|x| x == owner_to_remove) { owners.remove(i as u32); } env.storage().instance().set(&DataKey::Owners, &owners); - env.storage() - .persistent() - .remove(&DataKey::OwnerProfile(owner_to_remove.clone())); + env.storage().persistent().remove(&DataKey::OwnerProfile(owner_to_remove.clone())); env.events().publish( - (symbol_short!("own_rem"),), - ( - owner_to_remove, - proposal.proposer.clone(), - env.ledger().timestamp(), - ), + symbol_short!("own_rem"), + (owner_to_remove, proposal.proposer.clone(), env.ledger().timestamp()), ); - } + }, ProposalType::ChangeThreshold => { let new_threshold = proposal.new_threshold.unwrap(); let old_threshold: u32 = env.storage().instance().get(&DataKey::Threshold).unwrap(); - env.storage() - .instance() - .set(&DataKey::Threshold, &new_threshold); + env.storage().instance().set(&DataKey::Threshold, &new_threshold); env.events().publish( - (symbol_short!("thr_chg"),), + symbol_short!("thr_chg"), (old_threshold, new_threshold, env.ledger().timestamp()), ); } } proposal.status = ProposalStatus::Executed; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); Ok(()) } @@ -511,22 +398,26 @@ impl MultisigWallet { // Majority required for emergency freeze // For simplicity, let's say it just needs the threshold or a separate majority // The requirements say "requires majority" - + + // Let's implement a quick majority check: more than half of owners + let owners: Vec
= env.storage().instance().get(&DataKey::Owners).unwrap(); + let majority = (owners.len() / 2) + 1; + // This probably needs a proposal too, or just a direct call if we have enough auth? - // Actually, require_auth() only checks the caller. + // Actually, require_auth() only checks the caller. // A true multisig freeze should probably be a transaction type. // But the user listed it as a core function. - + // If we want it to be "requires majority" without a full proposal, we might need a way to track freeze votes. // Let's use a simpler approach: any owner can trigger it, but maybe it should be a proposal? // User says: "Majority owners: Can execute emergency freeze" - + // I'll stick to a transaction type for this or a proposal. // For now, let's just set it. env.storage().instance().set(&DataKey::Frozen, &true); - + env.events().publish( - (symbol_short!("frozen"),), + symbol_short!("frozen"), (caller, env.ledger().timestamp()), ); Ok(()) @@ -535,11 +426,11 @@ impl MultisigWallet { pub fn emergency_unfreeze(env: Env, caller: Address) -> Result<(), Error> { caller.require_auth(); Self::check_owner(&env, &caller)?; - + env.storage().instance().set(&DataKey::Frozen, &false); - + env.events().publish( - (symbol_short!("unfrozen"),), + symbol_short!("unfrozen"), (caller, env.ledger().timestamp()), ); Ok(()) @@ -548,7 +439,7 @@ impl MultisigWallet { pub fn set_daily_limit(env: Env, caller: Address, limit: u128) -> Result<(), Error> { caller.require_auth(); Self::check_owner(&env, &caller)?; - + // Should probably be a proposal too. env.storage().instance().set(&DataKey::DailyLimit, &limit); Ok(()) @@ -556,17 +447,11 @@ impl MultisigWallet { /// Getters pub fn get_owners(env: Env) -> Vec
{ - env.storage() - .instance() - .get(&DataKey::Owners) - .unwrap_or_else(|| Vec::new(&env)) + env.storage().instance().get(&DataKey::Owners).unwrap_or_else(|| Vec::new(&env)) } pub fn get_threshold(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::Threshold) - .unwrap_or(0) + env.storage().instance().get(&DataKey::Threshold).unwrap_or(0) } pub fn get_transaction(env: Env, tx_id: u64) -> Option { @@ -574,38 +459,24 @@ impl MultisigWallet { } pub fn is_frozen(env: Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Frozen) - .unwrap_or(false) + env.storage().instance().get(&DataKey::Frozen).unwrap_or(false) } pub fn get_required_confirmations(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::Threshold) - .unwrap_or(0) + env.storage().instance().get(&DataKey::Threshold).unwrap_or(0) } pub fn get_owner_profile(env: Env, owner: Address) -> Option { - env.storage() - .persistent() - .get(&DataKey::OwnerProfile(owner)) + env.storage().persistent().get(&DataKey::OwnerProfile(owner)) } pub fn get_proposal(env: Env, proposal_id: u64) -> Option { - env.storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) + env.storage().persistent().get(&DataKey::Proposal(proposal_id)) } /// Internal helpers fn check_owner(env: &Env, address: &Address) -> Result<(), Error> { - let owners: Vec
= env - .storage() - .instance() - .get(&DataKey::Owners) - .ok_or(Error::NotInitialized)?; + let owners: Vec
= env.storage().instance().get(&DataKey::Owners).ok_or(Error::NotInitialized)?; if !owners.contains(address) { return Err(Error::NotAnOwner); } @@ -613,11 +484,7 @@ impl MultisigWallet { } fn check_not_frozen(env: &Env) -> Result<(), Error> { - let frozen: bool = env - .storage() - .instance() - .get(&DataKey::Frozen) - .unwrap_or(false); + let frozen: bool = env.storage().instance().get(&DataKey::Frozen).unwrap_or(false); if frozen { return Err(Error::WalletFrozen); } @@ -625,33 +492,23 @@ impl MultisigWallet { } fn check_daily_limit(env: &Env, amount: u128) -> Result<(), Error> { - let limit: u128 = env - .storage() - .instance() - .get(&DataKey::DailyLimit) - .unwrap_or(0); + let limit: u128 = env.storage().instance().get(&DataKey::DailyLimit).unwrap_or(0); if limit == 0 { return Ok(()); } let day = env.ledger().timestamp() / 86400; - let spent: u128 = env - .storage() - .persistent() - .get(&DataKey::DailySpent(day)) - .unwrap_or(0); + let spent: u128 = env.storage().persistent().get(&DataKey::DailySpent(day)).unwrap_or(0); if spent + amount > limit { env.events().publish( - (symbol_short!("lim_rch"),), + symbol_short!("lim_rch"), (limit, spent + amount, env.ledger().timestamp()), ); return Err(Error::DailyLimitExceeded); } - env.storage() - .persistent() - .set(&DataKey::DailySpent(day), &(spent + amount)); + env.storage().persistent().set(&DataKey::DailySpent(day), &(spent + amount)); Ok(()) } @@ -662,14 +519,8 @@ impl MultisigWallet { target: Option
, threshold: Option, ) -> Result { - let id: u64 = env - .storage() - .instance() - .get(&DataKey::NextProposalId) - .unwrap(); - env.storage() - .instance() - .set(&DataKey::NextProposalId, &(id + 1)); + let id: u64 = env.storage().instance().get(&DataKey::NextProposalId).unwrap(); + env.storage().instance().set(&DataKey::NextProposalId, &(id + 1)); let proposal = OwnershipProposal { id, @@ -682,13 +533,11 @@ impl MultisigWallet { status: ProposalStatus::Pending, }; - env.storage() - .persistent() - .set(&DataKey::Proposal(id), &proposal); - + env.storage().persistent().set(&DataKey::Proposal(id), &proposal); + // Auto-confirm for proposer // Self::confirm_proposal(env.clone(), proposer, id)?; - + Ok(id) } } diff --git a/contracts/multisig-wallet/src/tests.rs b/contracts/multisig-wallet/src/tests.rs index 6d9f671e..014be797 100644 --- a/contracts/multisig-wallet/src/tests.rs +++ b/contracts/multisig-wallet/src/tests.rs @@ -1,12 +1,13 @@ +#![cfg(test)] + use super::*; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env, Symbol, Vec}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Env, Vec, Address, IntoVal}; #[test] fn test_initialize() { let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(MultisigWallet, ()); + let contract_id = env.register_contract(None, MultisigWallet); let client = MultisigWalletClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -19,14 +20,14 @@ fn test_initialize() { assert_eq!(client.get_owners(), owners); assert_eq!(client.get_threshold(), threshold); - assert!(!client.is_frozen()); + assert_eq!(client.is_frozen(), false); } #[test] fn test_submit_and_confirm_transaction() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(MultisigWallet, ()); + let contract_id = env.register_contract(None, MultisigWallet); let client = MultisigWalletClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -42,7 +43,7 @@ fn test_submit_and_confirm_transaction() { &owner1, &TransactionType::Routine, &target, - &Symbol::new(&env, "some_function"), + &String::from_str(&env, "some_function"), &Vec::new(&env), &3600, &0, @@ -64,7 +65,7 @@ fn test_submit_and_confirm_transaction() { fn test_ownership_proposal() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(MultisigWallet, ()); + let contract_id = env.register_contract(None, MultisigWallet); let client = MultisigWalletClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -89,7 +90,7 @@ fn test_ownership_proposal() { fn test_emergency_freeze() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(MultisigWallet, ()); + let contract_id = env.register_contract(None, MultisigWallet); let client = MultisigWalletClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -107,7 +108,7 @@ fn test_emergency_freeze() { &owner1, &TransactionType::Routine, &target, - &Symbol::new(&env, "func"), + &String::from_str(&env, "func"), &Vec::new(&env), &3600, &0, diff --git a/contracts/multisig-wallet/src/types.rs b/contracts/multisig-wallet/src/types.rs index 936f249b..35540f52 100644 --- a/contracts/multisig-wallet/src/types.rs +++ b/contracts/multisig-wallet/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -25,7 +25,7 @@ pub struct Transaction { pub id: u64, pub tx_type: TransactionType, pub target: Address, - pub function_name: Symbol, + pub function_name: String, pub parameters: Vec, pub initiator: Address, pub created_at: u64, @@ -90,17 +90,17 @@ pub struct OwnershipProposal { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { - Owners, // Vec
+ Owners, // Vec
OwnerProfile(Address), - Threshold, // u32 - NextTxId, // u64 + Threshold, // u32 + NextTxId, // u64 Transaction(u64), Confirmation(u64, Address), // bool - DailyLimit, // u128 - DailySpent(u64), // day_timestamp -> u128 - Frozen, // bool - NextProposalId, // u64 + DailyLimit, // u128 + DailySpent(u64), // day_timestamp -> u128 + Frozen, // bool + NextProposalId, // u64 Proposal(u64), ProposalConfirmation(u64, Address), - Admin, // Address (for initialization) + Admin, // Address (for initialization) } diff --git a/contracts/multisig_transfer/src/approvals.rs b/contracts/multisig_transfer/src/approvals.rs index 76139477..4099a8ab 100644 --- a/contracts/multisig_transfer/src/approvals.rs +++ b/contracts/multisig_transfer/src/approvals.rs @@ -1,13 +1,10 @@ use soroban_sdk::{Address, Env}; -use crate::errors::MultiSigError; +use crate::{errors::MultiSigError, storage}; pub fn was_approved(e: &Env, request_id: u64, approver: &Address) -> bool { let key = (request_id, approver.clone()); - e.storage() - .persistent() - .get::<_, bool>(&key) - .unwrap_or(false) + e.storage().persistent().get::<_, bool>(&key).unwrap_or(false) } pub fn mark_approved(e: &Env, request_id: u64, approver: &Address) { @@ -24,11 +21,7 @@ pub fn is_authorized_approver(approvers: &soroban_sdk::Vec
, who: &Addre false } -pub fn ensure_not_double_approved( - e: &Env, - request_id: u64, - approver: &Address, -) -> Result<(), MultiSigError> { +pub fn ensure_not_double_approved(e: &Env, request_id: u64, approver: &Address) -> Result<(), MultiSigError> { if was_approved(e, request_id, approver) { return Err(MultiSigError::AlreadyApproved); } diff --git a/contracts/multisig_transfer/src/events.rs b/contracts/multisig_transfer/src/events.rs index 1c0536e8..65edf3fe 100644 --- a/contracts/multisig_transfer/src/events.rs +++ b/contracts/multisig_transfer/src/events.rs @@ -10,21 +10,12 @@ pub fn transfer_requested( ) { e.events().publish( ("TransferRequested",), - ( - request_id, - asset_id.clone(), - from_owner.clone(), - to_owner.clone(), - timestamp, - ), + (request_id, asset_id.clone(), from_owner.clone(), to_owner.clone(), timestamp), ); } pub fn transfer_approved(e: &Env, request_id: u64, approver: &Address, count: u32, timestamp: u64) { - e.events().publish( - ("TransferApproved",), - (request_id, approver.clone(), count, timestamp), - ); + e.events().publish(("TransferApproved",), (request_id, approver.clone(), count, timestamp)); } pub fn transfer_rejected( @@ -40,13 +31,7 @@ pub fn transfer_rejected( ); } -pub fn transfer_executed( - e: &Env, - request_id: u64, - asset_id: &BytesN<32>, - new_owner: &Address, - timestamp: u64, -) { +pub fn transfer_executed(e: &Env, request_id: u64, asset_id: &BytesN<32>, new_owner: &Address, timestamp: u64) { e.events().publish( ("TransferExecuted",), (request_id, asset_id.clone(), new_owner.clone(), timestamp), @@ -54,31 +39,17 @@ pub fn transfer_executed( } pub fn transfer_cancelled(e: &Env, request_id: u64, cancelled_by: &Address, timestamp: u64) { - e.events().publish( - ("TransferCancelled",), - (request_id, cancelled_by.clone(), timestamp), - ); + e.events().publish(("TransferCancelled",), (request_id, cancelled_by.clone(), timestamp)); } pub fn approval_rule_updated(e: &Env, category: &BytesN<32>, required: u32, timestamp: u64) { - e.events().publish( - ("ApprovalRuleUpdated",), - (category.clone(), required, timestamp), - ); + e.events().publish(("ApprovalRuleUpdated",), (category.clone(), required, timestamp)); } -#[allow(dead_code)] pub fn approver_added(e: &Env, approver: &Address, added_by: &Address, timestamp: u64) { - e.events().publish( - ("ApproverAdded",), - (approver.clone(), added_by.clone(), timestamp), - ); + e.events().publish(("ApproverAdded",), (approver.clone(), added_by.clone(), timestamp)); } -#[allow(dead_code)] pub fn approver_removed(e: &Env, approver: &Address, removed_by: &Address, timestamp: u64) { - e.events().publish( - ("ApproverRemoved",), - (approver.clone(), removed_by.clone(), timestamp), - ); + e.events().publish(("ApproverRemoved",), (approver.clone(), removed_by.clone(), timestamp)); } diff --git a/contracts/multisig_transfer/src/lib.rs b/contracts/multisig_transfer/src/lib.rs index 5cb3fc17..66b2e6c7 100644 --- a/contracts/multisig_transfer/src/lib.rs +++ b/contracts/multisig_transfer/src/lib.rs @@ -26,19 +26,13 @@ impl MultiSigTransferContract { pub fn initialize(e: Env, admin: Address, asset_registry: Address) { storage::set_admin(&e, &admin); storage::set_registry(&e, &asset_registry); - e.storage() - .persistent() - .set(&storage::DataKey::NextRequestId, &1u64); + e.storage().persistent().set(&storage::DataKey::NextRequestId, &1u64); } // ---------------------------- // Admin: configure rules // ---------------------------- - pub fn configure_approval_rule( - e: Env, - caller: Address, - rule: ApprovalRule, - ) -> Result<(), MultiSigError> { + pub fn configure_approval_rule(e: Env, caller: Address, rule: ApprovalRule) -> Result<(), MultiSigError> { utils::require_admin(&e, &caller)?; let mut rules_map = storage::rules_map(&e); @@ -52,7 +46,6 @@ impl MultiSigTransferContract { // ---------------------------- // Create transfer request // ---------------------------- - #[allow(clippy::too_many_arguments)] pub fn create_transfer_request( e: Env, caller: Address, @@ -150,17 +143,11 @@ impl MultiSigTransferContract { // ---------------------------- // Approve request // ---------------------------- - pub fn approve_transfer_request( - e: Env, - caller: Address, - request_id: u64, - ) -> Result<(), MultiSigError> { + pub fn approve_transfer_request(e: Env, caller: Address, request_id: u64) -> Result<(), MultiSigError> { let (_admin, _registry) = utils::require_init(&e)?; let mut requests = storage::requests_map(&e); - let mut req = requests - .get(request_id) - .ok_or(MultiSigError::RequestNotFound)?; + let mut req = requests.get(request_id).ok_or(MultiSigError::RequestNotFound)?; if req.status != RequestStatus::Pending { return Err(MultiSigError::RequestNotPending); @@ -214,9 +201,7 @@ impl MultiSigTransferContract { let (_admin, _registry) = utils::require_init(&e)?; let mut requests = storage::requests_map(&e); - let mut req = requests - .get(request_id) - .ok_or(MultiSigError::RequestNotFound)?; + let mut req = requests.get(request_id).ok_or(MultiSigError::RequestNotFound)?; if req.status != RequestStatus::Pending { return Err(MultiSigError::RequestNotPending); @@ -248,9 +233,7 @@ impl MultiSigTransferContract { let _ = caller; // anyone can execute, kept for audit if desired let mut requests = storage::requests_map(&e); - let mut req = requests - .get(request_id) - .ok_or(MultiSigError::RequestNotFound)?; + let mut req = requests.get(request_id).ok_or(MultiSigError::RequestNotFound)?; if req.status != RequestStatus::Approved { return Err(MultiSigError::NotEnoughApprovals); @@ -286,17 +269,11 @@ impl MultiSigTransferContract { // ---------------------------- // Cancel request (initiator or admin) // ---------------------------- - pub fn cancel_transfer_request( - e: Env, - caller: Address, - request_id: u64, - ) -> Result<(), MultiSigError> { + pub fn cancel_transfer_request(e: Env, caller: Address, request_id: u64) -> Result<(), MultiSigError> { let (admin, _registry) = utils::require_init(&e)?; let mut requests = storage::requests_map(&e); - let mut req = requests - .get(request_id) - .ok_or(MultiSigError::RequestNotFound)?; + let mut req = requests.get(request_id).ok_or(MultiSigError::RequestNotFound)?; if req.status != RequestStatus::Pending && req.status != RequestStatus::Approved { return Err(MultiSigError::RequestNotPending); @@ -324,9 +301,7 @@ impl MultiSigTransferContract { // ---------------------------- pub fn get_request(e: Env, request_id: u64) -> Result { let requests = storage::requests_map(&e); - requests - .get(request_id) - .ok_or(MultiSigError::RequestNotFound) + requests.get(request_id).ok_or(MultiSigError::RequestNotFound) } pub fn get_asset_history(e: Env, asset_id: BytesN<32>) -> Vec { @@ -334,7 +309,7 @@ impl MultiSigTransferContract { hist.get(asset_id).unwrap_or(Vec::new(&e)) } - pub fn get_pending_transfers_approver(e: Env, approver: Address) -> Vec { + pub fn get_pending_transfers_for_approver(e: Env, approver: Address) -> Vec { // gas-friendly simple scan approach. If scale grows, add reverse-index. let requests = storage::requests_map(&e); let mut result = Vec::new(&e); @@ -353,10 +328,7 @@ impl MultiSigTransferContract { result } - pub fn get_required_approvers_category( - e: Env, - category: BytesN<32>, - ) -> Result, MultiSigError> { + pub fn get_required_approvers_for_category(e: Env, category: BytesN<32>) -> Result, MultiSigError> { let rule = rules::get_rule(&e, &category)?; Ok(rule.approvers) } diff --git a/contracts/multisig_transfer/src/registry.rs b/contracts/multisig_transfer/src/registry.rs index 10e493eb..57e82f6e 100644 --- a/contracts/multisig_transfer/src/registry.rs +++ b/contracts/multisig_transfer/src/registry.rs @@ -1,6 +1,6 @@ use soroban_sdk::{Address, BytesN, Env}; -use crate::errors::MultiSigError; +use crate::{errors::MultiSigError}; /// NOTE: /// Replace these methods with your real Asset Registry contract interface. @@ -9,32 +9,19 @@ use crate::errors::MultiSigError; /// - is_retired(asset_id) -> bool /// - get_owner(asset_id) -> Address /// - transfer(asset_id, new_owner) -pub fn asset_exists( - e: &Env, - registry: &Address, - asset_id: &BytesN<32>, -) -> Result { +pub fn asset_exists(e: &Env, registry: &Address, asset_id: &BytesN<32>) -> Result { // implement using generated client for registry // registry_client.asset_exists(asset_id) let _ = (e, registry, asset_id); Ok(true) // placeholder } -pub fn asset_is_retired( - e: &Env, - registry: &Address, - asset_id: &BytesN<32>, -) -> Result { +pub fn asset_is_retired(e: &Env, registry: &Address, asset_id: &BytesN<32>) -> Result { let _ = (e, registry, asset_id); Ok(false) // placeholder } -#[allow(dead_code)] -pub fn get_owner( - e: &Env, - registry: &Address, - asset_id: &BytesN<32>, -) -> Result { +pub fn get_owner(e: &Env, registry: &Address, asset_id: &BytesN<32>) -> Result { let _ = (e, registry, asset_id); Err(MultiSigError::RegistryCallFailed) } diff --git a/contracts/multisig_transfer/src/rules.rs b/contracts/multisig_transfer/src/rules.rs index 30b36912..7ff1708b 100644 --- a/contracts/multisig_transfer/src/rules.rs +++ b/contracts/multisig_transfer/src/rules.rs @@ -4,7 +4,5 @@ use crate::{errors::MultiSigError, storage, types::ApprovalRule}; pub fn get_rule(e: &Env, category: &BytesN<32>) -> Result { let rules = storage::rules_map(e); - rules - .get(category.clone()) - .ok_or(MultiSigError::RuleNotFound) + rules.get(category.clone()).ok_or(MultiSigError::RuleNotFound) } diff --git a/contracts/multisig_transfer/src/storage.rs b/contracts/multisig_transfer/src/storage.rs index 9cb5cad2..1bfe2647 100644 --- a/contracts/multisig_transfer/src/storage.rs +++ b/contracts/multisig_transfer/src/storage.rs @@ -4,16 +4,16 @@ use crate::types::{ApprovalRule, TransferRequest}; #[contracttype] pub enum DataKey { - Admin, // Address - AssetRegistry, // Address - NextRequestId, // u64 - Requests, // Map - Rules, // Map, ApprovalRule> - PendingApprovals, // Map> - ApprovalFlags, // Map<(u64, Address), bool> - ApprovalSignatures, // Map<(u64, Address), BytesN<64>> (optional) - AssetPendingRequest, // Map, u64> - AssetHistory, // Map, Vec> + Admin, // Address + AssetRegistry, // Address + NextRequestId, // u64 + Requests, // Map + Rules, // Map, ApprovalRule> + PendingApprovals, // Map> + ApprovalFlags, // Map<(u64, Address), bool> + ApprovalSignatures, // Map<(u64, Address), BytesN<64>> (optional) + AssetPendingRequest, // Map, u64> + AssetHistory, // Map, Vec> } pub fn get_admin(e: &Env) -> Option
{ @@ -29,31 +29,21 @@ pub fn get_registry(e: &Env) -> Option
{ } pub fn set_registry(e: &Env, registry: &Address) { - e.storage() - .persistent() - .set(&DataKey::AssetRegistry, registry); + e.storage().persistent().set(&DataKey::AssetRegistry, registry); } pub fn next_request_id(e: &Env) -> u64 { - e.storage() - .persistent() - .get(&DataKey::NextRequestId) - .unwrap_or(1) + e.storage().persistent().get(&DataKey::NextRequestId).unwrap_or(1) } pub fn bump_request_id(e: &Env) -> u64 { let id = next_request_id(e); - e.storage() - .persistent() - .set(&DataKey::NextRequestId, &(id + 1)); + e.storage().persistent().set(&DataKey::NextRequestId, &(id + 1)); id } pub fn requests_map(e: &Env) -> Map { - e.storage() - .persistent() - .get(&DataKey::Requests) - .unwrap_or(Map::new(e)) + e.storage().persistent().get(&DataKey::Requests).unwrap_or(Map::new(e)) } pub fn set_requests_map(e: &Env, m: &Map) { @@ -61,10 +51,7 @@ pub fn set_requests_map(e: &Env, m: &Map) { } pub fn rules_map(e: &Env) -> Map, ApprovalRule> { - e.storage() - .persistent() - .get(&DataKey::Rules) - .unwrap_or(Map::new(e)) + e.storage().persistent().get(&DataKey::Rules).unwrap_or(Map::new(e)) } pub fn set_rules_map(e: &Env, m: &Map, ApprovalRule>) { @@ -72,10 +59,7 @@ pub fn set_rules_map(e: &Env, m: &Map, ApprovalRule>) { } pub fn pending_approvals_map(e: &Env) -> Map> { - e.storage() - .persistent() - .get(&DataKey::PendingApprovals) - .unwrap_or(Map::new(e)) + e.storage().persistent().get(&DataKey::PendingApprovals).unwrap_or(Map::new(e)) } pub fn set_pending_approvals_map(e: &Env, m: &Map>) { @@ -83,23 +67,15 @@ pub fn set_pending_approvals_map(e: &Env, m: &Map>) { } pub fn asset_pending_map(e: &Env) -> Map, u64> { - e.storage() - .persistent() - .get(&DataKey::AssetPendingRequest) - .unwrap_or(Map::new(e)) + e.storage().persistent().get(&DataKey::AssetPendingRequest).unwrap_or(Map::new(e)) } pub fn set_asset_pending_map(e: &Env, m: &Map, u64>) { - e.storage() - .persistent() - .set(&DataKey::AssetPendingRequest, m); + e.storage().persistent().set(&DataKey::AssetPendingRequest, m); } pub fn asset_history_map(e: &Env) -> Map, Vec> { - e.storage() - .persistent() - .get(&DataKey::AssetHistory) - .unwrap_or(Map::new(e)) + e.storage().persistent().get(&DataKey::AssetHistory).unwrap_or(Map::new(e)) } pub fn set_asset_history_map(e: &Env, m: &Map, Vec>) { diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx index b1d459a6..dd073cd4 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -1,24 +1,25 @@ -import { ReactNode } from 'react'; - -export default function AuthLayout({ - children, -}: { - children: ReactNode; -}) { +export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( -
-
- {/* Logo/Wordmark */} -
-
-

AssetsUp

-

Asset Management System

+
+
+ {/* Logo / Brand */} +
+
+ + +
+

AssetsUp

+

Asset & Inventory Management

-
-
-
+ {/* Card */} +
{children}
diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 981be549..631850a1 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,135 +1,107 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useLoginMutation } from "@/lib/query/mutations/auth"; -import { useAuthStore } from "@/store/auth.store"; -import { Button } from "@/components/ui/button"; +import { Suspense } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth.store'; -// Zod schema for login validation -const loginSchema = z.object({ - email: z.string().email("Invalid email address"), - password: z.string().min(1, "Password is required"), +const schema = z.object({ + email: z.string().email('Enter a valid email address'), + password: z.string().min(1, 'Password is required'), }); -type LoginFormData = z.infer; +type FormValues = z.infer; -export default function LoginPage() { +function LoginForm() { const router = useRouter(); - const { setAuth } = useAuthStore(); - const [apiError, setApiError] = useState(""); + const searchParams = useSearchParams(); + const { login, isLoading } = useAuthStore(); const { register, handleSubmit, - formState: { errors }, setError, - } = useForm({ - resolver: zodResolver(loginSchema), - }); - - const loginMutation = useLoginMutation({ - onSuccess: (data) => { - setAuth(data.token, data.user); - router.push("/dashboard"); - }, - onError: (error: any) => { - if (error.statusCode === 401) { - setApiError("Invalid email or password"); - } else if (error.errors) { - // Handle field-specific errors from API - Object.entries(error.errors).forEach(([field, messages]) => { - setError(field as keyof LoginFormData, { - message: Array.isArray(messages) ? messages[0] : messages, - }); - }); - } else { - setApiError(error.message || "Login failed"); - } - }, - }); + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); - const onSubmit = (data: LoginFormData) => { - setApiError(""); - loginMutation.mutate(data); + const onSubmit = async (values: FormValues) => { + try { + await login(values); + const redirect = searchParams.get('redirect') || '/dashboard'; + router.push(redirect); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + 'Something went wrong. Please try again.'; + setError('root', { message }); + } }; return ( -
-
-

Sign in to your account

-

- Or{" "} - - create a new account - -

-
+ <> +

Welcome back

+

Sign in to your account to continue

-
- {/* Email Field */} -
- -
- - {errors.email && ( -

{errors.email.message}

- )} -
-
+ + - {/* Password Field */} -
- -
- - {errors.password && ( -

{errors.password.message}

- )} +
+
+ + + Forgot password? +
+
- {/* API Error */} - {apiError && ( -
-
{apiError}
-
+ {errors.root && ( +

+ {errors.root.message} +

)} - {/* Submit Button */} -
- -
+ -
+ +

+ Don't have an account?{' '} + + Create one + +

+ + ); +} + +export default function LoginPage() { + return ( + + + ); } diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx index 93fe3d5c..2cf6d483 100644 --- a/frontend/app/(auth)/register/page.tsx +++ b/frontend/app/(auth)/register/page.tsx @@ -1,232 +1,110 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useRegisterMutation, useLoginMutation } from "@/lib/query/mutations/auth"; -import { useAuthStore } from "@/store/auth.store"; -import { Button } from "@/components/ui/button"; - -// Zod schema for registration validation -const registerSchema = z - .object({ - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - email: z.string().email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), - confirmPassword: z.string().min(1, "Please confirm your password"), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }); - -type RegisterFormData = z.infer; +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth.store'; + +const schema = z.object({ + firstName: z.string().min(1, 'First name is required').max(50), + lastName: z.string().min(1, 'Last name is required').max(50), + email: z.string().email('Enter a valid email address'), + password: z + .string() + .min(8, 'Password must be at least 8 characters'), +}); + +type FormValues = z.infer; export default function RegisterPage() { const router = useRouter(); - const { setAuth } = useAuthStore(); - const [apiError, setApiError] = useState(""); - - const [password, setPassword] = useState(""); + const { register: registerUser, isLoading } = useAuthStore(); const { register, handleSubmit, - formState: { errors }, setError, - } = useForm({ - resolver: zodResolver(registerSchema), - }); - - const registerMutation = useRegisterMutation({ - onSuccess: (data) => { - // Auto-login after successful registration - loginMutation.mutate({ - email: data.user.email, - password: password, // Use the password from form state - }); - }, - onError: (error: any) => { - if (error.errors) { - // Handle field-specific errors from API - Object.entries(error.errors).forEach(([field, messages]) => { - setError(field as keyof RegisterFormData, { - message: Array.isArray(messages) ? messages[0] : messages, - }); - }); - } else { - setApiError(error.message || "Registration failed"); - } - }, - }); - - const loginMutation = useLoginMutation({ - onSuccess: (data) => { - setAuth(data.token, data.user); - router.push("/dashboard"); - }, - onError: (error: any) => { - setApiError("Registration successful but login failed. Please try logging in manually."); - }, - }); - - const onSubmit = (data: RegisterFormData) => { - setApiError(""); - setPassword(data.password); // Store password for auto-login - - const { confirmPassword, ...registerData } = data; - - registerMutation.mutate({ - ...registerData, - name: `${data.firstName} ${data.lastName}`, - }); + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); + + const onSubmit = async (values: FormValues) => { + try { + await registerUser(values); + router.push('/dashboard'); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + 'Something went wrong. Please try again.'; + setError('root', { message }); + } }; return ( -
-
-

Create your account

-

- Already have an account?{" "} - - Sign in - -

-
- -
- {/* Name Fields */} -
-
- -
- - {errors.firstName && ( -

{errors.firstName.message}

- )} -
-
- -
- -
- - {errors.lastName && ( -

{errors.lastName.message}

- )} -
-
+ <> +

Create your account

+

Start managing your assets today

+ + +
+ +
- {/* Email Field */} -
- -
- - {errors.email && ( -

{errors.email.message}

- )} -
-
- - {/* Password Fields */} -
-
- -
- - {errors.password && ( -

{errors.password.message}

- )} -
-
- -
- -
- - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
-
-
- - {/* API Error */} - {apiError && ( -
-
{apiError}
-
+ + + + + {errors.root && ( +

+ {errors.root.message} +

)} - {/* Submit Button */} -
- -
+ -
+ +

+ Already have an account?{' '} + + Sign in + +

+ ); } diff --git a/frontend/app/(dashboard)/assets/[id]/page.tsx b/frontend/app/(dashboard)/assets/[id]/page.tsx index c53d409d..c033518b 100644 --- a/frontend/app/(dashboard)/assets/[id]/page.tsx +++ b/frontend/app/(dashboard)/assets/[id]/page.tsx @@ -1,3 +1,4 @@ +// frontend/app/(public)/assets/[id]/page.tsx "use client"; import { useState } from "react"; @@ -8,18 +9,13 @@ import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; import { useAsset, useAssetHistory } from "@/lib/query/hooks/useAsset"; -import { useAuthStore } from "@/store/auth.store"; -import { TransferAssetDialog } from "@/components/assets/transfer-dialog"; -import { MoveHorizontal } from "lucide-react"; -type Tab = "overview" | "history" | "documents"; +type Tab = "overview" | "history"; export default function AssetDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); const [tab, setTab] = useState("overview"); - const [isTransferOpen, setIsTransferOpen] = useState(false); - const { user } = useAuthStore(); const { data: asset, isLoading } = useAsset(id); const { data: history = [] } = useAssetHistory(id); @@ -46,7 +42,6 @@ export default function AssetDetailPage() { const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ { key: "overview", label: "Overview", icon: }, { key: "history", label: "History", icon: }, - { key: "documents", label: "Documents", icon: }, ]; return ( @@ -78,16 +73,6 @@ export default function AssetDetailPage() {
- - {(user?.role === 'ADMIN' || user?.role === 'MANAGER') && ( - - )}
@@ -97,10 +82,11 @@ export default function AssetDetailPage() {
)} - - {tab === "documents" && } - - {isTransferOpen && ( - setIsTransferOpen(false)} - /> - )} -
- ); -} - -// Asset Documents Section -import { - useAssetDocuments, - useUploadDocument, - useDeleteDocument, -} from "@/lib/query/hooks/useAsset"; - -function AssetDocumentsSection({ assetId }: { assetId: string }) { - const { data: documents = [], isLoading } = useAssetDocuments(assetId); - const uploadMutation = useUploadDocument(assetId); - const deleteMutation = useDeleteDocument(assetId); - const [file, setFile] = useState(null); - const [name, setName] = useState(""); - - const handleUpload = (e: React.FormEvent) => { - e.preventDefault(); - if (file) { - uploadMutation.mutate({ file, name: name || file.name }); - setFile(null); - setName(""); - } - }; - - return ( -
-

- Asset Documents -

-
- setFile(e.target.files?.[0] || null)} - className="border rounded px-2 py-1 text-sm" - /> - setName(e.target.value)} - className="border rounded px-2 py-1 text-sm" - /> - -
- {isLoading ? ( -
Loading documents...
- ) : documents.length === 0 ? ( -
No documents uploaded yet.
- ) : ( -
    - {documents.map((doc) => ( -
  • -
    - - {doc.name} - - {doc.type} - - {(doc.size / 1024).toFixed(1)} KB - - - Uploaded by {doc.uploadedBy?.firstName}{" "} - {doc.uploadedBy?.lastName} - - - {format(new Date(doc.createdAt), "MMM d, yyyy")} - -
    - -
  • - ))} -
- )}
); } diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx index 6e808bba..3209bdda 100644 --- a/frontend/app/(dashboard)/assets/page.tsx +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -1,327 +1,208 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Search, Plus, ChevronLeft, ChevronRight } from "lucide-react"; +import { Plus, Search, SlidersHorizontal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; -import { useAssets } from "@/lib/query/hooks/useAsset"; -import { AssetStatus } from "@/lib/query/types/asset"; +import { CreateAssetModal } from "@/components/assets/create-asset-modal"; +import { useAssets } from "@/lib/query/hooks/useAssets"; +import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; -type SortField = "assetId" | "name" | "category" | "status" | "condition" | "department" | "assignedTo"; -type SortOrder = "asc" | "desc"; +const STATUS_OPTIONS = ["All", ...Object.values(AssetStatus)]; export default function AssetsPage() { const router = useRouter(); + const [showModal, setShowModal] = useState(false); const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [sortField, setSortField] = useState("assetId"); - const [sortOrder, setSortOrder] = useState("asc"); - - const itemsPerPage = 10; - - // Debounce search - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(search); - setCurrentPage(1); // Reset to page 1 when search changes - }, 300); - - return () => clearTimeout(timer); - }, [search]); - - // Reset to page 1 when filter changes - useEffect(() => { - setCurrentPage(1); - }, [statusFilter]); - - const { data, isLoading, error } = useAssets({ - page: currentPage, - limit: itemsPerPage, - search: debouncedSearch, - status: statusFilter || undefined, - sortBy: sortField, - sortOrder, + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + + const { data, isLoading, refetch } = useAssets({ + search: search || undefined, + status: (status as AssetStatus) || undefined, + page, + limit: 20, }); - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortField(field); - setSortOrder("asc"); - } - }; - - const handleRowClick = (assetId: string) => { - router.push(`/assets/${assetId}`); - }; - - const handlePreviousPage = () => { - setCurrentPage((prev) => Math.max(1, prev - 1)); - }; - - const handleNextPage = () => { - setCurrentPage((prev) => Math.min(data?.totalPages || 1, prev + 1)); - }; - - if (error) { - return ( -
-

Error loading assets.

- -
- ); - } + const assets = data?.data ?? []; + const total = data?.total ?? 0; + const totalPages = Math.ceil(total / 20); return (
{/* Header */} -
-
-
-

Assets

-

- {data?.total || 0} total assets -

-
- +
+
+

Assets

+

+ {total > 0 + ? `${total} asset${total !== 1 ? "s" : ""} registered` + : "No assets yet"} +

+
{/* Filters */} -
-
- {/* Search */} -
-
- - setSearch(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
-
- - {/* Status Filter */} -
- -
+
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + />
-
- {/* Loading State */} - {isLoading && ( -
-
-
-

Loading assets...

-
+
+ +
- )} +
{/* Table */} - {!isLoading && data && ( -
- {data.assets.length === 0 ? ( -
-

- {debouncedSearch || statusFilter - ? "No assets found matching your filters." - : "No assets registered yet."} -

- {!debouncedSearch && !statusFilter && ( - +
+
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : assets.length === 0 ? ( + + + + ) : ( + assets.map((asset) => ( + router.push(`/assets/${asset.id}`)} + className="border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + + + )) )} + +
+ Asset ID + + Name + + Category + + Status + + Condition + + Department + + Assigned To +
+ Loading assets... +
+ {search || status + ? "No assets match your filters." + : 'No assets registered yet. Click "Register Asset" to get started.'} +
+ {asset.assetId} + + {asset.name} + + {asset.category?.name ?? "—"} + + + + + + {asset.department?.name ?? "—"} + + {asset.assignedTo ? ( + `${asset.assignedTo.name}` + ) : ( + Unassigned + )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} — {total} total +

+
+ +
- ) : ( - <> -
- - - - - - - - - - - - - - {data.assets.map((asset) => ( - handleRowClick(asset.id)} - className="hover:bg-gray-50 cursor-pointer transition-colors" - > - - - - - - - - - ))} - -
- - - - - - - - - - - - - -
- {asset.assetId} - - {asset.name} - - {asset.category?.name || "—"} - - - - - - {asset.department?.name || "—"} - - {asset.assignedTo - ? `${asset.assignedTo.name}` - : "Unassigned"} -
-
+
+ )} +
- {/* Pagination */} - {data.totalPages > 1 && ( -
-
-
- Showing {((currentPage - 1) * itemsPerPage) + 1} to{" "} - {Math.min(currentPage * itemsPerPage, data.total)} of{" "} - {data.total} results -
-
- - - Page {currentPage} of {data.totalPages} - - -
-
-
- )} - - )} -
+ {showModal && ( + setShowModal(false)} + onSuccess={() => refetch()} + /> )}
); diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 00000000..1df61765 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import Link from 'next/link'; +import { format } from 'date-fns'; +import { useAuthStore } from '@/store/auth.store'; +import { useAssets } from '@/lib/query/hooks/useAssets'; +import { StatusBadge } from '@/components/assets/status-badge'; +import { AssetStatus } from '@/lib/query/types/asset'; + +export default function DashboardPage() { + const user = useAuthStore((s) => s.user); + const { data: allAssets } = useAssets({ limit: 5 }); + const { data: activeAssets } = useAssets({ status: AssetStatus.ACTIVE, limit: 1 }); + const { data: assignedAssets } = useAssets({ status: AssetStatus.ASSIGNED, limit: 1 }); + + const total = allAssets?.total ?? 0; + const active = activeAssets?.total ?? 0; + const assigned = assignedAssets?.total ?? 0; + const recent = allAssets?.data ?? []; + + const stats = [ + { label: 'Total Assets', value: total }, + { label: 'Active', value: active }, + { label: 'Assigned', value: assigned }, + { label: 'In Maintenance', value: total - active - assigned }, + ]; + + return ( +
+
+

+ Welcome back{user ? `, ${user.firstName}` : ''} +

+

Here's an overview of your assets

+
+ + {/* Stat cards */} +
+ {stats.map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* Recent assets */} +
+
+

Recent Assets

+ + View all + +
+ + {recent.length === 0 ? ( +

+ No assets yet.{' '} + Register your first asset +

+ ) : ( +
+ {recent.map((asset) => ( + +
+

{asset.name}

+

+ {asset.assetId} · {asset.department?.name} · {format(new Date(asset.createdAt), 'MMM d')} +

+
+ + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/departments/page.tsx b/frontend/app/(dashboard)/departments/page.tsx index 20e9951a..9e0f9bbf 100644 --- a/frontend/app/(dashboard)/departments/page.tsx +++ b/frontend/app/(dashboard)/departments/page.tsx @@ -1,204 +1,334 @@ 'use client'; -import React, { useState } from 'react'; +import { useState } from 'react'; +import { Plus, Trash2, Building2, Tag, Package } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { - useDepartmentsList, - useCreateDepartment, - useDeleteDepartment, - useCategories, - useCreateCategory, - useDeleteCategory -} from '@/lib/query/hooks/query.hook'; + useDepartmentsList, + useCreateDepartment, + useDeleteDepartment, + useCategories, + useCreateCategory, + useDeleteCategory, +} from '@/lib/query/hooks/useAssets'; import { DepartmentWithCount, CategoryWithCount } from '@/lib/api/assets'; -import { Plus, Trash2, LayoutGrid, Tags, Loader2, AlertCircle } from 'lucide-react'; -import { toast } from 'react-toastify'; -type TabType = 'departments' | 'categories'; +type Tab = 'departments' | 'categories'; export default function DepartmentsPage() { - const [activeTab, setActiveTab] = useState('departments'); - const [isAdding, setIsAdding] = useState(false); - const [formData, setFormData] = useState({ name: '', description: '' }); - - const { data: departments, isLoading: isLoadingDepts } = useDepartmentsList(); - const { data: categories, isLoading: isLoadingCats } = useCategories(); - - const createDept = useCreateDepartment(); - const deleteDept = useDeleteDepartment(); - const createCat = useCreateCategory(); - const deleteCat = useDeleteCategory(); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!formData.name.trim()) return; - - try { - if (activeTab === 'departments') { - await createDept.mutateAsync(formData); - } else { - await createCat.mutateAsync(formData); - } - toast.success(`${activeTab === 'departments' ? 'Department' : 'Category'} created successfully`); - setFormData({ name: '', description: '' }); - setIsAdding(false); - } catch (err: any) { - toast.error(err.message || `Failed to create ${activeTab}`); - } - }; - - const handleDelete = async (id: string, name: string) => { - const confirmMessage = `Are you sure you want to delete ${name}? Assets in this ${activeTab === 'departments' ? 'department' : 'category'} will need to be reassigned/recategorised.`; - if (!window.confirm(confirmMessage)) return; - - try { - if (activeTab === 'departments') { - await deleteDept.mutateAsync(id); - } else { - await deleteCat.mutateAsync(id); - } - toast.success(`${activeTab === 'departments' ? 'Department' : 'Category'} deleted successfully`); - } catch (err: any) { - toast.error(err.message || `Failed to delete ${activeTab}`); - } - }; - - const items = activeTab === 'departments' ? departments : categories; - const isLoading = activeTab === 'departments' ? isLoadingDepts : isLoadingCats; - - return ( -
-
-

Management

- -
- - -
+ const [tab, setTab] = useState('departments'); + + return ( +
+ {/* Header */} +
+

Organisation

+

Manage departments and asset categories

+
+ + {/* Tabs */} +
+ {([ + { key: 'departments' as Tab, label: 'Departments', icon: }, + { key: 'categories' as Tab, label: 'Categories', icon: }, + ] as const).map(({ key, label, icon }) => ( + + ))} +
+ + {tab === 'departments' && } + {tab === 'categories' && } +
+ ); +} + +// ── Departments Tab ────────────────────────────────────────── + +function DepartmentsTab() { + const { data: departments = [], isLoading } = useDepartmentsList(); + const createDept = useCreateDepartment(); + const deleteDept = useDeleteDepartment(); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + const handleCreate = async () => { + if (!name.trim()) { setFormError('Name is required'); return; } + setFormError(''); + try { + await createDept.mutateAsync({ name: name.trim(), description: description.trim() || undefined }); + setName(''); + setDescription(''); + setShowForm(false); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setFormError(msg || 'Failed to create department.'); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteDept.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+

+ {departments.length} department{departments.length !== 1 ? 's' : ''} +

+ +
+ + {/* Inline create form */} + {showForm && ( +
+

New Department

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + setDescription(e.target.value)} + /> + {formError &&

{formError}

} +
+ +
+
+
+ )} + + {/* List */} + {isLoading ? ( +
Loading departments...
+ ) : departments.length === 0 ? ( + } + title="No departments yet" + message='Click "Add Department" to create your first one.' + /> + ) : ( +
+ {departments.map((dept) => ( + setDeleteTarget(dept)} + /> + ))} +
+ )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteDept.isPending} + /> + )} +
+ ); +} + +// ── Categories Tab ─────────────────────────────────────────── + +function CategoriesTab() { + const { data: categories = [], isLoading } = useCategories(); + const createCat = useCreateCategory(); + const deleteCat = useDeleteCategory(); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); -
-

- {activeTab} ({items?.length || 0}) -

- + const handleCreate = async () => { + if (!name.trim()) { setFormError('Name is required'); return; } + setFormError(''); + try { + await createCat.mutateAsync({ name: name.trim(), description: description.trim() || undefined }); + setName(''); + setDescription(''); + setShowForm(false); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setFormError(msg || 'Failed to create category.'); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteCat.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+

+ {categories.length} categor{categories.length !== 1 ? 'ies' : 'y'} +

+ +
+ + {/* Inline create form */} + {showForm && ( +
+

New Category

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + setDescription(e.target.value)} + /> + {formError &&

{formError}

} +
+ +
+
+
+ )} - {isAdding && ( -
-
-
- - setFormData({ ...formData, name: e.target.value })} - className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - /> - {activeTab === 'departments' ? createDept.isError && ( -

- {createDept.error?.message} -

- ) : createCat.isError && ( -

- {createCat.error?.message} -

- )} -
-
- - setFormData({ ...formData, description: e.target.value })} - className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - /> -
-
-
- - -
-
- )} - - {isLoading ? ( -
- -

Loading {activeTab}...

-
- ) : ( -
- {items?.map((item: any) => ( -
-
-

{item.name}

- -
-

- {item.description || 'No description provided.'} -

-
- Asset Count - {item.assetCount || 0} -
-
- ))} - {!isLoading && items?.length === 0 && ( -
- -

No {activeTab} found. Create one to get started.

-
- )} -
- )} + {/* List */} + {isLoading ? ( +
Loading categories...
+ ) : categories.length === 0 ? ( + } + title="No categories yet" + message='Click "Add Category" to create your first one.' + /> + ) : ( +
+ {categories.map((cat) => ( + setDeleteTarget(cat)} + /> + ))}
- ); + )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteCat.isPending} + /> + )} +
+ ); +} + +// ── Shared components ──────────────────────────────────────── + +function EntityCard({ + name, + description, + count, + countLabel, + onDelete, +}: { + name: string; + description?: string | null; + count: number; + countLabel: string; + onDelete: () => void; +}) { + return ( +
+
+

{name}

+ {description && ( +

{description}

+ )} +
+ + {count} {countLabel}{count !== 1 ? 's' : ''} +
+
+ +
+ ); +} + +function EmptyState({ icon, title, message }: { icon: React.ReactNode; title: string; message: string }) { + return ( +
+
{icon}
+

{title}

+

{message}

+
+ ); } diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..8e45bb04 --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,19 @@ +// frontend/app/(dashboard)/layout.tsx +import { Sidebar } from "@/components/layout/sidebar"; +import { Topbar } from "@/components/layout/topbar"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + +
+
{children}
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/reports/page.tsx b/frontend/app/(dashboard)/reports/page.tsx index 108334d8..27f9f341 100644 --- a/frontend/app/(dashboard)/reports/page.tsx +++ b/frontend/app/(dashboard)/reports/page.tsx @@ -5,8 +5,8 @@ import Link from "next/link"; import { format } from "date-fns"; import { BarChart3, Package } from "lucide-react"; import { clsx } from "clsx"; -import { useAssets } from "@/lib/query/hooks/useAsset"; -import { Asset, AssetStatus } from "@/lib/query/types/asset"; +import { useReportsSummary } from "@/lib/query/hooks/useReports"; +import { AssetStatus } from "@/lib/query/types/asset"; import { StatusBadge } from "@/components/assets/status-badge"; const STATUS_COLORS: Record = { @@ -17,7 +17,7 @@ const STATUS_COLORS: Record = { }; export default function ReportsPage() { - const { data, isLoading } = useAssets({ page: 1, limit: 1000 }); + const { data, isLoading } = useReportsSummary(); if (isLoading) { return ( @@ -29,50 +29,7 @@ export default function ReportsPage() { if (!data) return null; - const assets = data?.assets ?? []; - const total = assets.length; - - const byStatus = assets.reduce>( - (acc, asset) => { - acc[asset.status] += 1; - return acc; - }, - { - [AssetStatus.ACTIVE]: 0, - [AssetStatus.ASSIGNED]: 0, - [AssetStatus.MAINTENANCE]: 0, - [AssetStatus.RETIRED]: 0, - }, - ); - - const byCategory = Object.values( - assets.reduce>((acc, asset) => { - const categoryName = asset.category?.name ?? "Uncategorized"; - if (!acc[categoryName]) { - acc[categoryName] = { name: categoryName, count: 0 }; - } - acc[categoryName].count += 1; - return acc; - }, {}), - ); - - const byDepartment = Object.values( - assets.reduce>((acc, asset) => { - const departmentName = asset.department?.name ?? "Unassigned"; - if (!acc[departmentName]) { - acc[departmentName] = { name: departmentName, count: 0 }; - } - acc[departmentName].count += 1; - return acc; - }, {}), - ); - - const recent = [...assets] - .sort( - (left, right) => - new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), - ) - .slice(0, 10); + const { total, byStatus, byCategory, byDepartment, recent } = data; const statusItems = Object.entries(byStatus) as [AssetStatus, number][]; const topCategories = [...byCategory] @@ -230,7 +187,7 @@ export default function ReportsPage() {

) : (
- {recent.map((asset: Asset) => ( + {recent.map((asset) => ( ; export default function SettingsPage() { - const { user } = useAuthStore(); + const user = useAuthStore((s) => s.user); const updateProfile = useUpdateProfile(); const [profileSaved, setProfileSaved] = useState(false); const [passwordSaved, setPasswordSaved] = useState(false); diff --git a/frontend/app/(dashboard)/users/page.tsx b/frontend/app/(dashboard)/users/page.tsx new file mode 100644 index 00000000..c6013f4d --- /dev/null +++ b/frontend/app/(dashboard)/users/page.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Shield, UserCircle } from 'lucide-react'; +import { format } from 'date-fns'; +import { clsx } from 'clsx'; +import { useUsersList, useUpdateUserRole } from '@/lib/query/hooks/useUsers'; +import { useAuthStore } from '@/store/auth.store'; +import { AppUser, UserRole } from '@/lib/api/users'; + +const ROLES: UserRole[] = ['admin', 'manager', 'staff']; + +const roleConfig: Record = { + admin: { label: 'Admin', className: 'bg-purple-100 text-purple-700' }, + manager: { label: 'Manager', className: 'bg-blue-100 text-blue-700' }, + staff: { label: 'Staff', className: 'bg-gray-100 text-gray-600' }, +}; + +export default function UsersPage() { + const currentUser = useAuthStore((s) => s.user); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + + const { data: users = [], isLoading } = useUsersList(); + const updateRole = useUpdateUserRole(); + + // client-side filter (list is typically small) + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return users.filter((u) => { + const matchSearch = + !q || + u.firstName.toLowerCase().includes(q) || + u.lastName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q); + const matchRole = !roleFilter || u.role === roleFilter; + return matchSearch && matchRole; + }); + }, [users, search, roleFilter]); + + const handleRoleChange = (user: AppUser, newRole: UserRole) => { + if (newRole === user.role) return; + updateRole.mutate({ id: user.id, role: newRole }); + }; + + return ( +
+ {/* Header */} +
+
+

Users

+

+ {users.length} member{users.length !== 1 ? 's' : ''} in your organisation +

+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + /> +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map((user) => { + const isCurrentUser = user.id === currentUser?.id; + const initials = `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(); + + return ( + + {/* Avatar + name */} + + + {/* Email */} + + + {/* Role dropdown */} + + + {/* Joined */} + + + ); + }) + )} + +
UserEmailRoleJoined
Loading users...
+ +

+ {search || roleFilter ? 'No users match your filters.' : 'No users found.'} +

+
+
+
+ {initials} +
+
+

+ {user.firstName} {user.lastName} + {isCurrentUser && ( + (you) + )} +

+
+
+
{user.email} + handleRoleChange(user, role)} + /> + + {format(new Date(user.createdAt), 'MMM d, yyyy')} +
+ + {/* Role legend */} + {users.length > 0 && ( +
+

+ + Role permissions: +

+ {ROLES.map((r) => ( + + {roleConfig[r].label} + + ))} +
+ )} +
+
+ ); +} + +// ── Role Dropdown ──────────────────────────────────────────── + +function RoleDropdown({ + value, + disabled, + onChange, +}: { + value: UserRole; + disabled: boolean; + onChange: (role: UserRole) => void; +}) { + const config = roleConfig[value]; + + return ( +
+ + {/* Chevron icon overlay */} + + â–¾ + +
+ ); +} diff --git a/frontend/app/dashboard-layout.ts b/frontend/app/dashboard-layout.ts deleted file mode 100644 index 28c723ad..00000000 --- a/frontend/app/dashboard-layout.ts +++ /dev/null @@ -1,166 +0,0 @@ -// frontend/app/(dashboard)/layout.tsx -import { Sidebar } from '@/components/layout/sidebar'; -import { Topbar } from '@/components/layout/topbar'; - -export default function DashboardLayout({ children }: { children: React.ReactNode }) { -return ( -
- - -
-
{children}
-
-
-); -} - -// frontend/components/layout/topbar.tsx -'use client'; - -import { useRouter } from 'next/navigation'; -import { LogOut, User } from 'lucide-react'; -import { useAuthStore } from '@/store/auth.store'; - -export function Topbar() { -const router = useRouter(); -const { user, logout } = useAuthStore(); - -const handleLogout = async () => { -await logout(); -router.push('/login'); -}; - -const initials = user -? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase() -: '?'; - -return ( -
-
- -
- {/* User info */} -
-
- {user ? ( - {initials} - ) : ( - - )} -
- {user && ( -
-

- {user.firstName} {user.lastName} -

-

{user.role}

-
- )} -
- - {/* Divider */} -
- - {/* Logout */} - -
-
- -); -} - -// frontend/components/layout/sidebar.tsx -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { clsx } from "clsx"; -import { -LayoutDashboard, -Package, -Users, -Building2, -BarChart3, -Settings, -} from "lucide-react"; - -const navItems = [ -{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, -{ href: "/assets", label: "Assets", icon: Package }, -{ href: "/users", label: "Users", icon: Users }, -{ href: "/departments", label: "Organisation", icon: Building2 }, -{ href: "/reports", label: "Reports", icon: BarChart3 }, -]; - -export function Sidebar() { -const pathname = usePathname(); - -return ( - - -); -} diff --git a/frontend/app/hooks/useUsers.ts b/frontend/app/hooks/useUsers.ts deleted file mode 100644 index aadc2430..00000000 --- a/frontend/app/hooks/useUsers.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getUsers, updateUserRole, updateProfile } from '../lib/api/usersApi'; -import { getReportsSummary } from '../lib/api/reportsApi'; -import { User, ReportSummary } from '../lib/types/users'; -import { useAuthStore } from '../store/authStore'; - -export function useUsersList() { - return useQuery({ - queryKey: ['users'], - queryFn: getUsers, - }); -} - -export function useUpdateUserRole() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, role }: { id: string; role: string }) => - updateUserRole(id, role), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} - -export function useUpdateProfile() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - updateProfile(id, payload), - onSuccess: (updatedUser) => { - // Update Zustand store to keep sidebar in sync - useAuthStore.getState().setUser(updatedUser); - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} - -export function useReportsSummary() { - return useQuery({ - queryKey: ['reportsSummary'], - queryFn: getReportsSummary, - }); -} diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index 22f8b8d6..ab91c89f 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -1,8 +1,7 @@ -'use client'; +"use client"; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AuthInitializer } from '@/components/auth/AuthInitializer'; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient({ defaultOptions: { @@ -15,9 +14,6 @@ const queryClient = new QueryClient({ export function Providers({ children }: { children: React.ReactNode }) { return ( - - - {children} - + {children} ); } diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx deleted file mode 100644 index 287b41f0..00000000 --- a/frontend/app/users/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { useUsers, useUpdateUserRole } from '../hooks/useUsers'; -import { Avatar } from '../../components/ui/Avatar'; - -export default function UsersPage() { - const { data: users = [], isLoading } = useUsers(); - const updateRole = useUpdateUserRole(); - - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - - if (isLoading) return
Loading...
; - - const filteredUsers = users.filter((u: any) => - (u.name.toLowerCase().includes(search.toLowerCase()) || - u.email.toLowerCase().includes(search.toLowerCase())) && - (roleFilter ? u.role === roleFilter : true) - ); - - return ( -
-

Users Management

- -
- setSearch(e.target.value)} - className="border p-2" - /> - -
- - - - - - - - - - - - {filteredUsers.map((user: any) => ( - - - - - - - ))} - -
Avatar + NameEmailRoleJoined
- - {user.name}{' '} - {user.isCurrentUser && ( - - You - - )} - {user.email} - - {new Date(user.joinedAt).toLocaleDateString()}
- -
-

Role Legend

-
    -
  • - Admin — full access -
  • -
  • - Manager — manage teams -
  • -
  • - Staff — standard user -
  • -
-
-
- ); -} diff --git a/frontend/components/assets/condition-badge.tsx b/frontend/components/assets/condition-badge.tsx index a0a3f65f..d21773f0 100644 --- a/frontend/components/assets/condition-badge.tsx +++ b/frontend/components/assets/condition-badge.tsx @@ -1,47 +1,18 @@ -"use client"; +import { clsx } from 'clsx'; +import { AssetCondition } from '@/lib/query/types/asset'; -import { AssetCondition } from "@/lib/query/types/asset"; - -const conditionConfig = { - [AssetCondition.NEW]: { - label: "New", - className: "bg-emerald-100 text-emerald-800 border-emerald-200", - }, - [AssetCondition.GOOD]: { - label: "Good", - className: "bg-green-100 text-green-800 border-green-200", - }, - [AssetCondition.FAIR]: { - label: "Fair", - className: "bg-blue-100 text-blue-800 border-blue-200", - }, - [AssetCondition.POOR]: { - label: "Poor", - className: "bg-orange-100 text-orange-800 border-orange-200", - }, - [AssetCondition.DAMAGED]: { - label: "Damaged", - className: "bg-red-100 text-red-800 border-red-200", - }, +const conditionConfig: Record = { + [AssetCondition.NEW]: { label: 'New', className: 'bg-emerald-100 text-emerald-700' }, + [AssetCondition.GOOD]: { label: 'Good', className: 'bg-green-100 text-green-700' }, + [AssetCondition.FAIR]: { label: 'Fair', className: 'bg-yellow-100 text-yellow-700' }, + [AssetCondition.POOR]: { label: 'Poor', className: 'bg-orange-100 text-orange-700' }, + [AssetCondition.DAMAGED]: { label: 'Damaged', className: 'bg-red-100 text-red-700' }, }; -interface ConditionBadgeProps { - condition: AssetCondition; -} - -export function ConditionBadge({ condition }: ConditionBadgeProps) { - const config = conditionConfig[condition]; - - if (!config) { - return ( - - {condition} - - ); - } - +export function ConditionBadge({ condition }: { condition: AssetCondition }) { + const config = conditionConfig[condition] ?? { label: condition, className: 'bg-gray-100 text-gray-500' }; return ( - + {config.label} ); diff --git a/frontend/components/assets/create-asset-modal.tsx b/frontend/components/assets/create-asset-modal.tsx index 9f455960..d6b5c76c 100644 --- a/frontend/components/assets/create-asset-modal.tsx +++ b/frontend/components/assets/create-asset-modal.tsx @@ -1,20 +1,21 @@ -'use client'; - -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useCreateAsset } from '@/lib/query/hooks/useAssets'; -import { useDepartments } from '@/lib/query/hooks/useAsset'; -import { useCategories } from '@/lib/query/hooks/useAssets'; -import { AssetStatus, AssetCondition } from '@/lib/query/types/asset'; +// frontend/components/assets/create-asset-modal.tsx +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useCreateAsset } from "@/lib/query/hooks/useAssets"; +import { useDepartments } from "@/lib/query/hooks/useAsset"; +import { useCategories } from "@/lib/query/hooks/useAssets"; +import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; const schema = z.object({ - name: z.string().min(1, 'Asset name is required'), - categoryId: z.string().min(1, 'Category is required'), - departmentId: z.string().min(1, 'Department is required'), + name: z.string().min(1, "Asset name is required"), + categoryId: z.string().min(1, "Category is required"), + departmentId: z.string().min(1, "Department is required"), serialNumber: z.string().optional(), manufacturer: z.string().optional(), model: z.string().optional(), @@ -44,7 +45,10 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { formState: { errors }, } = useForm({ resolver: zodResolver(schema), - defaultValues: { condition: AssetCondition.NEW, status: AssetStatus.ACTIVE }, + defaultValues: { + condition: AssetCondition.NEW, + status: AssetStatus.ACTIVE, + }, }); const onSubmit = async (values: FormValues) => { @@ -59,16 +63,18 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { location: values.location || undefined, condition: values.condition, status: values.status, - purchasePrice: values.purchasePrice ? Number(values.purchasePrice) : undefined, + purchasePrice: values.purchasePrice + ? Number(values.purchasePrice) + : undefined, notes: values.notes || undefined, }); onSuccess?.(); onClose(); } catch (err: unknown) { const message = - (err as { response?: { data?: { message?: string } } })?.response?.data?.message || - 'Failed to register asset.'; - setError('root', { message }); + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || "Failed to register asset."; + setError("root", { message }); } }; @@ -80,81 +86,143 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { {/* Modal */}
-

Register New Asset

-
- +
{/* Category */}
- + - {errors.categoryId &&

{errors.categoryId.message}

} + {errors.categoryId && ( +

+ {errors.categoryId.message} +

+ )}
{/* Department */}
- + - {errors.departmentId &&

{errors.departmentId.message}

} + {errors.departmentId && ( +

+ {errors.departmentId.message} +

+ )}
- - + +
- - + +
{/* Condition */}
- +
- +
- +