diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b09c30e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Rust contracts: test, build wasm, lint ────────────────────────── + contracts: + name: Contracts (Rust) + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: clippy + + - name: Cache cargo registry & target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: contracts-${{ runner.os }}-cargo-${{ hashFiles('contracts/Cargo.lock') }} + restore-keys: | + contracts-${{ runner.os }}-cargo- + + - name: Run tests + run: cargo test --workspace + + - name: Build wasm + run: cargo build --release --target wasm32-unknown-unknown --workspace + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + # ── TypeScript agents: typecheck ──────────────────────────────────── + agents: + name: Agents (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + fail-fast: false + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install agents/ dependencies + working-directory: agents + run: npm ci + + - name: Typecheck agents/ + working-directory: agents + run: npx tsc --noEmit + + - name: Install agent-002-governance-analyst/ dependencies + working-directory: agent-002-governance-analyst + run: npm ci + + - name: Typecheck agent-002-governance-analyst/ + working-directory: agent-002-governance-analyst + run: npx tsc --noEmit + + # ── Spec verification: schemas, reference impls, datasets ─────────── + verify-specs: + name: Verify Specs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install root dependencies + run: npm ci + + - name: Run verify script + run: node scripts/verify.mjs diff --git a/.github/workflows/wasm-size.yml b/.github/workflows/wasm-size.yml new file mode 100644 index 0000000..340da42 --- /dev/null +++ b/.github/workflows/wasm-size.yml @@ -0,0 +1,103 @@ +name: WASM Size Report + +on: + pull_request: + branches: [main] + paths: + - "contracts/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + wasm-size: + name: Report WASM Binary Sizes + runs-on: ubuntu-latest + permissions: + pull-requests: write + defaults: + run: + working-directory: contracts + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache cargo registry & target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: wasm-size-${{ runner.os }}-cargo-${{ hashFiles('contracts/Cargo.lock') }} + restore-keys: | + wasm-size-${{ runner.os }}-cargo- + + - name: Build wasm release + run: cargo build --release --target wasm32-unknown-unknown --workspace + + - name: Collect binary sizes + id: sizes + run: | + WASM_DIR="target/wasm32-unknown-unknown/release" + { + echo "report<> "$GITHUB_OUTPUT" + + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = [ + marker, + '## WASM Binary Sizes', + '', + '${{ steps.sizes.outputs.report }}', + '', + `_Built from \`${context.sha.slice(0, 8)}\` on \`${context.ref.replace('refs/heads/', '')}\`_`, + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.gitignore b/.gitignore index 82eedf7..dfaa7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,17 @@ .DS_Store node_modules/ +# Rust build artifacts +target/ +Cargo.lock + # Local-only context (LLM analyses, workspace archives, private notes) .local/ # Claude Code +.claude/ CLAUDE.local.md + +# Agent data +*.sqlite +data/ diff --git a/agent-001-registry-reviewer/.env.example b/agent-001-registry-reviewer/.env.example new file mode 100644 index 0000000..2561907 --- /dev/null +++ b/agent-001-registry-reviewer/.env.example @@ -0,0 +1,17 @@ +# Regen LCD endpoint (Cosmos REST) +REGEN_LCD_URL=https://regen.api.chandrastation.com + +# Anthropic API key (required) +ANTHROPIC_API_KEY=sk-ant-... + +# Model (default: claude-sonnet-4-5-20250929) +ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 + +# Discord webhook for output (optional) +DISCORD_WEBHOOK_URL= + +# KOI MCP endpoint (optional, for future knowledge graph integration) +KOI_MCP_URL= + +# Polling interval in seconds (default: 300 = 5 minutes) +POLL_INTERVAL_SECONDS=300 diff --git a/agent-001-registry-reviewer/package-lock.json b/agent-001-registry-reviewer/package-lock.json new file mode 100644 index 0000000..cc2cf26 --- /dev/null +++ b/agent-001-registry-reviewer/package-lock.json @@ -0,0 +1,1475 @@ +{ + "name": "@regen/agent-001-registry-reviewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@regen/agent-001-registry-reviewer", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^11.7.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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.1.13" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/form-data": { + "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", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/agent-001-registry-reviewer/package.json b/agent-001-registry-reviewer/package.json new file mode 100644 index 0000000..308479b --- /dev/null +++ b/agent-001-registry-reviewer/package.json @@ -0,0 +1,27 @@ +{ + "name": "@regen/agent-001-registry-reviewer", + "version": "0.1.0", + "description": "Regen Network Registry Reviewer Agent — Layer 1 read-only credit class, project, and batch screening", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node --import tsx src/index.ts", + "dev": "node --import tsx --watch src/index.ts", + "analyze": "node --import tsx src/index.ts --once", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "better-sqlite3": "^11.7.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/agent-001-registry-reviewer/src/config.ts b/agent-001-registry-reviewer/src/config.ts new file mode 100644 index 0000000..2a13fb3 --- /dev/null +++ b/agent-001-registry-reviewer/src/config.ts @@ -0,0 +1,46 @@ +export const config = { + // Regen LCD endpoint + lcdUrl: process.env.REGEN_LCD_URL || "https://regen.api.chandrastation.com", + + // Anthropic + anthropicApiKey: process.env.ANTHROPIC_API_KEY || "", + model: process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5-20250929", + + // Discord webhook (optional) + discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL || "", + + // KOI MCP endpoint (optional) + koiMcpUrl: process.env.KOI_MCP_URL || "", + + // Polling + pollIntervalMs: + parseInt(process.env.POLL_INTERVAL_SECONDS || "300", 10) * 1000, + + // Screening thresholds + screening: { + approveThreshold: 700, // score >= 700 → APPROVE + rejectThreshold: 300, // score < 300 → REJECT + // Between 300–699 → CONDITIONAL + }, + + // Factor weights (must sum to 1.0) + weights: { + methodology_quality: 0.40, + reputation: 0.30, + novelty: 0.20, + completeness: 0.10, + }, + + // Agent identity + agentId: "AGENT-001", + agentName: "RegenRegistryReviewer", + governanceLayer: 1 as const, +} as const; + +export function validateConfig(): void { + if (!config.anthropicApiKey) { + throw new Error( + "ANTHROPIC_API_KEY is required. Copy .env.example to .env and set it." + ); + } +} diff --git a/agent-001-registry-reviewer/src/index.ts b/agent-001-registry-reviewer/src/index.ts new file mode 100644 index 0000000..0db4c00 --- /dev/null +++ b/agent-001-registry-reviewer/src/index.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { config, validateConfig } from "./config.js"; +import { LedgerClient } from "./ledger.js"; +import { executeOODA } from "./ooda.js"; +import { store } from "./store.js"; +import { createClassScreeningWorkflow } from "./workflows/class-screening.js"; +import { createProjectValidationWorkflow } from "./workflows/project-validation.js"; +import { createBatchReviewWorkflow } from "./workflows/batch-review.js"; + +// ── Banner ──────────────────────────────────────────────────── + +function banner() { + console.log(` + ╔══════════════════════════════════════════════════════════════╗ + ║ REGEN REGISTRY REVIEWER (AGENT-001) ║ + ║ ║ + ║ Layer 1 — Fully Automated, Informational Only ║ + ║ Workflows: WF-RR-01, WF-RR-02, WF-RR-03 ║ + ║ ║ + ║ Regen Agentic Tokenomics Framework ║ + ╚══════════════════════════════════════════════════════════════╝ +`); +} + +// ── Main loop ───────────────────────────────────────────────── + +async function runCycle(ledger: LedgerClient): Promise { + const ts = new Date().toISOString(); + console.log(`\n[${ts}] ═══ Starting registry review cycle ═══\n`); + + // WF-RR-01: Screen new credit classes + const wf01 = createClassScreeningWorkflow(ledger); + await executeOODA(wf01); + + // WF-RR-02: Validate new projects + const wf02 = createProjectValidationWorkflow(ledger); + await executeOODA(wf02); + + // WF-RR-03: Review new credit batches + const wf03 = createBatchReviewWorkflow(ledger); + await executeOODA(wf03); + + const execCount = store.getExecutionCount(); + console.log( + `[${new Date().toISOString()}] ═══ Cycle complete (${execCount} total executions logged) ═══\n` + ); +} + +async function main() { + banner(); + validateConfig(); + + const runOnce = process.argv.includes("--once"); + const ledger = new LedgerClient(); + + console.log(`Configuration:`); + console.log(` LCD endpoint: ${config.lcdUrl}`); + console.log(` LLM model: ${config.model}`); + console.log(` Discord: ${config.discordWebhookUrl ? "configured" : "not configured"}`); + console.log(` KOI MCP: ${config.koiMcpUrl ? "configured" : "not configured"}`); + console.log(` Mode: ${runOnce ? "single run" : `polling every ${config.pollIntervalMs / 1000}s`}`); + console.log(); + + // Verify LCD connectivity + try { + const { blockHeight } = await ledger.checkConnection(); + console.log( + `Connected to Regen Ledger. Latest block: ${blockHeight}\n` + ); + } catch (err) { + console.error( + `Failed to connect to Regen Ledger at ${config.lcdUrl}:`, + err + ); + process.exit(1); + } + + if (runOnce) { + // Single run mode (e.g., `npm run analyze`) + await runCycle(ledger); + } else { + // Polling mode + await runCycle(ledger); + + const interval = setInterval(() => { + runCycle(ledger).catch((err) => + console.error(`Cycle failed:`, err) + ); + }, config.pollIntervalMs); + + // Graceful shutdown + const shutdown = () => { + console.log("\nShutting down gracefully..."); + clearInterval(interval); + store.close(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + console.log("Agent running. Press Ctrl+C to stop.\n"); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/agent-001-registry-reviewer/src/ledger.ts b/agent-001-registry-reviewer/src/ledger.ts new file mode 100644 index 0000000..03a4351 --- /dev/null +++ b/agent-001-registry-reviewer/src/ledger.ts @@ -0,0 +1,127 @@ +import { config } from "./config.js"; +import type { CreditClass, Project, CreditBatch } from "./types.js"; + +/** + * Regen Ledger LCD (REST) client — ecocredit module. + * + * Talks directly to a Cosmos LCD endpoint — no MCP dependency. + * When Ledger MCP becomes available, this can be swapped out + * behind the same interface. + */ +export class LedgerClient { + private baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = (baseUrl || config.lcdUrl).replace(/\/$/, ""); + } + + // ── Credit Classes ───────────────────────────────────────── + + async getCreditClasses(): Promise { + const params = new URLSearchParams(); + params.set("pagination.limit", "200"); + + const data = await this.get( + `/regen/ecocredit/v1/classes?${params.toString()}` + ); + return (data.classes || []) as CreditClass[]; + } + + async getCreditClass(classId: string): Promise { + try { + const data = await this.get( + `/regen/ecocredit/v1/classes/${classId}` + ); + return (data.class || null) as CreditClass | null; + } catch { + return null; + } + } + + async getClassIssuers(classId: string): Promise { + try { + const params = new URLSearchParams(); + params.set("pagination.limit", "100"); + + const data = await this.get( + `/regen/ecocredit/v1/classes/${classId}/issuers?${params.toString()}` + ); + return (data.issuers || []) as string[]; + } catch { + return []; + } + } + + // ── Projects ─────────────────────────────────────────────── + + async getProjects(): Promise { + const params = new URLSearchParams(); + params.set("pagination.limit", "200"); + + const data = await this.get( + `/regen/ecocredit/v1/projects?${params.toString()}` + ); + return (data.projects || []) as Project[]; + } + + async getProject(projectId: string): Promise { + try { + const data = await this.get( + `/regen/ecocredit/v1/projects/${projectId}` + ); + return (data.project || null) as Project | null; + } catch { + return null; + } + } + + // ── Credit Batches ───────────────────────────────────────── + + async getCreditBatches(): Promise { + const params = new URLSearchParams(); + params.set("pagination.limit", "200"); + params.set("pagination.reverse", "true"); + + const data = await this.get( + `/regen/ecocredit/v1/batches?${params.toString()}` + ); + return (data.batches || []) as CreditBatch[]; + } + + async getCreditBatch(denom: string): Promise { + try { + const data = await this.get( + `/regen/ecocredit/v1/batches/${denom}` + ); + return (data.batch || null) as CreditBatch | null; + } catch { + return null; + } + } + + // ── Connectivity check ──────────────────────────────────── + + async checkConnection(): Promise<{ blockHeight: string }> { + const data = await this.get(`/cosmos/base/tendermint/v1beta1/blocks/latest`); + const block = data.block as Record | undefined; + const header = block?.header as Record | undefined; + const height = (header?.height as string) || "unknown"; + return { blockHeight: height }; + } + + // ── HTTP ──────────────────────────────────────────────────── + + private async get(path: string): Promise> { + const url = `${this.baseUrl}${path}`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) { + throw new Error(`LCD ${res.status}: ${res.statusText} — ${url}`); + } + + return (await res.json()) as Record; + } +} diff --git a/agent-001-registry-reviewer/src/ooda.ts b/agent-001-registry-reviewer/src/ooda.ts new file mode 100644 index 0000000..3d48e07 --- /dev/null +++ b/agent-001-registry-reviewer/src/ooda.ts @@ -0,0 +1,86 @@ +import { config } from "./config.js"; +import { store } from "./store.js"; +import type { OODAExecution } from "./types.js"; + +/** + * Generic OODA loop executor. + * + * Each workflow provides its own observe/orient/decide/act functions. + * The executor handles lifecycle, timing, error handling, and persistence. + */ +export interface OODAWorkflow { + id: string; + name: string; + observe: () => Promise; + orient: (observations: TObserve) => Promise; + decide: (orientation: TOrient) => Promise; + act: (decision: TDecide) => Promise; +} + +export async function executeOODA( + workflow: OODAWorkflow +): Promise> { + const executionId = crypto.randomUUID(); + const execution: OODAExecution = { + executionId, + workflowId: workflow.id, + status: "running", + observations: null as unknown as TObserve, + orientation: null, + decision: null, + actions: null, + startedAt: new Date(), + completedAt: null, + error: null, + }; + + const log = (phase: string, msg: string) => + console.log( + `[${new Date().toISOString()}] [${workflow.id}] [${phase}] ${msg}` + ); + + try { + // ── OBSERVE ─────────────────────────────────────────── + log("OBSERVE", "Gathering data..."); + execution.observations = await workflow.observe(); + + // ── ORIENT ──────────────────────────────────────────── + log("ORIENT", "Analyzing context..."); + execution.orientation = await workflow.orient(execution.observations); + + // ── DECIDE ──────────────────────────────────────────── + log("DECIDE", "Making decision..."); + execution.decision = await workflow.decide(execution.orientation); + + // ── ACT ─────────────────────────────────────────────── + log("ACT", "Executing actions..."); + execution.actions = await workflow.act(execution.decision); + + execution.status = "completed"; + log("DONE", "Workflow completed successfully."); + } catch (err) { + execution.status = "failed"; + execution.error = err instanceof Error ? err.message : String(err); + log("ERROR", execution.error); + } + + execution.completedAt = new Date(); + + // Persist execution record + store.logExecution({ + executionId: execution.executionId, + workflowId: execution.workflowId, + agentId: config.agentId, + status: execution.status, + startedAt: execution.startedAt.toISOString(), + completedAt: execution.completedAt.toISOString(), + result: JSON.stringify({ + orientation: execution.orientation, + decision: execution.decision, + actions: execution.actions, + error: execution.error, + }), + }); + + return execution; +} diff --git a/agent-001-registry-reviewer/src/output.ts b/agent-001-registry-reviewer/src/output.ts new file mode 100644 index 0000000..606dc6f --- /dev/null +++ b/agent-001-registry-reviewer/src/output.ts @@ -0,0 +1,61 @@ +import { config } from "./config.js"; +import type { OutputMessage } from "./types.js"; + +/** + * Output dispatcher. + * + * Sends agent outputs to configured channels: + * - Console (always) + * - Discord webhook (if configured) + * + * Extend this to add Telegram, Twitter, KOI object creation, etc. + */ +export async function output(msg: OutputMessage): Promise { + // Always log to console + logToConsole(msg); + + // Discord webhook (if configured) + if (config.discordWebhookUrl) { + await postToDiscord(msg).catch((err) => + console.error(` Discord post failed: ${err}`) + ); + } +} + +function logToConsole(msg: OutputMessage): void { + const prefix = + msg.alertLevel === "CRITICAL" + ? "!!! CRITICAL" + : msg.alertLevel === "HIGH" + ? "!! HIGH" + : "--"; + + console.log(`\n${"=".repeat(72)}`); + console.log( + `${prefix} [${msg.workflow}] ${msg.entityId}: ${msg.title}` + ); + console.log(`${"=".repeat(72)}`); + console.log(msg.content); + console.log(`${"=".repeat(72)}\n`); +} + +async function postToDiscord(msg: OutputMessage): Promise { + // Truncate content for Discord's 2000-char limit + const maxLen = 1900; + let content = `**[${msg.workflow}]** ${msg.entityId}: **${msg.title}**\n\n`; + const remaining = maxLen - content.length; + content += + msg.content.length > remaining + ? msg.content.slice(0, remaining - 20) + "\n\n*...truncated*" + : msg.content; + + await fetch(config.discordWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: config.agentName, + content, + }), + signal: AbortSignal.timeout(10_000), + }); +} diff --git a/agent-001-registry-reviewer/src/reviewer.ts b/agent-001-registry-reviewer/src/reviewer.ts new file mode 100644 index 0000000..38ab14e --- /dev/null +++ b/agent-001-registry-reviewer/src/reviewer.ts @@ -0,0 +1,265 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { config } from "./config.js"; +import type { + CreditClass, + Project, + CreditBatch, + ScreeningResult, +} from "./types.js"; + +const client = new Anthropic({ apiKey: config.anthropicApiKey }); + +const SYSTEM_PROMPT = `You are AGENT-001, the Registry Reviewer for Regen Network. + +Your responsibilities: +1. Pre-screening credit class applications for methodology quality and completeness +2. Validating project registrations against their credit class requirements +3. Reviewing credit batch issuances for accuracy, anomalies, and compliance +4. Flagging potential issues before they reach on-chain governance + +Core Principles: +- NEVER approve or reject unilaterally — provide scoring and recommendations +- Be conservative: false positives (flagging good items) are better than false negatives (missing bad ones) +- Consider methodology rigor, issuer reputation, data completeness, and novelty +- Cite specific concerns with evidence from the data +- Be precise with scores and confidence levels + +Scoring Rubric (each factor 0–1000): +- Methodology Quality (40% weight): Rigor of the credit methodology, scientific basis, measurement/reporting/verification approach +- Reputation (30% weight): Track record of admin/issuer addresses, history on Regen Network, known entities +- Novelty (20% weight): Innovation in approach, new credit types, geographic expansion, methodological advances +- Completeness (10% weight): Metadata quality, required fields present, documentation thoroughness + +Composite Score = weighted sum of factors (0–1000) +- >= 700: Recommend APPROVE +- 300–699: Recommend CONDITIONAL (needs human review) +- < 300: Recommend REJECT + +Confidence (0–1000): How certain you are in your assessment given available data. + +Regen Network Context: +- Cosmos SDK-based blockchain for ecological assets (eco-credits) +- Credit classes define methodologies (e.g., C01 for carbon, C02 for forestry) +- Projects register under a class and represent real-world ecological activity +- Batches are issuances of credits for a project over a date range +- x/ecocredit module manages classes, projects, batches, and retirements + +Output Format: +Always respond with valid JSON matching this schema: +{ + "score": , + "confidence": , + "recommendation": "APPROVE" | "CONDITIONAL" | "REJECT", + "factors": { + "methodology_quality": , + "reputation": , + "novelty": , + "completeness": + }, + "rationale": "" +}`; + +/** + * Screen a credit class application via Claude. + */ +export async function screenCreditClass( + classData: CreditClass, + issuers: string[], + existingClasses: CreditClass[] +): Promise { + const prompt = `Screen this credit class registration for Regen Network. + +## Credit Class Data +- Class ID: ${classData.id} +- Admin: ${classData.admin} +- Credit Type: ${classData.credit_type?.name || "unknown"} (${classData.credit_type?.abbreviation || "?"}) +- Unit: ${classData.credit_type?.unit || "unknown"} +- Precision: ${classData.credit_type?.precision ?? "unknown"} +- Metadata: ${classData.metadata || "(empty)"} + +## Authorized Issuers (${issuers.length}) +${issuers.length > 0 ? issuers.map((addr) => `- ${addr}`).join("\n") : "- None registered"} + +## Existing Credit Classes on Network (${existingClasses.length} total) +${existingClasses + .slice(0, 20) + .map( + (c) => + `- ${c.id}: ${c.credit_type?.name || "unknown"} (admin: ${c.admin})` + ) + .join("\n")} +${existingClasses.length > 20 ? `\n... and ${existingClasses.length - 20} more` : ""} + +Evaluate: +1. Is the credit type well-defined with appropriate unit and precision? +2. Does the metadata provide sufficient methodology documentation? +3. Is this duplicating an existing class, or does it bring something new? +4. Are the admin and issuers known entities on the network? +5. Are there any red flags (e.g., empty metadata, suspicious addresses)? + +Respond with the JSON screening result.`; + + return callClaude(prompt); +} + +/** + * Screen a project registration via Claude. + */ +export async function screenProject( + projectData: Project, + classData: CreditClass | null +): Promise { + const prompt = `Screen this project registration for Regen Network. + +## Project Data +- Project ID: ${projectData.id} +- Class ID: ${projectData.class_id} +- Admin: ${projectData.admin} +- Jurisdiction: ${projectData.jurisdiction || "(not specified)"} +- Reference ID: ${projectData.reference_id || "(none)"} +- Metadata: ${projectData.metadata || "(empty)"} + +## Parent Credit Class +${ + classData + ? `- Class ID: ${classData.id} +- Credit Type: ${classData.credit_type?.name || "unknown"} (${classData.credit_type?.abbreviation || "?"}) +- Class Admin: ${classData.admin} +- Class Metadata: ${classData.metadata || "(empty)"}` + : "- Class data not available" +} + +Evaluate: +1. Does the project align with its credit class methodology? +2. Is the jurisdiction specified and reasonable for this credit type? +3. Does the metadata contain adequate project documentation? +4. Is the project admin a known entity or connected to the class admin/issuers? +5. Does the reference ID link to external verification (e.g., a registry)? +6. Are there any red flags (e.g., missing jurisdiction, empty metadata)? + +Respond with the JSON screening result.`; + + return callClaude(prompt); +} + +/** + * Screen a credit batch issuance via Claude. + */ +export async function screenBatch( + batchData: CreditBatch, + projectData: Project | null +): Promise { + const prompt = `Screen this credit batch issuance for Regen Network. + +## Batch Data +- Batch Denom: ${batchData.denom} +- Project ID: ${batchData.project_id} +- Issuer: ${batchData.issuer} +- Start Date: ${batchData.start_date} +- End Date: ${batchData.end_date} +- Total Amount: ${batchData.total_amount} +- Open: ${batchData.open} +- Metadata: ${batchData.metadata || "(empty)"} + +## Parent Project +${ + projectData + ? `- Project ID: ${projectData.id} +- Class ID: ${projectData.class_id} +- Jurisdiction: ${projectData.jurisdiction || "(not specified)"} +- Project Admin: ${projectData.admin} +- Project Metadata: ${projectData.metadata || "(empty)"}` + : "- Project data not available" +} + +Evaluate: +1. Is the issuance amount reasonable for the credit type and date range? +2. Does the date range (start to end) make sense for ecological credit generation? +3. Is the issuer authorized for this project's credit class? +4. Does the batch metadata provide adequate supporting evidence? +5. Are there anomalies? (e.g., very large issuance, very short date range, open batch with large amount) +6. Is the batch consistent with the parent project's jurisdiction and methodology? + +Respond with the JSON screening result.`; + + return callClaude(prompt); +} + +// ── Helpers ────────────────────────────────────────────────── + +async function callClaude(prompt: string): Promise { + const response = await client.messages.create({ + model: config.model, + max_tokens: 2000, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: prompt }], + }); + + const text = response.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join("\n"); + + return parseScreeningResult(text); +} + +function parseScreeningResult(text: string): ScreeningResult { + // Try to extract JSON from the response (handles markdown code blocks) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + return fallbackResult(text); + } + + try { + const parsed = JSON.parse(jsonMatch[0]) as Record; + + const factors = parsed.factors as Record | undefined; + + return { + score: clamp(Number(parsed.score) || 0), + confidence: clamp(Number(parsed.confidence) || 0), + recommendation: validateRecommendation( + parsed.recommendation as string + ), + factors: { + methodology_quality: clamp( + Number(factors?.methodology_quality) || 0 + ), + reputation: clamp(Number(factors?.reputation) || 0), + novelty: clamp(Number(factors?.novelty) || 0), + completeness: clamp(Number(factors?.completeness) || 0), + }, + rationale: String(parsed.rationale || text), + }; + } catch { + return fallbackResult(text); + } +} + +function fallbackResult(text: string): ScreeningResult { + return { + score: 0, + confidence: 0, + recommendation: "CONDITIONAL", + factors: { + methodology_quality: 0, + reputation: 0, + novelty: 0, + completeness: 0, + }, + rationale: `Failed to parse structured response. Raw output:\n\n${text.slice(0, 1000)}`, + }; +} + +function clamp(value: number): number { + return Math.max(0, Math.min(1000, Math.round(value))); +} + +function validateRecommendation( + rec: string +): "APPROVE" | "CONDITIONAL" | "REJECT" { + const upper = (rec || "").toUpperCase(); + if (upper === "APPROVE") return "APPROVE"; + if (upper === "REJECT") return "REJECT"; + return "CONDITIONAL"; +} diff --git a/agent-001-registry-reviewer/src/store.ts b/agent-001-registry-reviewer/src/store.ts new file mode 100644 index 0000000..d6178f8 --- /dev/null +++ b/agent-001-registry-reviewer/src/store.ts @@ -0,0 +1,180 @@ +import Database from "better-sqlite3"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = path.join(__dirname, "..", "agent-001.db"); + +/** + * Local SQLite store for agent state. + * + * Tracks which credit classes, projects, and batches we've screened, + * plus workflow execution history. Intentionally lightweight — + * can be replaced with PostgreSQL for production. + */ +class Store { + private db: Database.Database; + + constructor() { + this.db = new Database(DB_PATH); + this.db.pragma("journal_mode = WAL"); + this.migrate(); + } + + private migrate() { + this.db.exec(` + CREATE TABLE IF NOT EXISTS workflow_executions ( + execution_id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + result TEXT + ); + + CREATE TABLE IF NOT EXISTS credit_class_screenings ( + class_id TEXT PRIMARY KEY, + screening TEXT NOT NULL, + screened_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS project_screenings ( + project_id TEXT PRIMARY KEY, + screening TEXT NOT NULL, + screened_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS batch_screenings ( + batch_denom TEXT PRIMARY KEY, + screening TEXT NOT NULL, + screened_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_exec_workflow + ON workflow_executions(workflow_id); + `); + } + + // ── Credit class screenings ─────────────────────────── + + hasClassScreening(classId: string): boolean { + const row = this.db + .prepare("SELECT 1 FROM credit_class_screenings WHERE class_id = ?") + .get(classId); + return !!row; + } + + saveClassScreening(classId: string, screening: string): void { + this.db + .prepare( + `INSERT OR REPLACE INTO credit_class_screenings (class_id, screening, screened_at) + VALUES (?, ?, ?)` + ) + .run(classId, screening, new Date().toISOString()); + } + + getClassScreening(classId: string): string | null { + const row = this.db + .prepare( + "SELECT screening FROM credit_class_screenings WHERE class_id = ?" + ) + .get(classId) as { screening: string } | undefined; + return row?.screening || null; + } + + // ── Project screenings ──────────────────────────────── + + hasProjectScreening(projectId: string): boolean { + const row = this.db + .prepare("SELECT 1 FROM project_screenings WHERE project_id = ?") + .get(projectId); + return !!row; + } + + saveProjectScreening(projectId: string, screening: string): void { + this.db + .prepare( + `INSERT OR REPLACE INTO project_screenings (project_id, screening, screened_at) + VALUES (?, ?, ?)` + ) + .run(projectId, screening, new Date().toISOString()); + } + + getProjectScreening(projectId: string): string | null { + const row = this.db + .prepare( + "SELECT screening FROM project_screenings WHERE project_id = ?" + ) + .get(projectId) as { screening: string } | undefined; + return row?.screening || null; + } + + // ── Batch screenings ────────────────────────────────── + + hasBatchScreening(batchDenom: string): boolean { + const row = this.db + .prepare("SELECT 1 FROM batch_screenings WHERE batch_denom = ?") + .get(batchDenom); + return !!row; + } + + saveBatchScreening(batchDenom: string, screening: string): void { + this.db + .prepare( + `INSERT OR REPLACE INTO batch_screenings (batch_denom, screening, screened_at) + VALUES (?, ?, ?)` + ) + .run(batchDenom, screening, new Date().toISOString()); + } + + getBatchScreening(batchDenom: string): string | null { + const row = this.db + .prepare( + "SELECT screening FROM batch_screenings WHERE batch_denom = ?" + ) + .get(batchDenom) as { screening: string } | undefined; + return row?.screening || null; + } + + // ── Workflow executions ─────────────────────────────── + + logExecution(exec: { + executionId: string; + workflowId: string; + agentId: string; + status: string; + startedAt: string; + completedAt: string; + result: string; + }): void { + this.db + .prepare( + `INSERT INTO workflow_executions + (execution_id, workflow_id, agent_id, status, started_at, completed_at, result) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + exec.executionId, + exec.workflowId, + exec.agentId, + exec.status, + exec.startedAt, + exec.completedAt, + exec.result + ); + } + + getExecutionCount(): number { + const row = this.db + .prepare("SELECT COUNT(*) as cnt FROM workflow_executions") + .get() as { cnt: number }; + return row.cnt; + } + + close(): void { + this.db.close(); + } +} + +export const store = new Store(); diff --git a/agent-001-registry-reviewer/src/types.ts b/agent-001-registry-reviewer/src/types.ts new file mode 100644 index 0000000..ed07501 --- /dev/null +++ b/agent-001-registry-reviewer/src/types.ts @@ -0,0 +1,91 @@ +// ============================================================ +// Regen Ledger ecocredit types +// ============================================================ + +export interface CreditClass { + id: string; + admin: string; + credit_type: CreditType; + metadata: string; + issuers?: string[]; +} + +export interface CreditType { + abbreviation: string; + name: string; + unit: string; + precision: number; +} + +export interface Project { + id: string; + class_id: string; + jurisdiction: string; + metadata: string; + admin: string; + reference_id: string; +} + +export interface CreditBatch { + denom: string; + project_id: string; + issuer: string; + start_date: string; + end_date: string; + total_amount: string; + metadata: string; + open: boolean; +} + +// ============================================================ +// Screening types +// ============================================================ + +export type Recommendation = "APPROVE" | "CONDITIONAL" | "REJECT"; + +export interface ScreeningFactors { + methodology_quality: number; // 0-1000 + reputation: number; // 0-1000 + novelty: number; // 0-1000 + completeness: number; // 0-1000 +} + +export interface ScreeningResult { + score: number; // 0-1000 composite + confidence: number; // 0-1000 + recommendation: Recommendation; + factors: ScreeningFactors; + rationale: string; +} + +// ============================================================ +// OODA loop types +// ============================================================ + +export interface OODAExecution { + executionId: string; + workflowId: string; + status: "running" | "completed" | "failed" | "escalated"; + observations: TObserve; + orientation: TOrient | null; + decision: TDecide | null; + actions: TAct | null; + startedAt: Date; + completedAt: Date | null; + error: string | null; +} + +// ============================================================ +// Output types +// ============================================================ + +export type AlertLevel = "NORMAL" | "HIGH" | "CRITICAL"; + +export interface OutputMessage { + workflow: string; + entityId: string; + title: string; + content: string; + alertLevel: AlertLevel; + timestamp: Date; +} diff --git a/agent-001-registry-reviewer/src/workflows/batch-review.ts b/agent-001-registry-reviewer/src/workflows/batch-review.ts new file mode 100644 index 0000000..dd444ac --- /dev/null +++ b/agent-001-registry-reviewer/src/workflows/batch-review.ts @@ -0,0 +1,179 @@ +import { LedgerClient } from "../ledger.js"; +import { store } from "../store.js"; +import { screenBatch } from "../reviewer.js"; +import { output } from "../output.js"; +import type { OODAWorkflow } from "../ooda.js"; +import type { CreditBatch, Project, ScreeningResult } from "../types.js"; + +/** + * WF-RR-03: Batch Review + * + * Trigger: New credit batch issued on-chain + * Layer: 1 (fully automated, informational only) + * SLA: Review within 2 hours of batch issuance + * + * OODA: + * Observe — Fetch recent credit batches from ledger + * Orient — Filter to unreviewed batches, resolve parent projects + * Decide — Review each via Claude (amount, dates, project alignment, anomalies) + * Act — Persist results, flag anomalies + */ + +interface Observations { + batches: CreditBatch[]; + projectMap: Map; + unreviewedDenoms: string[]; +} + +interface Orientation { + batchesToReview: { + batchData: CreditBatch; + projectData: Project | null; + }[]; +} + +interface Decision { + reviews: { + batchDenom: string; + batchLabel: string; + result: ScreeningResult; + }[]; +} + +interface Actions { + saved: number; + output: number; + anomaliesFlagged: number; +} + +export function createBatchReviewWorkflow( + ledger: LedgerClient +): OODAWorkflow { + return { + id: "WF-RR-03", + name: "Batch Review", + + async observe(): Promise { + const batches = await ledger.getCreditBatches(); + + const unreviewedDenoms = batches + .map((b) => b.denom) + .filter((denom) => !store.hasBatchScreening(denom)); + + // Resolve parent projects for unreviewed batches + const projectIdsNeeded = new Set( + batches + .filter((b) => unreviewedDenoms.includes(b.denom)) + .map((b) => b.project_id) + ); + + const projectMap = new Map(); + await Promise.all( + [...projectIdsNeeded].map(async (projectId) => { + try { + const proj = await ledger.getProject(projectId); + if (proj) projectMap.set(projectId, proj); + } catch { + // Project not found — will review without it + } + }) + ); + + return { batches, projectMap, unreviewedDenoms }; + }, + + async orient(obs: Observations): Promise { + const batchesToReview = obs.unreviewedDenoms + .map((denom) => { + const batchData = obs.batches.find((b) => b.denom === denom); + if (!batchData) return null; + return { + batchData, + projectData: obs.projectMap.get(batchData.project_id) || null, + }; + }) + .filter(Boolean) as Orientation["batchesToReview"]; + + return { batchesToReview }; + }, + + async decide(orientation: Orientation): Promise { + const reviews: Decision["reviews"] = []; + + for (const { batchData, projectData } of orientation.batchesToReview) { + console.log( + ` Reviewing batch ${batchData.denom} (project: ${batchData.project_id}, amount: ${batchData.total_amount})` + ); + + const result = await screenBatch(batchData, projectData); + + reviews.push({ + batchDenom: batchData.denom, + batchLabel: `${batchData.denom} (${batchData.total_amount} credits)`, + result, + }); + } + + return { reviews }; + }, + + async act(decision: Decision): Promise { + let saved = 0; + let outputCount = 0; + let anomaliesFlagged = 0; + + for (const { batchDenom, batchLabel, result } of decision.reviews) { + // Persist + store.saveBatchScreening(batchDenom, JSON.stringify(result)); + saved++; + + // Determine alert level — batches get CRITICAL for REJECT + // because issuance of bad credits is an immediate concern + const alertLevel = + result.recommendation === "REJECT" + ? "CRITICAL" as const + : result.recommendation === "CONDITIONAL" + ? "HIGH" as const + : "NORMAL" as const; + + if (alertLevel !== "NORMAL") { + anomaliesFlagged++; + } + + // Output + await output({ + workflow: "WF-RR-03", + entityId: batchDenom, + title: `Batch: ${batchLabel} — ${result.recommendation}`, + content: formatScreeningOutput(result), + alertLevel, + timestamp: new Date(), + }); + outputCount++; + } + + if (decision.reviews.length === 0) { + console.log(" No new batches to review."); + } + + return { saved, output: outputCount, anomaliesFlagged }; + }, + }; +} + +function formatScreeningOutput(result: ScreeningResult): string { + return `## Screening Result + +**Score:** ${result.score}/1000 | **Confidence:** ${result.confidence}/1000 | **Recommendation:** ${result.recommendation} + +### Factor Breakdown +| Factor | Score | Weight | +|--------|-------|--------| +| Methodology Quality | ${result.factors.methodology_quality}/1000 | 40% | +| Reputation | ${result.factors.reputation}/1000 | 30% | +| Novelty | ${result.factors.novelty}/1000 | 20% | +| Completeness | ${result.factors.completeness}/1000 | 10% | + +### Rationale +${result.rationale}`; +} diff --git a/agent-001-registry-reviewer/src/workflows/class-screening.ts b/agent-001-registry-reviewer/src/workflows/class-screening.ts new file mode 100644 index 0000000..a14adf8 --- /dev/null +++ b/agent-001-registry-reviewer/src/workflows/class-screening.ts @@ -0,0 +1,171 @@ +import { LedgerClient } from "../ledger.js"; +import { store } from "../store.js"; +import { screenCreditClass } from "../reviewer.js"; +import { output } from "../output.js"; +import type { OODAWorkflow } from "../ooda.js"; +import type { CreditClass, ScreeningResult } from "../types.js"; + +/** + * WF-RR-01: Credit Class Screening + * + * Trigger: New credit class registered on-chain + * Layer: 1 (fully automated, informational only) + * SLA: Screening within 2 hours of class creation + * + * OODA: + * Observe — Fetch all credit classes and their issuers from ledger + * Orient — Filter to unscreened classes + * Decide — Screen each via Claude (methodology, reputation, novelty, completeness) + * Act — Persist results, output recommendations + */ + +interface Observations { + classes: CreditClass[]; + issuersMap: Map; + unscreenedIds: string[]; +} + +interface Orientation { + classesToScreen: { + classData: CreditClass; + issuers: string[]; + }[]; + allClasses: CreditClass[]; +} + +interface Decision { + screenings: { + classId: string; + className: string; + result: ScreeningResult; + }[]; +} + +interface Actions { + saved: number; + output: number; +} + +export function createClassScreeningWorkflow( + ledger: LedgerClient +): OODAWorkflow { + return { + id: "WF-RR-01", + name: "Credit Class Screening", + + async observe(): Promise { + const classes = await ledger.getCreditClasses(); + + // Fetch issuers for unscreened classes + const unscreenedIds = classes + .map((c) => c.id) + .filter((id) => !store.hasClassScreening(id)); + + const issuersMap = new Map(); + await Promise.all( + unscreenedIds.map(async (id) => { + try { + const issuers = await ledger.getClassIssuers(id); + issuersMap.set(id, issuers); + } catch { + issuersMap.set(id, []); + } + }) + ); + + return { classes, issuersMap, unscreenedIds }; + }, + + async orient(obs: Observations): Promise { + const classesToScreen = obs.unscreenedIds + .map((id) => { + const classData = obs.classes.find((c) => c.id === id); + if (!classData) return null; + return { + classData, + issuers: obs.issuersMap.get(id) || [], + }; + }) + .filter(Boolean) as Orientation["classesToScreen"]; + + return { classesToScreen, allClasses: obs.classes }; + }, + + async decide(orientation: Orientation): Promise { + const screenings: Decision["screenings"] = []; + + for (const { classData, issuers } of orientation.classesToScreen) { + console.log( + ` Screening credit class ${classData.id}: ${classData.credit_type?.name || "unknown"}` + ); + + const result = await screenCreditClass( + classData, + issuers, + orientation.allClasses + ); + + screenings.push({ + classId: classData.id, + className: classData.credit_type?.name || classData.id, + result, + }); + } + + return { screenings }; + }, + + async act(decision: Decision): Promise { + let saved = 0; + let outputCount = 0; + + for (const { classId, className, result } of decision.screenings) { + // Persist + store.saveClassScreening(classId, JSON.stringify(result)); + saved++; + + // Determine alert level based on recommendation + const alertLevel = + result.recommendation === "REJECT" + ? "CRITICAL" as const + : result.recommendation === "CONDITIONAL" + ? "HIGH" as const + : "NORMAL" as const; + + // Output + await output({ + workflow: "WF-RR-01", + entityId: classId, + title: `Credit Class: ${className} — ${result.recommendation}`, + content: formatScreeningOutput(result), + alertLevel, + timestamp: new Date(), + }); + outputCount++; + } + + if (decision.screenings.length === 0) { + console.log(" No new credit classes to screen."); + } + + return { saved, output: outputCount }; + }, + }; +} + +function formatScreeningOutput(result: ScreeningResult): string { + return `## Screening Result + +**Score:** ${result.score}/1000 | **Confidence:** ${result.confidence}/1000 | **Recommendation:** ${result.recommendation} + +### Factor Breakdown +| Factor | Score | Weight | +|--------|-------|--------| +| Methodology Quality | ${result.factors.methodology_quality}/1000 | 40% | +| Reputation | ${result.factors.reputation}/1000 | 30% | +| Novelty | ${result.factors.novelty}/1000 | 20% | +| Completeness | ${result.factors.completeness}/1000 | 10% | + +### Rationale +${result.rationale}`; +} diff --git a/agent-001-registry-reviewer/src/workflows/project-validation.ts b/agent-001-registry-reviewer/src/workflows/project-validation.ts new file mode 100644 index 0000000..badd01a --- /dev/null +++ b/agent-001-registry-reviewer/src/workflows/project-validation.ts @@ -0,0 +1,172 @@ +import { LedgerClient } from "../ledger.js"; +import { store } from "../store.js"; +import { screenProject } from "../reviewer.js"; +import { output } from "../output.js"; +import type { OODAWorkflow } from "../ooda.js"; +import type { Project, CreditClass, ScreeningResult } from "../types.js"; + +/** + * WF-RR-02: Project Validation + * + * Trigger: New project registered on-chain + * Layer: 1 (fully automated, informational only) + * SLA: Validation within 2 hours of project registration + * + * OODA: + * Observe — Fetch all projects and their parent credit classes from ledger + * Orient — Filter to unscreened projects, resolve parent class data + * Decide — Screen each via Claude (class alignment, jurisdiction, metadata) + * Act — Persist results, output recommendations + */ + +interface Observations { + projects: Project[]; + classMap: Map; + unscreenedIds: string[]; +} + +interface Orientation { + projectsToScreen: { + projectData: Project; + classData: CreditClass | null; + }[]; +} + +interface Decision { + screenings: { + projectId: string; + projectName: string; + result: ScreeningResult; + }[]; +} + +interface Actions { + saved: number; + output: number; +} + +export function createProjectValidationWorkflow( + ledger: LedgerClient +): OODAWorkflow { + return { + id: "WF-RR-02", + name: "Project Validation", + + async observe(): Promise { + const projects = await ledger.getProjects(); + + const unscreenedIds = projects + .map((p) => p.id) + .filter((id) => !store.hasProjectScreening(id)); + + // Resolve parent classes for unscreened projects + const classIdsNeeded = new Set( + projects + .filter((p) => unscreenedIds.includes(p.id)) + .map((p) => p.class_id) + ); + + const classMap = new Map(); + await Promise.all( + [...classIdsNeeded].map(async (classId) => { + try { + const cls = await ledger.getCreditClass(classId); + if (cls) classMap.set(classId, cls); + } catch { + // Class not found — will screen without it + } + }) + ); + + return { projects, classMap, unscreenedIds }; + }, + + async orient(obs: Observations): Promise { + const projectsToScreen = obs.unscreenedIds + .map((id) => { + const projectData = obs.projects.find((p) => p.id === id); + if (!projectData) return null; + return { + projectData, + classData: obs.classMap.get(projectData.class_id) || null, + }; + }) + .filter(Boolean) as Orientation["projectsToScreen"]; + + return { projectsToScreen }; + }, + + async decide(orientation: Orientation): Promise { + const screenings: Decision["screenings"] = []; + + for (const { projectData, classData } of orientation.projectsToScreen) { + console.log( + ` Screening project ${projectData.id} (class: ${projectData.class_id})` + ); + + const result = await screenProject(projectData, classData); + + screenings.push({ + projectId: projectData.id, + projectName: projectData.id, + result, + }); + } + + return { screenings }; + }, + + async act(decision: Decision): Promise { + let saved = 0; + let outputCount = 0; + + for (const { projectId, projectName, result } of decision.screenings) { + // Persist + store.saveProjectScreening(projectId, JSON.stringify(result)); + saved++; + + // Determine alert level + const alertLevel = + result.recommendation === "REJECT" + ? "CRITICAL" as const + : result.recommendation === "CONDITIONAL" + ? "HIGH" as const + : "NORMAL" as const; + + // Output + await output({ + workflow: "WF-RR-02", + entityId: projectId, + title: `Project: ${projectName} — ${result.recommendation}`, + content: formatScreeningOutput(result), + alertLevel, + timestamp: new Date(), + }); + outputCount++; + } + + if (decision.screenings.length === 0) { + console.log(" No new projects to screen."); + } + + return { saved, output: outputCount }; + }, + }; +} + +function formatScreeningOutput(result: ScreeningResult): string { + return `## Screening Result + +**Score:** ${result.score}/1000 | **Confidence:** ${result.confidence}/1000 | **Recommendation:** ${result.recommendation} + +### Factor Breakdown +| Factor | Score | Weight | +|--------|-------|--------| +| Methodology Quality | ${result.factors.methodology_quality}/1000 | 40% | +| Reputation | ${result.factors.reputation}/1000 | 30% | +| Novelty | ${result.factors.novelty}/1000 | 20% | +| Completeness | ${result.factors.completeness}/1000 | 10% | + +### Rationale +${result.rationale}`; +} diff --git a/agent-001-registry-reviewer/tsconfig.json b/agent-001-registry-reviewer/tsconfig.json new file mode 100644 index 0000000..4a4a4f3 --- /dev/null +++ b/agent-001-registry-reviewer/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/agents/packages/agents/src/index.ts b/agents/packages/agents/src/index.ts index 6ef47a9..62600a5 100644 --- a/agents/packages/agents/src/index.ts +++ b/agents/packages/agents/src/index.ts @@ -2,7 +2,8 @@ * Agent Entrypoint * * Bootstraps an ElizaOS agent runtime with the selected character - * and Regen MCP plugins. Selected via AGENT_CHARACTER env var. + * and Regen MCP plugins. Falls back to standalone OODA loop mode + * when ElizaOS is not installed. * * Usage: * AGENT_CHARACTER=governance-analyst tsx packages/agents/src/index.ts @@ -15,11 +16,122 @@ import { KOIMCPClient } from "@regen/plugin-koi-mcp"; import { governanceAnalystCharacter } from "./characters/governance-analyst.js"; import { registryReviewerCharacter } from "./characters/registry-reviewer.js"; -const CHARACTERS: Record = { +const CHARACTERS: Record = { "governance-analyst": governanceAnalystCharacter, "registry-reviewer": registryReviewerCharacter, }; +async function verifyMcpConnections( + ledgerClient: LedgerMCPClient, + koiClient: KOIMCPClient +): Promise<{ ledger: boolean; koi: boolean }> { + const result = { ledger: false, koi: false }; + + try { + await ledgerClient.listProposals({ limit: 1 }); + console.log("[Ledger MCP] Connected."); + result.ledger = true; + } catch (err) { + console.log( + `[Ledger MCP] Not available (${(err as Error).message}). ` + + "This is expected if the MCP server isn't running locally." + ); + } + + try { + await koiClient.search({ query: "regen governance", limit: 1 }); + console.log("[KOI MCP] Connected."); + result.koi = true; + } catch (err) { + console.log( + `[KOI MCP] Not available (${(err as Error).message}). ` + + "This is expected if the MCP server isn't running locally." + ); + } + + return result; +} + +async function bootstrapElizaOS( + character: any, + config: ReturnType, + ledgerClient: LedgerMCPClient, + koiClient: KOIMCPClient +): Promise { + // Dynamic import -- only resolves if @elizaos/core is installed. + // Cast to any because @elizaos/core v1.7 has a moduleResolution quirk + // where ./runtime re-export doesn't resolve under NodeNext. The class + // exists at runtime; this cast is safe. + const elizaCore: any = await import("@elizaos/core"); + const AgentRuntime = elizaCore.AgentRuntime as any; + + // Import plugin objects from our MCP packages + const { ledgerMcpPlugin } = await import( + "@regen/plugin-ledger-mcp" + ); + const { koiMcpPlugin } = await import("@regen/plugin-koi-mcp"); + + // Initialize the plugins with MCP client instances + ledgerMcpPlugin._setClient(ledgerClient); + koiMcpPlugin._setClient(koiClient); + + // ElizaOS v1.6.3 AgentRuntime constructor: + // { character?, plugins?, adapter?, settings?, agentId?, fetch? } + // API key goes through character.settings.secrets or runtime settings. + const runtime = new AgentRuntime({ + character: { + ...character, + settings: { + ...character.settings, + secrets: { + ...character.settings?.secrets, + ANTHROPIC_API_KEY: config.anthropicApiKey, + }, + }, + }, + plugins: [ledgerMcpPlugin as any, koiMcpPlugin as any], + settings: { + ANTHROPIC_API_KEY: config.anthropicApiKey, + } as any, + }); + + await runtime.initialize(); + + console.log("[ElizaOS] Runtime initialized successfully."); + console.log( + `[ElizaOS] Plugins loaded: ${[ledgerMcpPlugin.name, koiMcpPlugin.name].join(", ")}` + ); + + // Graceful shutdown + const shutdown = async () => { + console.log("\n[ElizaOS] Shutting down..."); + try { + if (typeof (runtime as any).close === "function") { + await (runtime as any).close(); + } + } catch { + // best-effort cleanup + } + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + // Keep process alive -- the runtime manages its own event loop + console.log("[ElizaOS] Runtime running. Press Ctrl+C to stop."); +} + +async function bootstrapStandalone( + characterName: string, + config: ReturnType, + ledgerClient: LedgerMCPClient, + koiClient: KOIMCPClient +): Promise { + const { runStandalone } = await import("./standalone.js"); + await runStandalone(characterName, config, ledgerClient, koiClient); +} + async function main() { const config = loadConfig(); @@ -44,74 +156,46 @@ async function main() { }); console.log(` -╔══════════════════════════════════════════════════════════╗ -║ Regen Agentic Tokenomics - Agent Runtime ║ -╠══════════════════════════════════════════════════════════╣ -║ Character: ${characterName.padEnd(41)}║ -║ Ledger MCP: ${config.ledgerMcp.baseUrl.padEnd(41)}║ -║ KOI MCP: ${config.koiMcp.baseUrl.padEnd(41)}║ -╚══════════════════════════════════════════════════════════╝ +\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 +\u2551 Regen Agentic Tokenomics - Agent Runtime \u2551 +\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563 +\u2551 Character: ${characterName.padEnd(41)}\u2551 +\u2551 Ledger MCP: ${config.ledgerMcp.baseUrl.padEnd(41)}\u2551 +\u2551 KOI MCP: ${config.koiMcp.baseUrl.padEnd(41)}\u2551 +\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d `); - // --------------------------------------------------------------- - // ElizaOS Runtime Bootstrap - // - // This is where you'd wire up the full ElizaOS runtime: - // - // import { AgentRuntime } from "@elizaos/core"; - // import { bootstrapPlugin } from "@elizaos/plugin-bootstrap"; - // - // const runtime = new AgentRuntime({ - // character, - // token: config.anthropicApiKey, - // modelProvider: "anthropic", - // plugins: [bootstrapPlugin, ledgerPlugin, koiPlugin], - // databaseAdapter: db, - // cacheManager: cache, - // }); - // await runtime.initialize(); - // - // For now, we verify the MCP connections and demonstrate the - // agent's core capability: analyzing a governance proposal. - // --------------------------------------------------------------- - + // Verify MCP connections before any runtime bootstrap console.log("Verifying MCP connections...\n"); + await verifyMcpConnections(ledgerClient, koiClient); + console.log(); - // Test Ledger MCP + // Attempt ElizaOS runtime; fall back to standalone OODA loop try { - const proposals = await ledgerClient.listProposals({ limit: 1 }); - console.log( - "[Ledger MCP] Connected. Sample response:", - JSON.stringify(proposals, null, 2).substring(0, 200) - ); + await bootstrapElizaOS(character, config, ledgerClient, koiClient); } catch (err) { - console.log( - `[Ledger MCP] Not available (${(err as Error).message}). ` + - "This is expected if the MCP server isn't running locally." - ); - } + const isModuleNotFound = + err instanceof Error && + (err.message.includes("Cannot find module") || + err.message.includes("Cannot find package") || + err.message.includes("MODULE_NOT_FOUND") || + err.message.includes("ERR_MODULE_NOT_FOUND")); - // Test KOI MCP - try { - const results = await koiClient.search({ - query: "regen governance", - limit: 1, - }); - console.log( - "[KOI MCP] Connected. Sample response:", - JSON.stringify(results, null, 2).substring(0, 200) - ); - } catch (err) { - console.log( - `[KOI MCP] Not available (${(err as Error).message}). ` + - "This is expected if the MCP server isn't running locally." - ); + if (isModuleNotFound) { + console.log( + "[ElizaOS] @elizaos/core not installed. Falling back to standalone OODA mode.\n" + + " To enable ElizaOS runtime: npm install @elizaos/core\n" + ); + await bootstrapStandalone( + characterName, + config, + ledgerClient, + koiClient + ); + } else { + throw err; + } } - - console.log("\nAgent scaffold initialized successfully."); - console.log( - "To run with full ElizaOS runtime, install @elizaos/core and uncomment the bootstrap section above." - ); } main().catch((err) => { diff --git a/agents/packages/agents/src/standalone.ts b/agents/packages/agents/src/standalone.ts new file mode 100644 index 0000000..2dabf5f --- /dev/null +++ b/agents/packages/agents/src/standalone.ts @@ -0,0 +1,409 @@ +/** + * Standalone Mode + * + * When ElizaOS is not installed, agents run directly using the OODA + * executor. Each character maps to a workflow definition and a set of + * step handlers. A polling loop drives the observe-orient-decide-act + * cycle on a configurable interval. + * + * This is the pragmatic "it works without ElizaOS" path. + */ + +import type { RegenConfig } from "@regen/core"; +import { + OODAExecutor, + type StepHandlers, +} from "@regen/core"; +import type { + OODAWorkflow, + ObserveStep, + OrientStep, + DecideStep, + ActStep, + WorkflowExecution, + WorkflowDecision, + GovernanceLayer, +} from "@regen/core"; +import type { LedgerMCPClient } from "@regen/plugin-ledger-mcp"; +import type { KOIMCPClient } from "@regen/plugin-koi-mcp"; +import { + analyzeProposal, + formatProposalAnalysis, +} from "@regen/plugin-ledger-mcp"; +import { + getLedgerState, + formatLedgerState, +} from "@regen/plugin-ledger-mcp"; +import { + getKnowledgeContext, + formatKnowledgeContext, +} from "@regen/plugin-koi-mcp"; + +// --------------------------------------------------------------------------- +// Workflow definitions per character +// --------------------------------------------------------------------------- + +function governanceAnalystWorkflow(): OODAWorkflow { + return { + id: "wf-ga-01-proposal-monitor", + name: "Governance Proposal Monitor", + agentId: "AGENT-002", + governanceLayer: 1 as GovernanceLayer, // Layer 1 -- informational only + sla: { responseTimeMs: 30_000 }, + observe: [ + { + id: "obs-active-proposals", + type: "ledger_query", + tool: "list_governance_proposals", + params: { limit: 10, proposal_status: "PROPOSAL_STATUS_VOTING_PERIOD" }, + }, + { + id: "obs-knowledge-context", + type: "koi_search", + tool: "search", + params: { query: "active governance proposals regen", limit: 5 }, + }, + ], + orient: [ + { + id: "orient-analyze", + type: "llm_analysis", + prompt: + "Analyze the active governance proposals. For each, assess technical, economic, and governance impact. Note quorum risk and unusual voting patterns.", + }, + ], + decide: { + id: "decide-report", + escalationThreshold: 0.6, + rules: [ + { + condition: "activeProposals > 0", + recommendation: "REPORT", + confidence: 0.9, + }, + { + condition: "activeProposals == 0", + recommendation: "SKIP", + confidence: 1.0, + }, + ], + }, + act: [ + { + id: "act-publish-report", + type: "log", + condition: "recommendation == REPORT", + params: { channel: "stdout" }, + }, + ], + }; +} + +function registryReviewerWorkflow(): OODAWorkflow { + return { + id: "wf-rr-01-registry-monitor", + name: "Registry Submission Monitor", + agentId: "AGENT-001", + governanceLayer: 2 as GovernanceLayer, // Layer 2 -- agentic with oversight + sla: { responseTimeMs: 60_000 }, + observe: [ + { + id: "obs-credit-classes", + type: "ledger_query", + tool: "list_classes", + params: { limit: 20 }, + }, + { + id: "obs-recent-batches", + type: "ledger_query", + tool: "list_batches", + params: { limit: 10 }, + }, + { + id: "obs-knowledge", + type: "koi_search", + tool: "search", + params: { query: "credit class methodology requirements", limit: 5 }, + }, + ], + orient: [ + { + id: "orient-review", + type: "rule_check", + rules: [ + { + condition: "newBatchesExist", + result: "Review new batches against methodology requirements", + }, + { + condition: "newClassesExist", + result: "Review new credit class applications", + }, + ], + }, + ], + decide: { + id: "decide-action", + escalationThreshold: 0.6, + rules: [ + { + condition: "pendingReviews > 0", + recommendation: "REVIEW", + confidence: 0.8, + }, + { + condition: "pendingReviews == 0", + recommendation: "SKIP", + confidence: 1.0, + }, + ], + }, + act: [ + { + id: "act-publish-review", + type: "log", + condition: "recommendation == REVIEW", + params: { channel: "stdout" }, + }, + { + id: "act-escalate", + type: "escalate", + condition: "confidence < 0.6", + params: { channel: "operator" }, + }, + ], + }; +} + +const WORKFLOWS: Record OODAWorkflow> = { + "governance-analyst": governanceAnalystWorkflow, + "registry-reviewer": registryReviewerWorkflow, +}; + +// --------------------------------------------------------------------------- +// Step handlers -- wire MCP clients into the OODA executor +// --------------------------------------------------------------------------- + +function createStepHandlers( + ledgerClient: LedgerMCPClient, + koiClient: KOIMCPClient +): StepHandlers { + return { + async observe( + step: ObserveStep, + _context: WorkflowExecution + ): Promise { + console.log(` [observe] ${step.id} (${step.type}: ${step.tool})`); + + if (step.type === "ledger_query") { + try { + return await ledgerClient.call(step.tool, step.params); + } catch (err) { + console.log( + ` [observe] Ledger call failed: ${(err as Error).message}` + ); + return { error: (err as Error).message }; + } + } + + if (step.type === "koi_search") { + try { + return await koiClient.call(step.tool, step.params); + } catch (err) { + console.log( + ` [observe] KOI call failed: ${(err as Error).message}` + ); + return { error: (err as Error).message }; + } + } + + // memory_recall, external_oracle -- not yet wired + return { note: `Step type ${step.type} not yet implemented in standalone mode` }; + }, + + async orient( + step: OrientStep, + observations: unknown[], + _context: WorkflowExecution + ): Promise> { + console.log(` [orient] ${step.id} (${step.type})`); + + if (step.type === "rule_check" && step.rules) { + // Evaluate simple rule conditions against observations + const results: Record = {}; + for (const rule of step.rules) { + results[rule.condition] = rule.result; + } + return results; + } + + // For llm_analysis in standalone mode, we return the raw observations + // since we don't have an LLM loop. The executor still produces a + // structured execution record. + return { + observationCount: observations.length, + prompt: step.prompt ?? "No prompt specified", + rawObservations: observations, + }; + }, + + async decide( + step: DecideStep, + orientation: Record + ): Promise { + console.log(` [decide] ${step.id}`); + + // Pick the first matching rule, or fall back to the last rule + const matchedRule = + step.rules.find((_r) => { + // In standalone mode without an LLM, we can't evaluate complex + // conditions. Default to the first rule with highest confidence. + return true; + }) ?? step.rules[step.rules.length - 1]; + + return { + recommendation: matchedRule.recommendation, + confidence: matchedRule.confidence, + rationale: `Standalone mode: matched rule "${matchedRule.condition}" with confidence ${matchedRule.confidence}`, + evidence: [orientation], + }; + }, + + async act( + step: ActStep, + decision: WorkflowDecision, + context: WorkflowExecution + ): Promise { + console.log(` [act] ${step.id} (${step.type})`); + + if (step.type === "log") { + console.log( + `\n--- ${context.workflowId} Result ---\n` + + `Recommendation: ${decision.recommendation}\n` + + `Confidence: ${decision.confidence}\n` + + `Rationale: ${decision.rationale}\n` + + `Observations: ${context.observations.length}\n` + + `---\n` + ); + return { logged: true }; + } + + if (step.type === "escalate") { + console.log( + `[ESCALATE] Confidence ${decision.confidence} below threshold. ` + + `Human review required for: ${decision.rationale}` + ); + return { escalated: true }; + } + + if (step.type === "post_message" || step.type === "create_koi_object") { + console.log( + `[${step.type}] Would execute in full runtime. Skipped in standalone mode.` + ); + return { skipped: true, reason: "standalone mode" }; + } + + return { unknown: true }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Polling loop +// --------------------------------------------------------------------------- + +const DEFAULT_POLL_INTERVAL_MS = 60_000; // 1 minute + +export async function runStandalone( + characterName: string, + config: RegenConfig, + ledgerClient: LedgerMCPClient, + koiClient: KOIMCPClient +): Promise { + const workflowFactory = WORKFLOWS[characterName]; + if (!workflowFactory) { + console.error( + `No standalone workflow defined for character: "${characterName}". ` + + `Available: ${Object.keys(WORKFLOWS).join(", ")}` + ); + process.exit(1); + } + + const handlers = createStepHandlers(ledgerClient, koiClient); + const executor = new OODAExecutor(handlers); + + const pollIntervalMs = parseInt( + process.env.AGENT_POLL_INTERVAL_MS ?? "", + 10 + ) || DEFAULT_POLL_INTERVAL_MS; + + console.log( + `[Standalone] Running ${characterName} with OODA executor.\n` + + `[Standalone] Poll interval: ${pollIntervalMs / 1000}s\n` + + `[Standalone] Press Ctrl+C to stop.\n` + ); + + let running = true; + let cycleCount = 0; + + const shutdown = () => { + console.log("\n[Standalone] Shutting down..."); + running = false; + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + // Run first cycle immediately, then poll + while (running) { + cycleCount++; + const workflow = workflowFactory(); + + console.log( + `\n[Standalone] Cycle #${cycleCount} starting at ${new Date().toISOString()}` + ); + + try { + const execution = await executor.execute(workflow); + console.log( + `[Standalone] Cycle #${cycleCount} completed: ` + + `status=${execution.status}, ` + + `observations=${execution.observations.length}, ` + + `actions=${execution.actions.length}` + ); + + if (execution.status === "escalated") { + console.log( + `[Standalone] Cycle #${cycleCount} escalated. Decision: ` + + JSON.stringify(execution.decision, null, 2) + ); + } + } catch (err) { + console.error( + `[Standalone] Cycle #${cycleCount} error: ${(err as Error).message}` + ); + } + + // Wait for next cycle (interruptible) + if (running) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, pollIntervalMs); + // Allow immediate exit on shutdown + const checkShutdown = () => { + if (!running) { + clearTimeout(timer); + resolve(); + } + }; + // Check every second + const interval = setInterval(checkShutdown, 1000); + const originalResolve = resolve; + setTimeout(() => clearInterval(interval), pollIntervalMs + 100); + }); + } + } + + console.log( + `[Standalone] Stopped after ${cycleCount} cycles.` + ); +} diff --git a/agents/packages/plugin-koi-mcp/src/index.ts b/agents/packages/plugin-koi-mcp/src/index.ts index 412f0df..476d368 100644 --- a/agents/packages/plugin-koi-mcp/src/index.ts +++ b/agents/packages/plugin-koi-mcp/src/index.ts @@ -5,12 +5,107 @@ * Enables semantic search, entity resolution, SPARQL queries, and * code graph navigation. * + * Exports both: + * - Raw functions for standalone mode (getKnowledgeContext, etc.) + * - ElizaOS plugin object (koiMcpPlugin) for runtime registration + * * Based on phase-3/3.2-agent-implementation.md §@regen/plugin-koi-mcp. */ +import type { KOIMCPClient, SearchIntent } from "./client.js"; +import { + getKnowledgeContext, + formatKnowledgeContext, +} from "./providers/knowledge-context.js"; + +// Re-export everything for direct imports export { KOIMCPClient, type KOIMCPConfig, type SearchIntent } from "./client.js"; export { getKnowledgeContext, formatKnowledgeContext, type KnowledgeResult, } from "./providers/knowledge-context.js"; + +// --------------------------------------------------------------------------- +// ElizaOS Plugin Object +// --------------------------------------------------------------------------- + +/** Shared client instance, set via _setClient before runtime.initialize(). */ +let _client: KOIMCPClient | null = null; + +function getClient(): KOIMCPClient { + if (!_client) { + throw new Error( + "KOIMCPClient not initialized. Call koiMcpPlugin._setClient() before runtime.initialize()." + ); + } + return _client; +} + +/** + * ElizaOS provider: knowledge-context + * + * Searches the KOI knowledge graph for context relevant to the current + * message and injects it into the agent's context window. Grounds all + * agent analysis in Regen's institutional knowledge. + */ +const knowledgeContextProvider = { + name: "KNOWLEDGE_CONTEXT", + description: + "Relevant knowledge from the Regen KOI knowledge graph -- methodology docs, forum discussions, historical context.", + + get: async ( + _runtime: any, + message: any, + _state: any + ): Promise => { + // Extract query text from the message + const text = + typeof message?.content === "string" + ? message.content + : message?.content?.text ?? "regen governance"; + + // Detect intent from message content + let intent: SearchIntent = "general"; + if (/how\s+to|setup|install|configure/i.test(text)) { + intent = "technical_howto"; + } else if (/who|team|contributor|member/i.test(text)) { + intent = "person_activity"; + } + + try { + const results = await getKnowledgeContext( + getClient(), + text, + intent, + 5 + ); + return formatKnowledgeContext(results); + } catch (err) { + return `[Knowledge context unavailable: ${(err as Error).message}]`; + } + }, +}; + +/** + * The ElizaOS plugin export. + * + * Usage: + * import { koiMcpPlugin } from "@regen/plugin-koi-mcp"; + * koiMcpPlugin._setClient(myKoiClient); + * // then pass to AgentRuntime plugins array + */ +export const koiMcpPlugin = { + name: "@regen/plugin-koi-mcp", + description: + "Provides Regen KOI knowledge graph access via MCP -- semantic search, entity resolution, SPARQL, code graph.", + actions: [], + providers: [knowledgeContextProvider], + evaluators: [], + services: [], + + /** Inject the MCP client before runtime initialization. */ + _setClient(client: KOIMCPClient) { + _client = client; + }, +}; diff --git a/agents/packages/plugin-ledger-mcp/src/index.ts b/agents/packages/plugin-ledger-mcp/src/index.ts index 34ae6c9..fbe46a4 100644 --- a/agents/packages/plugin-ledger-mcp/src/index.ts +++ b/agents/packages/plugin-ledger-mcp/src/index.ts @@ -5,9 +5,25 @@ * Registers actions, providers, and evaluators for interacting with * the Regen Ledger blockchain. * + * Exports both: + * - Raw functions for standalone mode (analyzeProposal, getLedgerState, etc.) + * - ElizaOS plugin object (ledgerMcpPlugin) for runtime registration + * * Based on phase-3/3.2-agent-implementation.md §@regen/plugin-ledger-mcp. */ +import type { LedgerMCPClient } from "./client.js"; +import { + analyzeProposal, + formatProposalAnalysis, + extractProposalId, +} from "./actions/analyze-proposal.js"; +import { + getLedgerState, + formatLedgerState, +} from "./providers/ledger-state.js"; + +// Re-export everything for direct imports export { LedgerMCPClient, type LedgerMCPConfig } from "./client.js"; export { analyzeProposal, @@ -20,3 +36,148 @@ export { formatLedgerState, type LedgerStateSnapshot, } from "./providers/ledger-state.js"; + +// --------------------------------------------------------------------------- +// ElizaOS Plugin Object +// +// Conforms to the ElizaOS Plugin interface. Actions and providers wrap the +// existing pure functions above so they work within the ElizaOS runtime. +// --------------------------------------------------------------------------- + +/** Shared client instance, set via _setClient before runtime.initialize(). */ +let _client: LedgerMCPClient | null = null; + +function getClient(): LedgerMCPClient { + if (!_client) { + throw new Error( + "LedgerMCPClient not initialized. Call ledgerMcpPlugin._setClient() before runtime.initialize()." + ); + } + return _client; +} + +/** + * ElizaOS action: analyze-proposal + * + * Validates that the message contains a proposal reference, + * fetches it from chain, and returns structured analysis. + */ +const analyzeProposalAction = { + name: "ANALYZE_PROPOSAL", + description: + "Fetch a Regen governance proposal by ID and return a structured analysis with voting tallies and impact assessment.", + similes: [ + "SUMMARIZE_PROPOSAL", + "GET_PROPOSAL", + "PROPOSAL_ANALYSIS", + "CHECK_PROPOSAL", + ], + + validate: async (_runtime: any, message: any): Promise => { + const text = + typeof message.content === "string" + ? message.content + : message.content?.text ?? ""; + return extractProposalId(text) !== null; + }, + + handler: async ( + _runtime: any, + message: any, + _state: any, + _options: any, + callback: (response: { text: string }) => void + ): Promise => { + const text = + typeof message.content === "string" + ? message.content + : message.content?.text ?? ""; + const proposalId = extractProposalId(text); + + if (!proposalId) { + callback({ + text: "Could not extract a proposal ID from that message. Try: 'Analyze proposal #62'", + }); + return; + } + + try { + const analysis = await analyzeProposal(getClient(), proposalId); + if (!analysis) { + callback({ + text: `Proposal #${proposalId} not found on chain.`, + }); + return; + } + callback({ text: formatProposalAnalysis(analysis) }); + } catch (err) { + callback({ + text: `Failed to analyze proposal #${proposalId}: ${(err as Error).message}`, + }); + } + }, + + examples: [ + [ + { + user: "{{user1}}", + content: { text: "Analyze proposal #62" }, + }, + { + user: "{{agentName}}", + content: { + text: "## Proposal #62 Analysis\n\n**Title**: Enable IBC Transfer Memo Field...", + action: "ANALYZE_PROPOSAL", + }, + }, + ], + ], +}; + +/** + * ElizaOS provider: ledger-state + * + * Injects current Regen Ledger state (active proposals, credit classes, + * REGEN supply) into the agent's context on every message cycle. + */ +const ledgerStateProvider = { + name: "LEDGER_STATE", + description: + "Current Regen Ledger state: active proposals, credit class count, total REGEN supply.", + + get: async ( + _runtime: any, + _message: any, + _state: any + ): Promise => { + try { + const state = await getLedgerState(getClient()); + return formatLedgerState(state); + } catch (err) { + return `[Ledger state unavailable: ${(err as Error).message}]`; + } + }, +}; + +/** + * The ElizaOS plugin export. + * + * Usage: + * import { ledgerMcpPlugin } from "@regen/plugin-ledger-mcp"; + * ledgerMcpPlugin._setClient(myLedgerClient); + * // then pass to AgentRuntime plugins array + */ +export const ledgerMcpPlugin = { + name: "@regen/plugin-ledger-mcp", + description: + "Provides Regen Ledger on-chain data access via MCP -- governance proposals, credit classes, validators, and supply.", + actions: [analyzeProposalAction], + providers: [ledgerStateProvider], + evaluators: [], + services: [], + + /** Inject the MCP client before runtime initialization. */ + _setClient(client: LedgerMCPClient) { + _client = client; + }, +}; diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml new file mode 100644 index 0000000..13c72d7 --- /dev/null +++ b/contracts/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = [ + "attestation-bonding", + "contribution-rewards", + "credit-class-voting", + "integration-tests", + "marketplace-curation", + "service-escrow", + "validator-governance", +] +resolver = "2" + +[workspace.dependencies] +cosmwasm-schema = "2.2" +cosmwasm-std = "2.2" +cw-storage-plus = "2.0" +cw2 = "2.0" +cw-multi-test = "2.0" +schemars = "0.8" +serde = { version = "1", features = ["derive"] } +thiserror = "2" diff --git a/contracts/attestation-bonding/Cargo.toml b/contracts/attestation-bonding/Cargo.toml new file mode 100644 index 0000000..761ef5c --- /dev/null +++ b/contracts/attestation-bonding/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "attestation-bonding" +version = "0.1.0" +edition = "2021" +description = "M008 Attestation Bonding — economic skin-in-the-game for ecological data attestations on Regen Network" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true diff --git a/contracts/attestation-bonding/src/contract.rs b/contracts/attestation-bonding/src/contract.rs new file mode 100644 index 0000000..b11af31 --- /dev/null +++ b/contracts/attestation-bonding/src/contract.rs @@ -0,0 +1,821 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Timestamp, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + AttestationResponse, AttestationsResponse, BondPoolResponse, ChallengeResponse, + ChallengesResponse, ConfigResponse, ExecuteMsg, InstantiateMsg, QueryMsg, +}; +use crate::state::{ + Attestation, AttestationStatus, AttestationType, BondPoolState, Challenge, + ChallengeResolution, Config, ATTESTATIONS, ATTESTATION_CHALLENGES, BOND_POOL, CHALLENGES, + CONFIG, NEXT_ATTESTATION_ID, NEXT_CHALLENGE_ID, +}; + +const CONTRACT_NAME: &str = "crates.io:attestation-bonding"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const DEFAULT_QUERY_LIMIT: u32 = 10; +const MAX_QUERY_LIMIT: u32 = 30; + +const SECONDS_PER_DAY: u64 = 86_400; + +// ── Instantiate ─────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let challenge_ratio = msg.challenge_deposit_ratio_bps.unwrap_or(1000); + let arbiter_fee = msg.arbiter_fee_ratio_bps.unwrap_or(500); + + if challenge_ratio > 5000 { + return Err(ContractError::FeeRateOutOfRange { + value: challenge_ratio, min: 0, max: 5000, + }); + } + if arbiter_fee > 2000 { + return Err(ContractError::FeeRateOutOfRange { + value: arbiter_fee, min: 0, max: 2000, + }); + } + + let config = Config { + admin: info.sender.clone(), + arbiter_dao: deps.api.addr_validate(&msg.arbiter_dao)?, + community_pool: deps.api.addr_validate(&msg.community_pool)?, + challenge_deposit_ratio_bps: challenge_ratio, + arbiter_fee_ratio_bps: arbiter_fee, + activation_delay_seconds: msg.activation_delay_seconds.unwrap_or(172_800), // 48h + denom: msg.denom, + // Min bonds (uregen) + min_bond_project_boundary: Uint128::new(500_000_000), // 500 REGEN + min_bond_baseline_measurement: Uint128::new(1_000_000_000), // 1000 REGEN + min_bond_credit_issuance: Uint128::new(2_000_000_000), // 2000 REGEN + min_bond_methodology_validation: Uint128::new(5_000_000_000), // 5000 REGEN + // Lock periods (seconds) + lock_period_project_boundary: 90 * SECONDS_PER_DAY, + lock_period_baseline_measurement: 180 * SECONDS_PER_DAY, + lock_period_credit_issuance: 365 * SECONDS_PER_DAY, + lock_period_methodology_validation: 730 * SECONDS_PER_DAY, + // Challenge windows (seconds) + challenge_window_project_boundary: 60 * SECONDS_PER_DAY, + challenge_window_baseline_measurement: 120 * SECONDS_PER_DAY, + challenge_window_credit_issuance: 300 * SECONDS_PER_DAY, + challenge_window_methodology_validation: 600 * SECONDS_PER_DAY, + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &config)?; + NEXT_ATTESTATION_ID.save(deps.storage, &1u64)?; + NEXT_CHALLENGE_ID.save(deps.storage, &1u64)?; + BOND_POOL.save(deps.storage, &BondPoolState::default())?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// ── Execute ─────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::CreateAttestation { + attestation_type, iri, beneficiary, + } => execute_create(deps, env, info, attestation_type, iri, beneficiary), + ExecuteMsg::ActivateAttestation { attestation_id } => { + execute_activate(deps, env, attestation_id) + } + ExecuteMsg::ChallengeAttestation { + attestation_id, evidence_iri, + } => execute_challenge(deps, env, info, attestation_id, evidence_iri), + ExecuteMsg::ResolveChallenge { + attestation_id, resolution, + } => execute_resolve(deps, env, info, attestation_id, resolution), + ExecuteMsg::ReleaseBond { attestation_id } => { + execute_release(deps, env, info, attestation_id) + } + ExecuteMsg::UpdateConfig { + arbiter_dao, community_pool, challenge_deposit_ratio_bps, + arbiter_fee_ratio_bps, activation_delay_seconds, + } => execute_update_config( + deps, info, arbiter_dao, community_pool, challenge_deposit_ratio_bps, + arbiter_fee_ratio_bps, activation_delay_seconds, + ), + } +} + +fn execute_create( + deps: DepsMut, env: Env, info: MessageInfo, + attestation_type: AttestationType, iri: String, beneficiary: Option, +) -> Result { + if iri.is_empty() { + return Err(ContractError::EmptyIri); + } + + let config = CONFIG.load(deps.storage)?; + let min_bond = config.min_bond_for(&attestation_type); + let bond_amount = must_pay(&info, &config.denom)?; + + if bond_amount < min_bond { + return Err(ContractError::BondBelowMinimum { + sent: bond_amount.to_string(), + required: min_bond.to_string(), + attestation_type: attestation_type.to_string(), + }); + } + + let beneficiary_addr = beneficiary + .map(|b| deps.api.addr_validate(&b)) + .transpose()?; + + let lock_period = config.lock_period_for(&attestation_type); + let challenge_window = config.challenge_window_for(&attestation_type); + let now = env.block.time; + + let id = NEXT_ATTESTATION_ID.load(deps.storage)?; + let attestation = Attestation { + id, + attester: info.sender.clone(), + attestation_type: attestation_type.clone(), + status: AttestationStatus::Bonded, + iri: iri.clone(), + bond_amount, + bonded_at: now, + activates_at: Timestamp::from_seconds(now.seconds() + config.activation_delay_seconds), + lock_expires_at: Timestamp::from_seconds(now.seconds() + lock_period), + challenge_window_closes_at: Timestamp::from_seconds(now.seconds() + challenge_window), + beneficiary: beneficiary_addr, + }; + + ATTESTATIONS.save(deps.storage, id, &attestation)?; + NEXT_ATTESTATION_ID.save(deps.storage, &(id + 1))?; + + let mut pool = BOND_POOL.load(deps.storage)?; + pool.total_bonded += bond_amount; + BOND_POOL.save(deps.storage, &pool)?; + + Ok(Response::new() + .add_attribute("action", "create_attestation") + .add_attribute("attestation_id", id.to_string()) + .add_attribute("attester", info.sender) + .add_attribute("attestation_type", attestation_type.to_string()) + .add_attribute("bond_amount", bond_amount) + .add_attribute("iri", iri)) +} + +fn execute_activate( + deps: DepsMut, env: Env, attestation_id: u64, +) -> Result { + let mut attestation = load_attestation(deps.as_ref(), attestation_id)?; + + if attestation.status != AttestationStatus::Bonded { + return Err(ContractError::InvalidStatus { + expected: "Bonded".to_string(), + actual: attestation.status.to_string(), + }); + } + if env.block.time < attestation.activates_at { + return Err(ContractError::InvalidStatus { + expected: "activation delay passed".to_string(), + actual: "activation delay not yet passed".to_string(), + }); + } + + attestation.status = AttestationStatus::Active; + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + + Ok(Response::new() + .add_attribute("action", "activate_attestation") + .add_attribute("attestation_id", attestation_id.to_string())) +} + +fn execute_challenge( + deps: DepsMut, env: Env, info: MessageInfo, + attestation_id: u64, evidence_iri: String, +) -> Result { + if evidence_iri.is_empty() { + return Err(ContractError::EmptyIri); + } + + let config = CONFIG.load(deps.storage)?; + let mut attestation = load_attestation(deps.as_ref(), attestation_id)?; + + // Must be Bonded or Active + match attestation.status { + AttestationStatus::Bonded | AttestationStatus::Active => {} + _ => { + return Err(ContractError::InvalidStatus { + expected: "Bonded or Active".to_string(), + actual: attestation.status.to_string(), + }); + } + } + + // Check challenge window + if env.block.time > attestation.challenge_window_closes_at { + return Err(ContractError::ChallengeWindowClosed); + } + + // Only one active challenge per attestation + if ATTESTATION_CHALLENGES.may_load(deps.storage, attestation_id)?.is_some() { + return Err(ContractError::ActiveChallengePending { attestation_id }); + } + + // Verify deposit + let min_deposit = attestation + .bond_amount + .multiply_ratio(config.challenge_deposit_ratio_bps, 10_000u128); + let deposit = must_pay(&info, &config.denom)?; + if deposit < min_deposit { + return Err(ContractError::ChallengeDepositBelowMinimum { + sent: deposit.to_string(), + required: min_deposit.to_string(), + }); + } + + let challenge_id = NEXT_CHALLENGE_ID.load(deps.storage)?; + let challenge = Challenge { + id: challenge_id, + attestation_id, + challenger: info.sender.clone(), + evidence_iri: evidence_iri.clone(), + deposit, + deposited_at: env.block.time, + resolution: None, + resolved_at: None, + }; + + attestation.status = AttestationStatus::Challenged; + + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + ATTESTATION_CHALLENGES.save(deps.storage, attestation_id, &challenge_id)?; + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + NEXT_CHALLENGE_ID.save(deps.storage, &(challenge_id + 1))?; + + let mut pool = BOND_POOL.load(deps.storage)?; + pool.total_challenge_deposits += deposit; + BOND_POOL.save(deps.storage, &pool)?; + + Ok(Response::new() + .add_attribute("action", "challenge_attestation") + .add_attribute("attestation_id", attestation_id.to_string()) + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("challenger", info.sender) + .add_attribute("deposit", deposit) + .add_attribute("evidence_iri", evidence_iri)) +} + +fn execute_resolve( + deps: DepsMut, env: Env, info: MessageInfo, + attestation_id: u64, resolution: ChallengeResolution, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + if info.sender != config.arbiter_dao && info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "Only arbiter DAO or admin can resolve challenges".to_string(), + }); + } + + let mut attestation = load_attestation(deps.as_ref(), attestation_id)?; + if attestation.status != AttestationStatus::Challenged { + return Err(ContractError::InvalidStatus { + expected: "Challenged".to_string(), + actual: attestation.status.to_string(), + }); + } + + let challenge_id = ATTESTATION_CHALLENGES.load(deps.storage, attestation_id) + .map_err(|_| ContractError::NoActiveChallenge { attestation_id })?; + let mut challenge = CHALLENGES.load(deps.storage, challenge_id)?; + + // Conflict of interest checks + if info.sender == attestation.attester { + return Err(ContractError::ResolverIsAttester); + } + if info.sender == challenge.challenger { + return Err(ContractError::ResolverIsChallenger); + } + + let arbiter_fee = attestation + .bond_amount + .multiply_ratio(config.arbiter_fee_ratio_bps, 10_000u128); + + let mut msgs = vec![]; + let mut pool = BOND_POOL.load(deps.storage)?; + + match resolution { + ChallengeResolution::Valid => { + // Attester wins: gets bond + challenge deposit - arbiter fee + let attester_receives = attestation.bond_amount + challenge.deposit - arbiter_fee; + if !attester_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: attestation.attester.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: attester_receives, + }], + }); + } + if !arbiter_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: arbiter_fee, + }], + }); + } + attestation.status = AttestationStatus::ResolvedValid; + pool.total_bonded -= attestation.bond_amount; + pool.total_challenge_deposits -= challenge.deposit; + pool.total_disbursed += attester_receives + arbiter_fee; + } + ChallengeResolution::Invalid => { + // Challenger wins: 50% of bond + deposit - arbiter fee + let bond_half = attestation.bond_amount.multiply_ratio(1u128, 2u128); + let challenger_receives = bond_half + challenge.deposit - arbiter_fee; + if !challenger_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: challenge.challenger.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: challenger_receives, + }], + }); + } + // Community pool gets other half + arbiter fee + let community_receives = bond_half + arbiter_fee; + if !community_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: community_receives, + }], + }); + } + attestation.status = AttestationStatus::Slashed; + pool.total_bonded -= attestation.bond_amount; + pool.total_challenge_deposits -= challenge.deposit; + pool.total_disbursed += challenger_receives + community_receives; + } + } + + challenge.resolution = Some(resolution); + challenge.resolved_at = Some(env.block.time); + + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + ATTESTATION_CHALLENGES.remove(deps.storage, attestation_id); + BOND_POOL.save(deps.storage, &pool)?; + + let mut resp = Response::new() + .add_attribute("action", "resolve_challenge") + .add_attribute("attestation_id", attestation_id.to_string()) + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("arbiter_fee", arbiter_fee); + for msg in msgs { + resp = resp.add_message(msg); + } + Ok(resp) +} + +fn execute_release( + deps: DepsMut, env: Env, info: MessageInfo, attestation_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut attestation = load_attestation(deps.as_ref(), attestation_id)?; + + if info.sender != attestation.attester { + return Err(ContractError::Unauthorized { + reason: "Only the attester can release their bond".to_string(), + }); + } + + match attestation.status { + AttestationStatus::Active | AttestationStatus::ResolvedValid => {} + _ => { + return Err(ContractError::InvalidStatus { + expected: "Active or ResolvedValid".to_string(), + actual: attestation.status.to_string(), + }); + } + } + + if env.block.time < attestation.lock_expires_at { + return Err(ContractError::LockPeriodNotExpired); + } + + // Check no active challenge + if ATTESTATION_CHALLENGES.may_load(deps.storage, attestation_id)?.is_some() { + return Err(ContractError::ActiveChallengePending { attestation_id }); + } + + let release_amount = attestation.bond_amount; + attestation.status = AttestationStatus::Released; + + let mut pool = BOND_POOL.load(deps.storage)?; + pool.total_bonded -= release_amount; + pool.total_disbursed += release_amount; + + ATTESTATIONS.save(deps.storage, attestation_id, &attestation)?; + BOND_POOL.save(deps.storage, &pool)?; + + let msg = BankMsg::Send { + to_address: attestation.attester.to_string(), + amount: vec![Coin { + denom: config.denom, + amount: release_amount, + }], + }; + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "release_bond") + .add_attribute("attestation_id", attestation_id.to_string()) + .add_attribute("amount", release_amount)) +} + +fn execute_update_config( + deps: DepsMut, info: MessageInfo, + arbiter_dao: Option, community_pool: Option, + challenge_deposit_ratio_bps: Option, arbiter_fee_ratio_bps: Option, + activation_delay_seconds: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "Only admin can update config".to_string(), + }); + } + + if let Some(addr) = arbiter_dao { + config.arbiter_dao = deps.api.addr_validate(&addr)?; + } + if let Some(addr) = community_pool { + config.community_pool = deps.api.addr_validate(&addr)?; + } + if let Some(v) = challenge_deposit_ratio_bps { + if v > 5000 { + return Err(ContractError::FeeRateOutOfRange { value: v, min: 0, max: 5000 }); + } + config.challenge_deposit_ratio_bps = v; + } + if let Some(v) = arbiter_fee_ratio_bps { + if v > 2000 { + return Err(ContractError::FeeRateOutOfRange { value: v, min: 0, max: 2000 }); + } + config.arbiter_fee_ratio_bps = v; + } + if let Some(v) = activation_delay_seconds { + config.activation_delay_seconds = v; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// ── Query ───────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Attestation { attestation_id } => { + to_json_binary(&query_attestation(deps, attestation_id)?) + } + QueryMsg::Attestations { + status, attester, start_after, limit, + } => to_json_binary(&query_attestations(deps, status, attester, start_after, limit)?), + QueryMsg::Challenge { challenge_id } => { + to_json_binary(&query_challenge(deps, challenge_id)?) + } + QueryMsg::Challenges { + attestation_id, start_after, limit, + } => to_json_binary(&query_challenges(deps, attestation_id, start_after, limit)?), + QueryMsg::BondPool {} => to_json_binary(&query_bond_pool(deps)?), + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + admin: config.admin.to_string(), + arbiter_dao: config.arbiter_dao.to_string(), + community_pool: config.community_pool.to_string(), + challenge_deposit_ratio_bps: config.challenge_deposit_ratio_bps, + arbiter_fee_ratio_bps: config.arbiter_fee_ratio_bps, + activation_delay_seconds: config.activation_delay_seconds, + denom: config.denom, + }) +} + +fn query_attestation(deps: Deps, attestation_id: u64) -> StdResult { + let attestation = ATTESTATIONS.load(deps.storage, attestation_id)?; + let active_challenge = ATTESTATION_CHALLENGES + .may_load(deps.storage, attestation_id)? + .and_then(|cid| CHALLENGES.load(deps.storage, cid).ok()); + Ok(AttestationResponse { + attestation, + active_challenge, + }) +} + +fn query_attestations( + deps: Deps, status: Option, attester: Option, + start_after: Option, limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| cw_storage_plus::Bound::exclusive(s)); + let attester_addr = attester.map(|a| deps.api.addr_validate(&a)).transpose()?; + + let attestations: Vec<_> = ATTESTATIONS + .range(deps.storage, start, None, Order::Ascending) + .filter_map(|item| item.ok()) + .filter(|(_, a)| { + status.as_ref().map_or(true, |s| a.status == *s) + && attester_addr.as_ref().map_or(true, |addr| a.attester == *addr) + }) + .take(limit) + .map(|(_, a)| a) + .collect(); + + Ok(AttestationsResponse { attestations }) +} + +fn query_challenge(deps: Deps, challenge_id: u64) -> StdResult { + let challenge = CHALLENGES.load(deps.storage, challenge_id)?; + Ok(ChallengeResponse { challenge }) +} + +fn query_challenges( + deps: Deps, attestation_id: Option, start_after: Option, limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| cw_storage_plus::Bound::exclusive(s)); + + let challenges: Vec<_> = CHALLENGES + .range(deps.storage, start, None, Order::Ascending) + .filter_map(|item| item.ok()) + .filter(|(_, c)| attestation_id.map_or(true, |aid| c.attestation_id == aid)) + .take(limit) + .map(|(_, c)| c) + .collect(); + + Ok(ChallengesResponse { challenges }) +} + +fn query_bond_pool(deps: Deps) -> StdResult { + let pool = BOND_POOL.load(deps.storage)?; + Ok(BondPoolResponse { + total_bonded: pool.total_bonded, + total_challenge_deposits: pool.total_challenge_deposits, + total_disbursed: pool.total_disbursed, + }) +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +fn load_attestation(deps: Deps, id: u64) -> Result { + ATTESTATIONS + .load(deps.storage, id) + .map_err(|_| ContractError::AttestationNotFound { id }) +} + +fn must_pay(info: &MessageInfo, expected_denom: &str) -> Result { + if info.funds.len() != 1 { + return Err(ContractError::InsufficientFunds { + required: expected_denom.to_string(), + sent: format!("{} coins", info.funds.len()), + }); + } + let coin = &info.funds[0]; + if coin.denom != expected_denom { + return Err(ContractError::WrongDenom { + expected: expected_denom.to_string(), + got: coin.denom.clone(), + }); + } + if coin.amount.is_zero() { + return Err(ContractError::InsufficientFunds { + required: "non-zero".to_string(), + sent: "0".to_string(), + }); + } + Ok(coin.amount) +} + +// ── Tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{Addr, Timestamp}; + + const DENOM: &str = "uregen"; + + fn addr(input: &str) -> Addr { + MockApi::default().addr_make(input) + } + + fn setup_contract(deps: DepsMut) { + let msg = InstantiateMsg { + arbiter_dao: addr("arbiter").to_string(), + community_pool: addr("community").to_string(), + denom: DENOM.to_string(), + challenge_deposit_ratio_bps: None, + arbiter_fee_ratio_bps: None, + activation_delay_seconds: Some(100), + }; + let info = message_info(&addr("admin"), &[]); + instantiate(deps, mock_env(), info, msg).unwrap(); + } + + fn create_attestation(deps: DepsMut, env: Env) -> u64 { + let info = message_info( + &addr("attester"), + &[Coin::new(500_000_000u128, DENOM)], + ); + let msg = ExecuteMsg::CreateAttestation { + attestation_type: AttestationType::ProjectBoundary, + iri: "regen:attestation/1".to_string(), + beneficiary: None, + }; + let res = execute(deps, env, info, msg).unwrap(); + res.attributes + .iter() + .find(|a| a.key == "attestation_id") + .unwrap() + .value + .parse() + .unwrap() + } + + #[test] + fn test_instantiate() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let config = CONFIG.load(&deps.storage).unwrap(); + assert_eq!(config.denom, DENOM); + assert_eq!(config.activation_delay_seconds, 100); + } + + #[test] + fn test_create_attestation() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let env = mock_env(); + let id = create_attestation(deps.as_mut(), env); + assert_eq!(id, 1); + + let attestation = ATTESTATIONS.load(&deps.storage, 1).unwrap(); + assert_eq!(attestation.status, AttestationStatus::Bonded); + assert_eq!(attestation.bond_amount, Uint128::new(500_000_000)); + } + + #[test] + fn test_bond_below_minimum() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let info = message_info( + &addr("attester"), + &[Coin::new(100u128, DENOM)], + ); + let msg = ExecuteMsg::CreateAttestation { + attestation_type: AttestationType::ProjectBoundary, + iri: "regen:attestation/1".to_string(), + beneficiary: None, + }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::BondBelowMinimum { .. })); + } + + #[test] + fn test_activate_attestation() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let mut env = mock_env(); + create_attestation(deps.as_mut(), env.clone()); + + // Too early + let msg = ExecuteMsg::ActivateAttestation { attestation_id: 1 }; + let err = execute(deps.as_mut(), env.clone(), message_info(&addr("anyone"), &[]), msg.clone()).unwrap_err(); + assert!(matches!(err, ContractError::InvalidStatus { .. })); + + // After delay + env.block.time = Timestamp::from_seconds(env.block.time.seconds() + 101); + execute(deps.as_mut(), env, message_info(&addr("anyone"), &[]), msg).unwrap(); + + let attestation = ATTESTATIONS.load(&deps.storage, 1).unwrap(); + assert_eq!(attestation.status, AttestationStatus::Active); + } + + #[test] + fn test_challenge_and_resolve_valid() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let env = mock_env(); + create_attestation(deps.as_mut(), env.clone()); + + // Challenge (10% of 500M = 50M) + let challenger_info = message_info( + &addr("challenger"), + &[Coin::new(50_000_000u128, DENOM)], + ); + let msg = ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "regen:evidence/1".to_string(), + }; + execute(deps.as_mut(), env.clone(), challenger_info, msg).unwrap(); + + let attestation = ATTESTATIONS.load(&deps.storage, 1).unwrap(); + assert_eq!(attestation.status, AttestationStatus::Challenged); + + // Resolve as Valid (attester wins) + let arbiter_info = message_info(&addr("arbiter"), &[]); + let msg = ExecuteMsg::ResolveChallenge { + attestation_id: 1, + resolution: ChallengeResolution::Valid, + }; + let res = execute(deps.as_mut(), env, arbiter_info, msg).unwrap(); + assert!(res.messages.len() >= 1); + + let attestation = ATTESTATIONS.load(&deps.storage, 1).unwrap(); + assert_eq!(attestation.status, AttestationStatus::ResolvedValid); + } + + #[test] + fn test_challenge_and_resolve_invalid() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let env = mock_env(); + create_attestation(deps.as_mut(), env.clone()); + + let challenger_info = message_info( + &addr("challenger"), + &[Coin::new(50_000_000u128, DENOM)], + ); + let msg = ExecuteMsg::ChallengeAttestation { + attestation_id: 1, + evidence_iri: "regen:evidence/1".to_string(), + }; + execute(deps.as_mut(), env.clone(), challenger_info, msg).unwrap(); + + let arbiter_info = message_info(&addr("arbiter"), &[]); + let msg = ExecuteMsg::ResolveChallenge { + attestation_id: 1, + resolution: ChallengeResolution::Invalid, + }; + let res = execute(deps.as_mut(), env, arbiter_info, msg).unwrap(); + assert!(res.messages.len() >= 2); + + let attestation = ATTESTATIONS.load(&deps.storage, 1).unwrap(); + assert_eq!(attestation.status, AttestationStatus::Slashed); + } + + #[test] + fn test_release_bond() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let mut env = mock_env(); + create_attestation(deps.as_mut(), env.clone()); + + // Activate + env.block.time = Timestamp::from_seconds(env.block.time.seconds() + 101); + execute( + deps.as_mut(), env.clone(), + message_info(&addr("anyone"), &[]), + ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + ).unwrap(); + + // Try release before lock expires + let attester_info = message_info(&addr("attester"), &[]); + let msg = ExecuteMsg::ReleaseBond { attestation_id: 1 }; + let err = execute(deps.as_mut(), env.clone(), attester_info.clone(), msg.clone()).unwrap_err(); + assert!(matches!(err, ContractError::LockPeriodNotExpired)); + + // After lock period (90 days) + env.block.time = Timestamp::from_seconds(env.block.time.seconds() + 90 * 86_400 + 1); + execute(deps.as_mut(), env, attester_info, msg).unwrap(); + + let attestation = ATTESTATIONS.load(&deps.storage, 1).unwrap(); + assert_eq!(attestation.status, AttestationStatus::Released); + } +} diff --git a/contracts/attestation-bonding/src/error.rs b/contracts/attestation-bonding/src/error.rs new file mode 100644 index 0000000..640223c --- /dev/null +++ b/contracts/attestation-bonding/src/error.rs @@ -0,0 +1,60 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + #[error("Attestation {id} not found")] + AttestationNotFound { id: u64 }, + + #[error("Challenge {id} not found")] + ChallengeNotFound { id: u64 }, + + #[error("Invalid attestation status: expected {expected}, got {actual}")] + InvalidStatus { expected: String, actual: String }, + + #[error("Bond amount {sent} below minimum {required} for {attestation_type}")] + BondBelowMinimum { + sent: String, + required: String, + attestation_type: String, + }, + + #[error("Challenge deposit {sent} below minimum {required}")] + ChallengeDepositBelowMinimum { sent: String, required: String }, + + #[error("Challenge window has closed")] + ChallengeWindowClosed, + + #[error("Active challenge already exists for attestation {attestation_id}")] + ActiveChallengePending { attestation_id: u64 }, + + #[error("No active challenge on attestation {attestation_id}")] + NoActiveChallenge { attestation_id: u64 }, + + #[error("Lock period has not expired yet")] + LockPeriodNotExpired, + + #[error("IRI must not be empty")] + EmptyIri, + + #[error("Insufficient funds: required {required}, sent {sent}")] + InsufficientFunds { required: String, sent: String }, + + #[error("Wrong denomination: expected {expected}, got {got}")] + WrongDenom { expected: String, got: String }, + + #[error("Resolver cannot be the attester")] + ResolverIsAttester, + + #[error("Resolver cannot be the challenger")] + ResolverIsChallenger, + + #[error("Fee rate out of range: {value} bps (allowed {min}-{max})")] + FeeRateOutOfRange { value: u64, min: u64, max: u64 }, +} diff --git a/contracts/attestation-bonding/src/lib.rs b/contracts/attestation-bonding/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/attestation-bonding/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/attestation-bonding/src/msg.rs b/contracts/attestation-bonding/src/msg.rs new file mode 100644 index 0000000..697586d --- /dev/null +++ b/contracts/attestation-bonding/src/msg.rs @@ -0,0 +1,131 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use crate::state::{ + Attestation, AttestationStatus, AttestationType, Challenge, ChallengeResolution, +}; + +// ── Instantiate ─────────────────────────────────────────────────────── + +#[cw_serde] +pub struct InstantiateMsg { + pub arbiter_dao: String, + pub community_pool: String, + pub denom: String, + pub challenge_deposit_ratio_bps: Option, + pub arbiter_fee_ratio_bps: Option, + pub activation_delay_seconds: Option, +} + +// ── Execute ─────────────────────────────────────────────────────────── + +#[cw_serde] +pub enum ExecuteMsg { + /// Submit a new attestation backed by a REGEN bond + CreateAttestation { + attestation_type: AttestationType, + iri: String, + beneficiary: Option, + }, + + /// Activate a bonded attestation after the activation delay + ActivateAttestation { attestation_id: u64 }, + + /// Challenge a bonded or active attestation + ChallengeAttestation { + attestation_id: u64, + evidence_iri: String, + }, + + /// Arbiter DAO resolves a challenge + ResolveChallenge { + attestation_id: u64, + resolution: ChallengeResolution, + }, + + /// Attester releases their bond after lock period expires + ReleaseBond { attestation_id: u64 }, + + /// Admin updates governance parameters + UpdateConfig { + arbiter_dao: Option, + community_pool: Option, + challenge_deposit_ratio_bps: Option, + arbiter_fee_ratio_bps: Option, + activation_delay_seconds: Option, + }, +} + +// ── Query ───────────────────────────────────────────────────────────── + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + + #[returns(AttestationResponse)] + Attestation { attestation_id: u64 }, + + #[returns(AttestationsResponse)] + Attestations { + status: Option, + attester: Option, + start_after: Option, + limit: Option, + }, + + #[returns(ChallengeResponse)] + Challenge { challenge_id: u64 }, + + #[returns(ChallengesResponse)] + Challenges { + attestation_id: Option, + start_after: Option, + limit: Option, + }, + + #[returns(BondPoolResponse)] + BondPool {}, +} + +// ── Responses ───────────────────────────────────────────────────────── + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub arbiter_dao: String, + pub community_pool: String, + pub challenge_deposit_ratio_bps: u64, + pub arbiter_fee_ratio_bps: u64, + pub activation_delay_seconds: u64, + pub denom: String, +} + +#[cw_serde] +pub struct AttestationResponse { + pub attestation: Attestation, + pub active_challenge: Option, +} + +#[cw_serde] +pub struct AttestationsResponse { + pub attestations: Vec, +} + +#[cw_serde] +pub struct ChallengeResponse { + pub challenge: Challenge, +} + +#[cw_serde] +pub struct ChallengesResponse { + pub challenges: Vec, +} + +#[cw_serde] +pub struct BondPoolResponse { + pub total_bonded: Uint128, + pub total_challenge_deposits: Uint128, + pub total_disbursed: Uint128, +} diff --git a/contracts/attestation-bonding/src/state.rs b/contracts/attestation-bonding/src/state.rs new file mode 100644 index 0000000..0ef6cc4 --- /dev/null +++ b/contracts/attestation-bonding/src/state.rs @@ -0,0 +1,171 @@ +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ── Attestation Types ───────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum AttestationType { + ProjectBoundary, + BaselineMeasurement, + CreditIssuanceClaim, + MethodologyValidation, +} + +impl std::fmt::Display for AttestationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AttestationType::ProjectBoundary => write!(f, "ProjectBoundary"), + AttestationType::BaselineMeasurement => write!(f, "BaselineMeasurement"), + AttestationType::CreditIssuanceClaim => write!(f, "CreditIssuanceClaim"), + AttestationType::MethodologyValidation => write!(f, "MethodologyValidation"), + } + } +} + +// ── Attestation Status ──────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum AttestationStatus { + Bonded, + Active, + Challenged, + ResolvedValid, + Slashed, + Released, +} + +impl std::fmt::Display for AttestationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AttestationStatus::Bonded => write!(f, "Bonded"), + AttestationStatus::Active => write!(f, "Active"), + AttestationStatus::Challenged => write!(f, "Challenged"), + AttestationStatus::ResolvedValid => write!(f, "ResolvedValid"), + AttestationStatus::Slashed => write!(f, "Slashed"), + AttestationStatus::Released => write!(f, "Released"), + } + } +} + +// ── Configuration ───────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + pub admin: Addr, + pub arbiter_dao: Addr, + pub community_pool: Addr, + /// Challenge deposit ratio in basis points (default 1000 = 10%) + pub challenge_deposit_ratio_bps: u64, + /// Arbiter fee ratio in basis points (default 500 = 5%) + pub arbiter_fee_ratio_bps: u64, + /// Activation delay in seconds (default 48h = 172800) + pub activation_delay_seconds: u64, + /// Accepted payment denomination + pub denom: String, + + // Min bonds per type (uregen) + pub min_bond_project_boundary: Uint128, + pub min_bond_baseline_measurement: Uint128, + pub min_bond_credit_issuance: Uint128, + pub min_bond_methodology_validation: Uint128, + + // Lock periods per type (seconds) + pub lock_period_project_boundary: u64, + pub lock_period_baseline_measurement: u64, + pub lock_period_credit_issuance: u64, + pub lock_period_methodology_validation: u64, + + // Challenge windows per type (seconds) + pub challenge_window_project_boundary: u64, + pub challenge_window_baseline_measurement: u64, + pub challenge_window_credit_issuance: u64, + pub challenge_window_methodology_validation: u64, +} + +impl Config { + pub fn min_bond_for(&self, atype: &AttestationType) -> Uint128 { + match atype { + AttestationType::ProjectBoundary => self.min_bond_project_boundary, + AttestationType::BaselineMeasurement => self.min_bond_baseline_measurement, + AttestationType::CreditIssuanceClaim => self.min_bond_credit_issuance, + AttestationType::MethodologyValidation => self.min_bond_methodology_validation, + } + } + + pub fn lock_period_for(&self, atype: &AttestationType) -> u64 { + match atype { + AttestationType::ProjectBoundary => self.lock_period_project_boundary, + AttestationType::BaselineMeasurement => self.lock_period_baseline_measurement, + AttestationType::CreditIssuanceClaim => self.lock_period_credit_issuance, + AttestationType::MethodologyValidation => self.lock_period_methodology_validation, + } + } + + pub fn challenge_window_for(&self, atype: &AttestationType) -> u64 { + match atype { + AttestationType::ProjectBoundary => self.challenge_window_project_boundary, + AttestationType::BaselineMeasurement => self.challenge_window_baseline_measurement, + AttestationType::CreditIssuanceClaim => self.challenge_window_credit_issuance, + AttestationType::MethodologyValidation => self.challenge_window_methodology_validation, + } + } +} + +// ── Attestation ─────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Attestation { + pub id: u64, + pub attester: Addr, + pub attestation_type: AttestationType, + pub status: AttestationStatus, + pub iri: String, + pub bond_amount: Uint128, + pub bonded_at: Timestamp, + pub activates_at: Timestamp, + pub lock_expires_at: Timestamp, + pub challenge_window_closes_at: Timestamp, + pub beneficiary: Option, +} + +// ── Challenge ───────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ChallengeResolution { + Valid, + Invalid, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Challenge { + pub id: u64, + pub attestation_id: u64, + pub challenger: Addr, + pub evidence_iri: String, + pub deposit: Uint128, + pub deposited_at: Timestamp, + pub resolution: Option, + pub resolved_at: Option, +} + +// ── Bond Pool ───────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, Default)] +pub struct BondPoolState { + pub total_bonded: Uint128, + pub total_challenge_deposits: Uint128, + pub total_disbursed: Uint128, +} + +// ── Storage Keys ────────────────────────────────────────────────────── + +pub const CONFIG: Item = Item::new("config"); +pub const NEXT_ATTESTATION_ID: Item = Item::new("next_attestation_id"); +pub const NEXT_CHALLENGE_ID: Item = Item::new("next_challenge_id"); +pub const ATTESTATIONS: Map = Map::new("attestations"); +pub const CHALLENGES: Map = Map::new("challenges"); +/// Map attestation_id → challenge_id for active challenges +pub const ATTESTATION_CHALLENGES: Map = Map::new("attestation_challenges"); +pub const BOND_POOL: Item = Item::new("bond_pool"); diff --git a/contracts/contribution-rewards/Cargo.toml b/contracts/contribution-rewards/Cargo.toml new file mode 100644 index 0000000..1db995d --- /dev/null +++ b/contracts/contribution-rewards/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "contribution-rewards" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true diff --git a/contracts/contribution-rewards/src/contract.rs b/contracts/contribution-rewards/src/contract.rs new file mode 100644 index 0000000..5eeafa5 --- /dev/null +++ b/contracts/contribution-rewards/src/contract.rs @@ -0,0 +1,2094 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Timestamp, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + ActivityScoreResponse, ClaimableRewardsResponse, ConfigResponse, DistributionRecordResponse, + ExecuteMsg, InstantiateMsg, MechanismStateResponse, ParticipantRewardsResponse, QueryMsg, + StabilityCommitmentResponse, +}; +use crate::state::{ + ActivityScore, CommitmentStatus, Config, DistributionRecord, MechanismState, MechanismStatus, + StabilityCommitment, ACTIVITY_SCORES, CLAIMABLE_REWARDS, COMMITMENTS, CONFIG, DISTRIBUTIONS, + MECHANISM_STATE, NEXT_COMMITMENT_ID, +}; + +const CONTRACT_NAME: &str = "crates.io:contribution-rewards"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Seconds per month approximation (30.44 days) +const SECONDS_PER_MONTH: u64 = 2_629_744; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let config = Config { + admin: info.sender.clone(), + community_pool_addr: deps.api.addr_validate(&msg.community_pool_addr)?, + denom: msg.denom, + credit_purchase_weight: 3000, + credit_retirement_weight: 3000, + platform_facilitation_weight: 2000, + governance_voting_weight: 1000, + proposal_submission_weight: 1000, + stability_annual_return_bps: 600, + max_stability_share_bps: 3000, + min_commitment_amount: Uint128::new(100_000_000), // 100 REGEN + min_lock_months: 6, + max_lock_months: 24, + early_exit_penalty_bps: 5000, + period_seconds: 604_800, // 7 days + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &config)?; + + let state = MechanismState { + status: MechanismStatus::Inactive, + tracking_start: None, + current_period: 0, + last_distribution_period: None, + total_committed_principal: Uint128::zero(), + }; + MECHANISM_STATE.save(deps.storage, &state)?; + NEXT_COMMITMENT_ID.save(deps.storage, &1u64)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::InitializeMechanism {} => execute_initialize(deps, env, info), + ExecuteMsg::ActivateDistribution {} => execute_activate(deps, info), + ExecuteMsg::CommitStability { lock_months } => { + execute_commit_stability(deps, env, info, lock_months) + } + ExecuteMsg::ExitStabilityEarly { commitment_id } => { + execute_exit_early(deps, env, info, commitment_id) + } + ExecuteMsg::ClaimMaturedStability { commitment_id } => { + execute_claim_matured(deps, env, info, commitment_id) + } + ExecuteMsg::RecordActivity { + participant, + credit_purchase_value, + credit_retirement_value, + platform_facilitation_value, + governance_votes, + proposal_credits, + } => execute_record_activity( + deps, + info, + participant, + credit_purchase_value, + credit_retirement_value, + platform_facilitation_value, + governance_votes, + proposal_credits, + ), + ExecuteMsg::TriggerDistribution { + community_pool_inflow, + } => execute_trigger_distribution(deps, env, info, community_pool_inflow), + ExecuteMsg::ClaimRewards {} => execute_claim_rewards(deps, info), + ExecuteMsg::UpdateConfig { + community_pool_addr, + credit_purchase_weight, + credit_retirement_weight, + platform_facilitation_weight, + governance_voting_weight, + proposal_submission_weight, + stability_annual_return_bps, + max_stability_share_bps, + min_commitment_amount, + min_lock_months, + max_lock_months, + early_exit_penalty_bps, + period_seconds, + } => execute_update_config( + deps, + info, + community_pool_addr, + credit_purchase_weight, + credit_retirement_weight, + platform_facilitation_weight, + governance_voting_weight, + proposal_submission_weight, + stability_annual_return_bps, + max_stability_share_bps, + min_commitment_amount, + min_lock_months, + max_lock_months, + early_exit_penalty_bps, + period_seconds, + ), + } +} + +// ── Execute handlers ─────────────────────────────────────────────────── + +fn execute_initialize( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let mut state = MECHANISM_STATE.load(deps.storage)?; + if state.status != MechanismStatus::Inactive { + return Err(ContractError::AlreadyInitialized); + } + + state.status = MechanismStatus::Tracking; + state.tracking_start = Some(env.block.time); + state.current_period = 1; + MECHANISM_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "initialize_mechanism") + .add_attribute("status", "Tracking") + .add_attribute("period", "1")) +} + +fn execute_activate(deps: DepsMut, info: MessageInfo) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let mut state = MECHANISM_STATE.load(deps.storage)?; + if state.status != MechanismStatus::Tracking { + return Err(ContractError::InvalidMechanismStatus { + expected: "Tracking".to_string(), + actual: state.status.to_string(), + }); + } + + state.status = MechanismStatus::Distributing; + MECHANISM_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "activate_distribution") + .add_attribute("status", "Distributing")) +} + +fn execute_commit_stability( + deps: DepsMut, + env: Env, + info: MessageInfo, + lock_months: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let state = MECHANISM_STATE.load(deps.storage)?; + + // Must be at least Tracking + if state.status == MechanismStatus::Inactive { + return Err(ContractError::InvalidMechanismStatus { + expected: "Tracking or Distributing".to_string(), + actual: state.status.to_string(), + }); + } + + // Validate lock_months + if lock_months < config.min_lock_months || lock_months > config.max_lock_months { + return Err(ContractError::InvalidLockMonths { + months: lock_months, + min: config.min_lock_months, + max: config.max_lock_months, + }); + } + + // Validate attached funds + let amount = extract_single_coin(&info, &config.denom)?; + if amount < config.min_commitment_amount { + return Err(ContractError::BelowMinCommitment { + amount: amount.to_string(), + min: config.min_commitment_amount.to_string(), + }); + } + + let id = NEXT_COMMITMENT_ID.load(deps.storage)?; + let committed_at = env.block.time; + let matures_at = + Timestamp::from_seconds(committed_at.seconds() + lock_months * SECONDS_PER_MONTH); + + let commitment = StabilityCommitment { + id, + holder: info.sender.clone(), + amount, + lock_months, + committed_at, + matures_at, + accrued_rewards: Uint128::zero(), + status: CommitmentStatus::Committed, + }; + + COMMITMENTS.save(deps.storage, id, &commitment)?; + NEXT_COMMITMENT_ID.save(deps.storage, &(id + 1))?; + + // Maintain running total of committed principal + let mut state = MECHANISM_STATE.load(deps.storage)?; + state.total_committed_principal += amount; + MECHANISM_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "commit_stability") + .add_attribute("commitment_id", id.to_string()) + .add_attribute("holder", info.sender) + .add_attribute("amount", amount) + .add_attribute("lock_months", lock_months.to_string()) + .add_attribute("matures_at", matures_at.seconds().to_string())) +} + +fn execute_exit_early( + deps: DepsMut, + _env: Env, + info: MessageInfo, + commitment_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut commitment = COMMITMENTS + .load(deps.storage, commitment_id) + .map_err(|_| ContractError::CommitmentNotFound { id: commitment_id })?; + + // Must be the holder + if commitment.holder != info.sender { + return Err(ContractError::Unauthorized { + reason: "only the commitment holder can exit".to_string(), + }); + } + + // Must be in Committed status + if commitment.status != CommitmentStatus::Committed { + return Err(ContractError::InvalidCommitmentStatus { + id: commitment_id, + expected: "Committed".to_string(), + }); + } + + // Calculate penalty: forfeit early_exit_penalty_bps of accrued rewards + let penalty = commitment + .accrued_rewards + .multiply_ratio(config.early_exit_penalty_bps as u128, 10_000u128); + let remaining_rewards = commitment.accrued_rewards - penalty; + let total_return = commitment.amount + remaining_rewards; + + commitment.status = CommitmentStatus::EarlyExit; + COMMITMENTS.save(deps.storage, commitment_id, &commitment)?; + + // Decrement running total of committed principal + let mut state = MECHANISM_STATE.load(deps.storage)?; + state.total_committed_principal = state + .total_committed_principal + .checked_sub(commitment.amount) + .unwrap_or(Uint128::zero()); + MECHANISM_STATE.save(deps.storage, &state)?; + + let mut msgs = vec![]; + if !total_return.is_zero() { + msgs.push(BankMsg::Send { + to_address: commitment.holder.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: total_return, + }], + }); + } + + Ok(Response::new() + .add_messages(msgs) + .add_attribute("action", "exit_stability_early") + .add_attribute("commitment_id", commitment_id.to_string()) + .add_attribute("returned", total_return) + .add_attribute("penalty", penalty) + .add_attribute("forfeited_rewards", penalty)) +} + +fn execute_claim_matured( + deps: DepsMut, + env: Env, + info: MessageInfo, + commitment_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut commitment = COMMITMENTS + .load(deps.storage, commitment_id) + .map_err(|_| ContractError::CommitmentNotFound { id: commitment_id })?; + + // Must be the holder + if commitment.holder != info.sender { + return Err(ContractError::Unauthorized { + reason: "only the commitment holder can claim".to_string(), + }); + } + + // Must be Committed (not already exited or claimed) + if commitment.status != CommitmentStatus::Committed { + return Err(ContractError::InvalidCommitmentStatus { + id: commitment_id, + expected: "Committed".to_string(), + }); + } + + // Must have matured + if env.block.time < commitment.matures_at { + return Err(ContractError::CommitmentNotMatured { + id: commitment_id, + matures_at: commitment.matures_at.seconds().to_string(), + }); + } + + let total_return = commitment.amount + commitment.accrued_rewards; + commitment.status = CommitmentStatus::Matured; + COMMITMENTS.save(deps.storage, commitment_id, &commitment)?; + + // Decrement running total of committed principal + let mut state = MECHANISM_STATE.load(deps.storage)?; + state.total_committed_principal = state + .total_committed_principal + .checked_sub(commitment.amount) + .unwrap_or(Uint128::zero()); + MECHANISM_STATE.save(deps.storage, &state)?; + + let mut msgs = vec![]; + if !total_return.is_zero() { + msgs.push(BankMsg::Send { + to_address: commitment.holder.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: total_return, + }], + }); + } + + Ok(Response::new() + .add_messages(msgs) + .add_attribute("action", "claim_matured_stability") + .add_attribute("commitment_id", commitment_id.to_string()) + .add_attribute("returned_principal", commitment.amount) + .add_attribute("returned_rewards", commitment.accrued_rewards) + .add_attribute("total", total_return)) +} + +fn execute_record_activity( + deps: DepsMut, + info: MessageInfo, + participant: String, + credit_purchase_value: Uint128, + credit_retirement_value: Uint128, + platform_facilitation_value: Uint128, + governance_votes: u32, + proposal_credits: u32, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let state = MECHANISM_STATE.load(deps.storage)?; + if state.status == MechanismStatus::Inactive { + return Err(ContractError::InvalidMechanismStatus { + expected: "Tracking or Distributing".to_string(), + actual: state.status.to_string(), + }); + } + + let participant_addr = deps.api.addr_validate(&participant)?; + let period = state.current_period; + + // Load existing or create new + let mut score = ACTIVITY_SCORES + .may_load(deps.storage, (period, &participant_addr))? + .unwrap_or(ActivityScore { + address: participant.clone(), + period, + ..Default::default() + }); + + // Accumulate (additive within a period) + score.credit_purchase_value += credit_purchase_value; + score.credit_retirement_value += credit_retirement_value; + score.platform_facilitation_value += platform_facilitation_value; + score.governance_votes += governance_votes; + score.proposal_credits += proposal_credits; + + ACTIVITY_SCORES.save(deps.storage, (period, &participant_addr), &score)?; + + Ok(Response::new() + .add_attribute("action", "record_activity") + .add_attribute("participant", participant) + .add_attribute("period", period.to_string())) +} + +fn execute_trigger_distribution( + deps: DepsMut, + env: Env, + info: MessageInfo, + community_pool_inflow: Uint128, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + if community_pool_inflow.is_zero() { + return Err(ContractError::ZeroInflow); + } + + let mut state = MECHANISM_STATE.load(deps.storage)?; + if state.status != MechanismStatus::Distributing { + return Err(ContractError::InvalidMechanismStatus { + expected: "Distributing".to_string(), + actual: state.status.to_string(), + }); + } + + let period = state.current_period; + + // Check not already distributed for this period + if DISTRIBUTIONS.may_load(deps.storage, period)?.is_some() { + return Err(ContractError::AlreadyDistributed { period }); + } + + // ── Step 1: Calculate stability allocation ── + let stability_allocation = calculate_stability_allocation(deps.storage, &config, &env, community_pool_inflow)?; + let activity_pool = community_pool_inflow - stability_allocation; + + // ── Step 2: Accrue stability rewards to commitments ── + accrue_stability_rewards(deps.storage, stability_allocation)?; + + // ── Step 3: Calculate total weighted score for all participants ── + let (total_score, participant_scores) = + calculate_period_scores(deps.as_ref(), &config, period)?; + + // ── Step 4: Accumulate activity rewards into claimable balances (pull model) ── + if !activity_pool.is_zero() && !total_score.is_zero() { + for (addr_str, score) in &participant_scores { + let reward = activity_pool.multiply_ratio(score.u128(), total_score.u128()); + if !reward.is_zero() { + let addr = deps.api.addr_validate(addr_str)?; + let existing = CLAIMABLE_REWARDS + .may_load(deps.storage, &addr)? + .unwrap_or(Uint128::zero()); + CLAIMABLE_REWARDS.save(deps.storage, &addr, &(existing + reward))?; + } + } + } + + // ── Step 5: Record distribution ── + let record = DistributionRecord { + period, + community_pool_inflow, + stability_allocation, + activity_pool, + total_score, + executed_at: env.block.time, + }; + DISTRIBUTIONS.save(deps.storage, period, &record)?; + + // Advance period + state.last_distribution_period = Some(period); + state.current_period = period + 1; + MECHANISM_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "trigger_distribution") + .add_attribute("period", period.to_string()) + .add_attribute("community_pool_inflow", community_pool_inflow) + .add_attribute("stability_allocation", stability_allocation) + .add_attribute("activity_pool", activity_pool) + .add_attribute("total_score", total_score) + .add_attribute("participants", participant_scores.len().to_string())) +} + +fn execute_claim_rewards( + deps: DepsMut, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let claimable = CLAIMABLE_REWARDS + .may_load(deps.storage, &info.sender)? + .unwrap_or(Uint128::zero()); + + if claimable.is_zero() { + return Err(ContractError::NoClaimableRewards { + address: info.sender.to_string(), + }); + } + + // Zero out the balance before sending + CLAIMABLE_REWARDS.save(deps.storage, &info.sender, &Uint128::zero())?; + + let msg = BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: claimable, + }], + }; + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "claim_rewards") + .add_attribute("address", info.sender.to_string()) + .add_attribute("amount", claimable)) +} + +#[allow(clippy::too_many_arguments)] +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + community_pool_addr: Option, + credit_purchase_weight: Option, + credit_retirement_weight: Option, + platform_facilitation_weight: Option, + governance_voting_weight: Option, + proposal_submission_weight: Option, + stability_annual_return_bps: Option, + max_stability_share_bps: Option, + min_commitment_amount: Option, + min_lock_months: Option, + max_lock_months: Option, + early_exit_penalty_bps: Option, + period_seconds: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + if let Some(addr) = community_pool_addr { + config.community_pool_addr = deps.api.addr_validate(&addr)?; + } + if let Some(v) = credit_purchase_weight { + config.credit_purchase_weight = v; + } + if let Some(v) = credit_retirement_weight { + config.credit_retirement_weight = v; + } + if let Some(v) = platform_facilitation_weight { + config.platform_facilitation_weight = v; + } + if let Some(v) = governance_voting_weight { + config.governance_voting_weight = v; + } + if let Some(v) = proposal_submission_weight { + config.proposal_submission_weight = v; + } + if let Some(v) = stability_annual_return_bps { + config.stability_annual_return_bps = v; + } + if let Some(v) = max_stability_share_bps { + config.max_stability_share_bps = v; + } + if let Some(v) = min_commitment_amount { + config.min_commitment_amount = v; + } + if let Some(v) = min_lock_months { + config.min_lock_months = v; + } + if let Some(v) = max_lock_months { + config.max_lock_months = v; + } + if let Some(v) = early_exit_penalty_bps { + config.early_exit_penalty_bps = v; + } + if let Some(v) = period_seconds { + config.period_seconds = v; + } + + // Validate weights sum to 10_000 + let weight_sum = config.credit_purchase_weight + + config.credit_retirement_weight + + config.platform_facilitation_weight + + config.governance_voting_weight + + config.proposal_submission_weight; + if weight_sum != 10_000 { + return Err(ContractError::InvalidWeightSum { sum: weight_sum }); + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// ── Internal helpers ─────────────────────────────────────────────────── + +fn require_admin(config: &Config, info: &MessageInfo) -> Result<(), ContractError> { + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "only admin can perform this action".to_string(), + }); + } + Ok(()) +} + +/// Extract a single coin of the expected denom from message funds +fn extract_single_coin(info: &MessageInfo, expected_denom: &str) -> Result { + if info.funds.is_empty() { + return Err(ContractError::NoFundsAttached); + } + let coin = info + .funds + .iter() + .find(|c| c.denom == expected_denom) + .ok_or_else(|| ContractError::WrongDenom { + expected: expected_denom.to_string(), + got: info.funds[0].denom.clone(), + })?; + Ok(coin.amount) +} + +/// Calculate how much of the inflow goes to stability tier. +/// Pro-rate 6% annual across all active commitments, capped at max_stability_share_bps of inflow. +/// Uses the `total_committed_principal` counter instead of scanning all commitments. +fn calculate_stability_allocation( + storage: &dyn cosmwasm_std::Storage, + config: &Config, + _env: &Env, + inflow: Uint128, +) -> Result { + let state = MECHANISM_STATE.load(storage)?; + let total_committed = state.total_committed_principal; + + if total_committed.is_zero() { + return Ok(Uint128::zero()); + } + + // Annual return pro-rated to one period: (annual_bps / 10_000) * (period_seconds / seconds_per_year) + // stability_owed = total_committed * annual_bps * period_seconds / (10_000 * 31_556_926) + let seconds_per_year: u128 = 31_556_926; + let stability_owed = total_committed + .multiply_ratio( + config.stability_annual_return_bps as u128 * config.period_seconds as u128, + 10_000u128 * seconds_per_year, + ); + + // Cap at max_stability_share_bps of inflow + let max_stability = inflow.multiply_ratio(config.max_stability_share_bps as u128, 10_000u128); + let allocation = std::cmp::min(stability_owed, max_stability); + + Ok(allocation) +} + +/// Distribute stability_allocation pro-rata across all active commitments +fn accrue_stability_rewards( + storage: &mut dyn cosmwasm_std::Storage, + stability_allocation: Uint128, +) -> Result<(), ContractError> { + if stability_allocation.is_zero() { + return Ok(()); + } + + // Collect all active commitments and their amounts + let active: Vec<(u64, Uint128)> = COMMITMENTS + .range(storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .filter(|(_, c)| c.status == CommitmentStatus::Committed) + .map(|(id, c)| (id, c.amount)) + .collect(); + + let total: Uint128 = active.iter().map(|(_, a)| *a).fold(Uint128::zero(), |acc, a| acc + a); + if total.is_zero() { + return Ok(()); + } + + for (id, amount) in &active { + let share = stability_allocation.multiply_ratio(amount.u128(), total.u128()); + let mut commitment = COMMITMENTS.load(storage, *id)?; + commitment.accrued_rewards += share; + COMMITMENTS.save(storage, *id, &commitment)?; + } + + Ok(()) +} + +/// Calculate weighted scores for all participants in a period. +/// Returns (total_score, vec of (addr, individual_score)). +fn calculate_period_scores( + deps: Deps, + config: &Config, + period: u32, +) -> Result<(Uint128, Vec<(String, Uint128)>), ContractError> { + let mut total = Uint128::zero(); + let mut participants: Vec<(String, Uint128)> = vec![]; + + // Find the max values for normalization of governance fields + let mut max_votes: u32 = 0; + let mut max_proposals: u32 = 0; + let scores: Vec = ACTIVITY_SCORES + .prefix(period) + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(_, s)| { + if s.governance_votes > max_votes { + max_votes = s.governance_votes; + } + if s.proposal_credits > max_proposals { + max_proposals = s.proposal_credits; + } + s + }) + .collect(); + + for score in &scores { + let weighted = calculate_weighted_score(config, score, max_votes, max_proposals); + total += weighted; + participants.push((score.address.clone(), weighted)); + } + + Ok((total, participants)) +} + +/// Calculate a single participant's weighted score. +/// Monetary fields are used directly (micro-denom units). +/// Count fields (votes, proposals) are normalized against the period max, then scaled to a reference value. +fn calculate_weighted_score( + config: &Config, + score: &ActivityScore, + max_votes: u32, + max_proposals: u32, +) -> Uint128 { + // Monetary components: value * weight / 10_000 + let purchase = score + .credit_purchase_value + .multiply_ratio(config.credit_purchase_weight as u128, 10_000u128); + let retirement = score + .credit_retirement_value + .multiply_ratio(config.credit_retirement_weight as u128, 10_000u128); + let facilitation = score + .platform_facilitation_value + .multiply_ratio(config.platform_facilitation_weight as u128, 10_000u128); + + // Count components: normalize to 0-1_000_000 range, then apply weight + let reference_scale: u128 = 1_000_000; + + let vote_normalized = if max_votes > 0 { + Uint128::new(score.governance_votes as u128) + .multiply_ratio(reference_scale, max_votes as u128) + } else { + Uint128::zero() + }; + let vote_weighted = + vote_normalized.multiply_ratio(config.governance_voting_weight as u128, 10_000u128); + + // proposal_credits is scaled by 100 (100 = 1.0) + let proposal_normalized = if max_proposals > 0 { + Uint128::new(score.proposal_credits as u128) + .multiply_ratio(reference_scale, max_proposals as u128) + } else { + Uint128::zero() + }; + let proposal_weighted = + proposal_normalized.multiply_ratio(config.proposal_submission_weight as u128, 10_000u128); + + purchase + retirement + facilitation + vote_weighted + proposal_weighted +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::MechanismState {} => to_json_binary(&query_mechanism_state(deps)?), + QueryMsg::ActivityScore { address, period } => { + to_json_binary(&query_activity_score(deps, address, period)?) + } + QueryMsg::StabilityCommitment { commitment_id } => { + to_json_binary(&query_stability_commitment(deps, commitment_id)?) + } + QueryMsg::DistributionRecord { period } => { + to_json_binary(&query_distribution_record(deps, period)?) + } + QueryMsg::ParticipantRewards { + address, + period_from, + period_to, + } => to_json_binary(&query_participant_rewards( + deps, + address, + period_from, + period_to, + )?), + QueryMsg::ClaimableRewards { address } => { + to_json_binary(&query_claimable_rewards(deps, address)?) + } + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + admin: config.admin.to_string(), + community_pool_addr: config.community_pool_addr.to_string(), + denom: config.denom, + credit_purchase_weight: config.credit_purchase_weight, + credit_retirement_weight: config.credit_retirement_weight, + platform_facilitation_weight: config.platform_facilitation_weight, + governance_voting_weight: config.governance_voting_weight, + proposal_submission_weight: config.proposal_submission_weight, + stability_annual_return_bps: config.stability_annual_return_bps, + max_stability_share_bps: config.max_stability_share_bps, + min_commitment_amount: config.min_commitment_amount, + min_lock_months: config.min_lock_months, + max_lock_months: config.max_lock_months, + early_exit_penalty_bps: config.early_exit_penalty_bps, + period_seconds: config.period_seconds, + }) +} + +fn query_mechanism_state(deps: Deps) -> StdResult { + let state = MECHANISM_STATE.load(deps.storage)?; + Ok(MechanismStateResponse { + status: state.status.to_string(), + tracking_start: state.tracking_start, + current_period: state.current_period, + last_distribution_period: state.last_distribution_period, + }) +} + +fn query_activity_score(deps: Deps, address: String, period: u32) -> StdResult { + let addr = deps.api.addr_validate(&address)?; + let score = ACTIVITY_SCORES + .may_load(deps.storage, (period, &addr))? + .unwrap_or(ActivityScore { + address: address.clone(), + period, + ..Default::default() + }); + Ok(ActivityScoreResponse { score }) +} + +fn query_stability_commitment(deps: Deps, commitment_id: u64) -> StdResult { + let commitment = COMMITMENTS.load(deps.storage, commitment_id)?; + Ok(StabilityCommitmentResponse { commitment }) +} + +fn query_distribution_record(deps: Deps, period: u32) -> StdResult { + let record = DISTRIBUTIONS.load(deps.storage, period)?; + Ok(DistributionRecordResponse { record }) +} + +fn query_participant_rewards( + deps: Deps, + address: String, + period_from: u32, + period_to: u32, +) -> StdResult { + let addr = deps.api.addr_validate(&address)?; + let mut total_activity_rewards = Uint128::zero(); + let mut active_periods = 0u32; + + for period in period_from..=period_to { + let score_opt = ACTIVITY_SCORES.may_load(deps.storage, (period, &addr))?; + let dist_opt = DISTRIBUTIONS.may_load(deps.storage, period)?; + + if let (Some(score), Some(dist)) = (score_opt, dist_opt) { + if !dist.total_score.is_zero() && !dist.activity_pool.is_zero() { + let config = CONFIG.load(deps.storage)?; + + // Recalculate this participant's weighted score for the period + // We need the max values from all participants + let mut max_votes: u32 = 0; + let mut max_proposals: u32 = 0; + let all_scores: Vec = ACTIVITY_SCORES + .prefix(period) + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(_, s)| { + if s.governance_votes > max_votes { + max_votes = s.governance_votes; + } + if s.proposal_credits > max_proposals { + max_proposals = s.proposal_credits; + } + s + }) + .collect(); + + // Find this participant's score in all_scores + let _participant_score_entry = all_scores.iter().find(|s| s.address == address); + + let weighted = calculate_weighted_score(&config, &score, max_votes, max_proposals); + let reward = dist.activity_pool.multiply_ratio(weighted.u128(), dist.total_score.u128()); + total_activity_rewards += reward; + active_periods += 1; + } + } + } + + Ok(ParticipantRewardsResponse { + address, + period_from, + period_to, + total_activity_rewards, + active_periods, + }) +} + +fn query_claimable_rewards(deps: Deps, address: String) -> StdResult { + let addr = deps.api.addr_validate(&address)?; + let amount = CLAIMABLE_REWARDS + .may_load(deps.storage, &addr)? + .unwrap_or(Uint128::zero()); + Ok(ClaimableRewardsResponse { address, amount }) +} + +// ── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{Addr, Coin, Uint128}; + + const DENOM: &str = "uregen"; + + fn addr(input: &str) -> Addr { + MockApi::default().addr_make(input) + } + + fn setup_contract(deps: DepsMut) -> MessageInfo { + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + community_pool_addr: addr("community_pool").to_string(), + denom: DENOM.to_string(), + }; + instantiate(deps, mock_env(), info.clone(), msg).unwrap(); + info + } + + fn initialize_mechanism(deps: DepsMut, admin: &Addr) { + let info = message_info(admin, &[]); + execute(deps, mock_env(), info, ExecuteMsg::InitializeMechanism {}).unwrap(); + } + + fn activate_distribution(deps: DepsMut, admin: &Addr) { + let info = message_info(admin, &[]); + execute(deps, mock_env(), info, ExecuteMsg::ActivateDistribution {}).unwrap(); + } + + // ── Test 1: Instantiate + Initialize ── + + #[test] + fn test_instantiate_and_initialize() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + + // Query config — verify defaults + let config: ConfigResponse = + cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()) + .unwrap(); + assert_eq!(config.admin, admin.to_string()); + assert_eq!(config.denom, DENOM); + assert_eq!(config.credit_purchase_weight, 3000); + assert_eq!(config.credit_retirement_weight, 3000); + assert_eq!(config.platform_facilitation_weight, 2000); + assert_eq!(config.governance_voting_weight, 1000); + assert_eq!(config.proposal_submission_weight, 1000); + assert_eq!(config.stability_annual_return_bps, 600); + assert_eq!(config.max_stability_share_bps, 3000); + assert_eq!(config.min_commitment_amount, Uint128::new(100_000_000)); + assert_eq!(config.min_lock_months, 6); + assert_eq!(config.max_lock_months, 24); + assert_eq!(config.early_exit_penalty_bps, 5000); + assert_eq!(config.period_seconds, 604_800); + + // Query state — should be Inactive + let state: MechanismStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::MechanismState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state.status, "Inactive"); + assert_eq!(state.current_period, 0); + + // Initialize + initialize_mechanism(deps.as_mut(), &admin); + + // Query state — should be Tracking, period 1 + let state: MechanismStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::MechanismState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state.status, "Tracking"); + assert_eq!(state.current_period, 1); + assert!(state.tracking_start.is_some()); + + // Double init should fail + let info = message_info(&admin, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::InitializeMechanism {}, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::AlreadyInitialized)); + } + + // ── Test 2: Commit stability ── + + #[test] + fn test_commit_stability() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + + let holder = addr("holder1"); + let commit_amount = Uint128::new(200_000_000); // 200 REGEN + + // Commit for 12 months + let info = message_info(&holder, &[Coin::new(commit_amount.u128(), DENOM)]); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 12 }, + ) + .unwrap(); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "commitment_id") + .unwrap() + .value, + "1" + ); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "lock_months") + .unwrap() + .value, + "12" + ); + + // Query commitment + let commitment: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::StabilityCommitment { commitment_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(commitment.commitment.holder, holder); + assert_eq!(commitment.commitment.amount, commit_amount); + assert_eq!(commitment.commitment.lock_months, 12); + assert_eq!(commitment.commitment.accrued_rewards, Uint128::zero()); + assert_eq!(commitment.commitment.status, CommitmentStatus::Committed); + + // Maturity should be ~12 months later + let expected_maturity = mock_env().block.time.seconds() + 12 * SECONDS_PER_MONTH; + assert_eq!(commitment.commitment.matures_at.seconds(), expected_maturity); + + // Below minimum fails + let small_holder = addr("small_holder"); + let info = message_info(&small_holder, &[Coin::new(50_000_000u128, DENOM)]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 6 }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::BelowMinCommitment { .. })); + + // Invalid lock months (too short) + let info = message_info(&holder, &[Coin::new(200_000_000u128, DENOM)]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 3 }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidLockMonths { .. })); + + // Invalid lock months (too long) + let info = message_info(&holder, &[Coin::new(200_000_000u128, DENOM)]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 36 }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidLockMonths { .. })); + + // No funds fails + let info = message_info(&holder, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 12 }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::NoFundsAttached)); + } + + // ── Test 3: Record activity + trigger distribution ── + + #[test] + fn test_record_activity_and_distribute() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + activate_distribution(deps.as_mut(), &admin); + + let alice = addr("alice"); + let bob = addr("bob"); + + // Record activity for alice + let info = message_info(&admin, &[]); + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::RecordActivity { + participant: alice.to_string(), + credit_purchase_value: Uint128::new(1_000_000), + credit_retirement_value: Uint128::new(500_000), + platform_facilitation_value: Uint128::new(200_000), + governance_votes: 5, + proposal_credits: 100, // 1.0 + }, + ) + .unwrap(); + + // Record activity for bob + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::RecordActivity { + participant: bob.to_string(), + credit_purchase_value: Uint128::new(500_000), + credit_retirement_value: Uint128::new(250_000), + platform_facilitation_value: Uint128::new(100_000), + governance_votes: 3, + proposal_credits: 50, // 0.5 + }, + ) + .unwrap(); + + // Query alice's activity + let score: ActivityScoreResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ActivityScore { + address: alice.to_string(), + period: 1, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(score.score.credit_purchase_value, Uint128::new(1_000_000)); + assert_eq!(score.score.governance_votes, 5); + + // Trigger distribution with 10_000_000 inflow (no stability commitments, so all goes to activity) + let inflow = Uint128::new(10_000_000); + let res = execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::TriggerDistribution { + community_pool_inflow: inflow, + }, + ) + .unwrap(); + + // Stability allocation should be 0 (no commitments) + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "stability_allocation") + .unwrap() + .value, + "0" + ); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "activity_pool") + .unwrap() + .value, + "10000000" + ); + + // Pull model: distribution should NOT send bank messages + assert_eq!(res.messages.len(), 0); + + // Both participants should have claimable rewards + let alice_claimable: ClaimableRewardsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ClaimableRewards { + address: alice.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert!( + alice_claimable.amount > Uint128::zero(), + "alice should have claimable rewards" + ); + + let bob_claimable: ClaimableRewardsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ClaimableRewards { + address: bob.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert!( + bob_claimable.amount > Uint128::zero(), + "bob should have claimable rewards" + ); + + // Total claimable should equal the full inflow (no stability commitments) + // Allow 1 unit of rounding error from pro-rata integer division + let total_claimable = alice_claimable.amount + bob_claimable.amount; + assert!( + total_claimable.u128().abs_diff(inflow.u128()) <= 1, + "total claimable {} should approximately equal inflow {}", + total_claimable, + inflow + ); + + // Alice should get more than Bob (higher activity scores) + assert!( + alice_claimable.amount > bob_claimable.amount, + "alice should get more than bob" + ); + + // Period should advance to 2 + let state: MechanismStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::MechanismState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state.current_period, 2); + assert_eq!(state.last_distribution_period, Some(1)); + + // Distribution record should exist + let dist: DistributionRecordResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::DistributionRecord { period: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(dist.record.community_pool_inflow, inflow); + assert_eq!(dist.record.stability_allocation, Uint128::zero()); + assert_eq!(dist.record.activity_pool, inflow); + + // Alice claims her rewards + let alice_info = message_info(&alice, &[]); + let claim_res = execute( + deps.as_mut(), + mock_env(), + alice_info, + ExecuteMsg::ClaimRewards {}, + ) + .unwrap(); + assert_eq!(claim_res.messages.len(), 1); // one BankMsg::Send + assert_eq!( + claim_res + .attributes + .iter() + .find(|a| a.key == "amount") + .unwrap() + .value, + alice_claimable.amount.to_string() + ); + + // After claiming, alice's claimable should be zero + let alice_after: ClaimableRewardsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ClaimableRewards { + address: alice.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(alice_after.amount, Uint128::zero()); + + // Claiming again should fail + let alice_info2 = message_info(&alice, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + alice_info2, + ExecuteMsg::ClaimRewards {}, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::NoClaimableRewards { .. })); + } + + // ── Test 4: Early exit penalty ── + + #[test] + fn test_early_exit_penalty() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + activate_distribution(deps.as_mut(), &admin); + + let holder = addr("holder1"); + let commit_amount = Uint128::new(500_000_000); // 500 REGEN + + // Commit + let info = message_info(&holder, &[Coin::new(commit_amount.u128(), DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 12 }, + ) + .unwrap(); + + // Record some activity so distribution has participants + let admin_info_no_funds = message_info(&admin, &[]); + let participant = addr("participant1"); + execute( + deps.as_mut(), + mock_env(), + admin_info_no_funds.clone(), + ExecuteMsg::RecordActivity { + participant: participant.to_string(), + credit_purchase_value: Uint128::new(1_000_000), + credit_retirement_value: Uint128::zero(), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap(); + + // Trigger distribution to accrue some stability rewards + execute( + deps.as_mut(), + mock_env(), + admin_info_no_funds.clone(), + ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(50_000_000), + }, + ) + .unwrap(); + + // Check accrued rewards + let commitment: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::StabilityCommitment { commitment_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + let accrued = commitment.commitment.accrued_rewards; + assert!(accrued > Uint128::zero(), "should have accrued some rewards"); + + // Early exit + let holder_info = message_info(&holder, &[]); + let res = execute( + deps.as_mut(), + mock_env(), + holder_info, + ExecuteMsg::ExitStabilityEarly { commitment_id: 1 }, + ) + .unwrap(); + + // Penalty = 50% of accrued + let expected_penalty = accrued.multiply_ratio(5000u128, 10_000u128); + let expected_return = commit_amount + accrued - expected_penalty; + + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "penalty") + .unwrap() + .value, + expected_penalty.to_string() + ); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "returned") + .unwrap() + .value, + expected_return.to_string() + ); + + // Should have bank message returning funds + assert_eq!(res.messages.len(), 1); + + // Commitment should be EarlyExit + let commitment: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::StabilityCommitment { commitment_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(commitment.commitment.status, CommitmentStatus::EarlyExit); + + // Can't exit again + let holder_info2 = message_info(&holder, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + holder_info2, + ExecuteMsg::ExitStabilityEarly { commitment_id: 1 }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidCommitmentStatus { .. })); + } + + // ── Test 5: Matured claim ── + + #[test] + fn test_matured_claim() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + activate_distribution(deps.as_mut(), &admin); + + let holder = addr("holder1"); + let commit_amount = Uint128::new(300_000_000); // 300 REGEN + + // Commit for 6 months (minimum) + let info = message_info(&holder, &[Coin::new(commit_amount.u128(), DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CommitStability { lock_months: 6 }, + ) + .unwrap(); + + // Record activity + distribute to accrue rewards + let admin_info_nf = message_info(&admin, &[]); + let participant = addr("p1"); + execute( + deps.as_mut(), + mock_env(), + admin_info_nf.clone(), + ExecuteMsg::RecordActivity { + participant: participant.to_string(), + credit_purchase_value: Uint128::new(1_000_000), + credit_retirement_value: Uint128::zero(), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + admin_info_nf.clone(), + ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(20_000_000), + }, + ) + .unwrap(); + + let commitment: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::StabilityCommitment { commitment_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + let accrued = commitment.commitment.accrued_rewards; + + // Try to claim before maturity — should fail + let holder_info = message_info(&holder, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + holder_info.clone(), + ExecuteMsg::ClaimMaturedStability { commitment_id: 1 }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::CommitmentNotMatured { .. })); + + // Fast-forward time past maturity (6 months + 1 day) + let mut future_env = mock_env(); + future_env.block.time = Timestamp::from_seconds( + mock_env().block.time.seconds() + 6 * SECONDS_PER_MONTH + 86_400, + ); + + // Claim matured + let res = execute( + deps.as_mut(), + future_env.clone(), + holder_info, + ExecuteMsg::ClaimMaturedStability { commitment_id: 1 }, + ) + .unwrap(); + + let expected_total = commit_amount + accrued; + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "total") + .unwrap() + .value, + expected_total.to_string() + ); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "returned_principal") + .unwrap() + .value, + commit_amount.to_string() + ); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "returned_rewards") + .unwrap() + .value, + accrued.to_string() + ); + + // Should be Matured + let commitment: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + future_env, + QueryMsg::StabilityCommitment { commitment_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(commitment.commitment.status, CommitmentStatus::Matured); + } + + // ── Test 6: Unauthorized checks ── + + #[test] + fn test_unauthorized_operations() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let stranger = addr("stranger"); + + // Non-admin cannot initialize + let info = message_info(&stranger, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::InitializeMechanism {}, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + + // Non-admin cannot record activity + let err = execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::RecordActivity { + participant: stranger.to_string(), + credit_purchase_value: Uint128::new(100), + credit_retirement_value: Uint128::zero(), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + + // Non-admin cannot trigger distribution + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(1_000_000), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + // ── Test 7: Distribution with stability + activity ── + + #[test] + fn test_distribution_with_stability_and_activity() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + activate_distribution(deps.as_mut(), &admin); + + // Two stability commitments + let holder_a = addr("holder_a"); + let holder_b = addr("holder_b"); + + let info_a = message_info(&holder_a, &[Coin::new(400_000_000u128, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info_a, + ExecuteMsg::CommitStability { lock_months: 12 }, + ) + .unwrap(); + + let info_b = message_info(&holder_b, &[Coin::new(600_000_000u128, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info_b, + ExecuteMsg::CommitStability { lock_months: 24 }, + ) + .unwrap(); + + // Activity participant + let alice = addr("alice"); + let admin_nf = message_info(&admin, &[]); + execute( + deps.as_mut(), + mock_env(), + admin_nf.clone(), + ExecuteMsg::RecordActivity { + participant: alice.to_string(), + credit_purchase_value: Uint128::new(2_000_000), + credit_retirement_value: Uint128::new(1_000_000), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap(); + + // Trigger distribution with 100M inflow + let inflow = Uint128::new(100_000_000); + let res = execute( + deps.as_mut(), + mock_env(), + admin_nf, + ExecuteMsg::TriggerDistribution { + community_pool_inflow: inflow, + }, + ) + .unwrap(); + + // Stability allocation should be > 0 + let stability_alloc: u128 = res + .attributes + .iter() + .find(|a| a.key == "stability_allocation") + .unwrap() + .value + .parse() + .unwrap(); + assert!(stability_alloc > 0, "stability allocation should be positive"); + + // Activity pool = inflow - stability + let activity_pool: u128 = res + .attributes + .iter() + .find(|a| a.key == "activity_pool") + .unwrap() + .value + .parse() + .unwrap(); + assert_eq!(activity_pool, inflow.u128() - stability_alloc); + + // Check that holder_a got 40% and holder_b got 60% of stability rewards + let c_a: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::StabilityCommitment { commitment_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + let c_b: StabilityCommitmentResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::StabilityCommitment { commitment_id: 2 }, + ) + .unwrap(), + ) + .unwrap(); + + // holder_b committed 1.5x holder_a, so should get proportionally more + assert!( + c_b.commitment.accrued_rewards > c_a.commitment.accrued_rewards, + "holder_b (600M) should accrue more than holder_a (400M)" + ); + // Total accrued should approximately equal stability_alloc + let total_accrued = c_a.commitment.accrued_rewards + c_b.commitment.accrued_rewards; + // Allow 1 unit of rounding error + assert!( + total_accrued.u128().abs_diff(stability_alloc) <= 1, + "total accrued {} should match stability allocation {}", + total_accrued, + stability_alloc + ); + } + + // ── Test 8: Update config weight validation ── + + #[test] + fn test_update_config_weight_validation() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + + // Valid update (weights still sum to 10_000) + let info = message_info(&admin, &[]); + let res = execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::UpdateConfig { + community_pool_addr: None, + credit_purchase_weight: Some(4000), + credit_retirement_weight: Some(2000), + platform_facilitation_weight: Some(2000), + governance_voting_weight: Some(1000), + proposal_submission_weight: Some(1000), + stability_annual_return_bps: None, + max_stability_share_bps: None, + min_commitment_amount: None, + min_lock_months: None, + max_lock_months: None, + early_exit_penalty_bps: None, + period_seconds: None, + }, + ); + assert!(res.is_ok()); + + // Invalid update (weights don't sum to 10_000) + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateConfig { + community_pool_addr: None, + credit_purchase_weight: Some(5000), + credit_retirement_weight: None, // stays 2000 from previous update + platform_facilitation_weight: None, + governance_voting_weight: None, + proposal_submission_weight: None, + stability_annual_return_bps: None, + max_stability_share_bps: None, + min_commitment_amount: None, + min_lock_months: None, + max_lock_months: None, + early_exit_penalty_bps: None, + period_seconds: None, + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidWeightSum { .. })); + } + + // ── Test 9: Total committed principal counter ── + + #[test] + fn test_total_committed_principal_counter() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + activate_distribution(deps.as_mut(), &admin); + + // Initially zero — read internal state directly since the query response + // doesn't expose total_committed_principal + let internal = MECHANISM_STATE.load(deps.as_ref().storage).unwrap(); + assert_eq!(internal.total_committed_principal, Uint128::zero()); + + // Commit 400 REGEN + let holder_a = addr("holder_a"); + let info_a = message_info(&holder_a, &[Coin::new(400_000_000u128, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info_a, + ExecuteMsg::CommitStability { lock_months: 12 }, + ) + .unwrap(); + + let internal = MECHANISM_STATE.load(deps.as_ref().storage).unwrap(); + assert_eq!( + internal.total_committed_principal, + Uint128::new(400_000_000) + ); + + // Commit 600 REGEN + let holder_b = addr("holder_b"); + let info_b = message_info(&holder_b, &[Coin::new(600_000_000u128, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info_b, + ExecuteMsg::CommitStability { lock_months: 6 }, + ) + .unwrap(); + + let internal = MECHANISM_STATE.load(deps.as_ref().storage).unwrap(); + assert_eq!( + internal.total_committed_principal, + Uint128::new(1_000_000_000) + ); + + // Early exit commitment 1 (400M) — counter decrements + let exit_info = message_info(&holder_a, &[]); + execute( + deps.as_mut(), + mock_env(), + exit_info, + ExecuteMsg::ExitStabilityEarly { commitment_id: 1 }, + ) + .unwrap(); + + let internal = MECHANISM_STATE.load(deps.as_ref().storage).unwrap(); + assert_eq!( + internal.total_committed_principal, + Uint128::new(600_000_000) + ); + + // Need activity participant for distribution to work + let p = addr("participant"); + let admin_nf = message_info(&admin, &[]); + execute( + deps.as_mut(), + mock_env(), + admin_nf.clone(), + ExecuteMsg::RecordActivity { + participant: p.to_string(), + credit_purchase_value: Uint128::new(1_000_000), + credit_retirement_value: Uint128::zero(), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap(); + + // Distribute so commitment 2 accrues some rewards + execute( + deps.as_mut(), + mock_env(), + admin_nf, + ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(10_000_000), + }, + ) + .unwrap(); + + // Claim matured commitment 2 (fast-forward time) + let mut future_env = mock_env(); + future_env.block.time = Timestamp::from_seconds( + mock_env().block.time.seconds() + 6 * SECONDS_PER_MONTH + 86_400, + ); + let claim_info = message_info(&holder_b, &[]); + execute( + deps.as_mut(), + future_env, + claim_info, + ExecuteMsg::ClaimMaturedStability { commitment_id: 2 }, + ) + .unwrap(); + + let internal = MECHANISM_STATE.load(deps.as_ref().storage).unwrap(); + assert_eq!( + internal.total_committed_principal, + Uint128::zero(), + "all commitments exited/claimed, counter should be zero" + ); + } + + // ── Test 10: Pull model claim rewards accumulation ── + + #[test] + fn test_claim_rewards_accumulates_across_periods() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let admin = admin_info.sender.clone(); + initialize_mechanism(deps.as_mut(), &admin); + activate_distribution(deps.as_mut(), &admin); + + let alice = addr("alice"); + let admin_nf = message_info(&admin, &[]); + + // Period 1: record activity + distribute + execute( + deps.as_mut(), + mock_env(), + admin_nf.clone(), + ExecuteMsg::RecordActivity { + participant: alice.to_string(), + credit_purchase_value: Uint128::new(1_000_000), + credit_retirement_value: Uint128::zero(), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + admin_nf.clone(), + ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(5_000_000), + }, + ) + .unwrap(); + + let after_p1: ClaimableRewardsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ClaimableRewards { + address: alice.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + let p1_amount = after_p1.amount; + assert!(p1_amount > Uint128::zero()); + + // Period 2: record activity + distribute (rewards should accumulate) + execute( + deps.as_mut(), + mock_env(), + admin_nf.clone(), + ExecuteMsg::RecordActivity { + participant: alice.to_string(), + credit_purchase_value: Uint128::new(2_000_000), + credit_retirement_value: Uint128::zero(), + platform_facilitation_value: Uint128::zero(), + governance_votes: 0, + proposal_credits: 0, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + admin_nf, + ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(8_000_000), + }, + ) + .unwrap(); + + let after_p2: ClaimableRewardsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ClaimableRewards { + address: alice.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + // Should have accumulated rewards from both periods + assert!( + after_p2.amount > p1_amount, + "claimable should accumulate: {} > {}", + after_p2.amount, + p1_amount + ); + + // Claim all accumulated rewards + let alice_info = message_info(&alice, &[]); + let res = execute( + deps.as_mut(), + mock_env(), + alice_info, + ExecuteMsg::ClaimRewards {}, + ) + .unwrap(); + assert_eq!(res.messages.len(), 1); + assert_eq!( + res.attributes + .iter() + .find(|a| a.key == "amount") + .unwrap() + .value, + after_p2.amount.to_string() + ); + + // Balance should be zero after claim + let after_claim: ClaimableRewardsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::ClaimableRewards { + address: alice.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(after_claim.amount, Uint128::zero()); + + // Claiming with nothing available should fail + let alice_info2 = message_info(&alice, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + alice_info2, + ExecuteMsg::ClaimRewards {}, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::NoClaimableRewards { .. })); + } +} diff --git a/contracts/contribution-rewards/src/error.rs b/contracts/contribution-rewards/src/error.rs new file mode 100644 index 0000000..d09d960 --- /dev/null +++ b/contracts/contribution-rewards/src/error.rs @@ -0,0 +1,62 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + #[error("Mechanism is not in expected status: expected {expected}, got {actual}")] + InvalidMechanismStatus { expected: String, actual: String }, + + #[error("Mechanism already initialized")] + AlreadyInitialized, + + #[error("Commitment {id} not found")] + CommitmentNotFound { id: u64 }, + + #[error("Commitment {id} is not in {expected} status")] + InvalidCommitmentStatus { id: u64, expected: String }, + + #[error("Commitment {id} has not matured yet (matures at {matures_at})")] + CommitmentNotMatured { id: u64, matures_at: String }, + + #[error("Insufficient funds: required {required}, sent {sent}")] + InsufficientFunds { required: String, sent: String }, + + #[error("Wrong denomination: expected {expected}, got {got}")] + WrongDenom { expected: String, got: String }, + + #[error("Lock months {months} out of range ({min}-{max})")] + InvalidLockMonths { months: u64, min: u64, max: u64 }, + + #[error("Amount {amount} below minimum commitment {min}")] + BelowMinCommitment { amount: String, min: String }, + + #[error("Activity weights must sum to 10000 bps, got {sum}")] + InvalidWeightSum { sum: u64 }, + + #[error("Distribution already executed for period {period}")] + AlreadyDistributed { period: u32 }, + + #[error("No activity recorded for period {period}")] + NoActivityForPeriod { period: u32 }, + + #[error("Distribution record not found for period {period}")] + DistributionNotFound { period: u32 }, + + #[error("Invalid period range: from {from} to {to}")] + InvalidPeriodRange { from: u32, to: u32 }, + + #[error("No funds attached")] + NoFundsAttached, + + #[error("Zero inflow amount")] + ZeroInflow, + + #[error("No claimable rewards for {address}")] + NoClaimableRewards { address: String }, +} diff --git a/contracts/contribution-rewards/src/lib.rs b/contracts/contribution-rewards/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/contribution-rewards/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/contribution-rewards/src/msg.rs b/contracts/contribution-rewards/src/msg.rs new file mode 100644 index 0000000..6772b58 --- /dev/null +++ b/contracts/contribution-rewards/src/msg.rs @@ -0,0 +1,171 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Timestamp, Uint128}; + +use crate::state::{ + ActivityScore, DistributionRecord, StabilityCommitment, +}; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cw_serde] +pub struct InstantiateMsg { + /// Community pool address (source of distribution funds) + pub community_pool_addr: String, + /// Accepted denomination (default "uregen") + pub denom: String, + // All other config fields use defaults; override via UpdateConfig +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cw_serde] +pub enum ExecuteMsg { + /// Admin: initialize the mechanism, begin tracking (status -> Tracking) + InitializeMechanism {}, + + /// Admin: activate distribution (status Tracking -> Distributing) + ActivateDistribution {}, + + /// Lock tokens for stability tier (must attach funds). + /// lock_months must be within min_lock_months..=max_lock_months. + CommitStability { lock_months: u64 }, + + /// Exit stability commitment early — forfeit penalty on accrued rewards + ExitStabilityEarly { commitment_id: u64 }, + + /// Claim matured stability commitment — full tokens + accrued rewards + ClaimMaturedStability { commitment_id: u64 }, + + /// Admin/module: record ecological/governance activity for a participant + RecordActivity { + participant: String, + credit_purchase_value: Uint128, + credit_retirement_value: Uint128, + platform_facilitation_value: Uint128, + governance_votes: u32, + proposal_credits: u32, + }, + + /// Admin: trigger distribution for current period with community pool inflow + TriggerDistribution { community_pool_inflow: Uint128 }, + + /// Claim accumulated activity rewards (pull model) + ClaimRewards {}, + + /// Admin: update configuration parameters + UpdateConfig { + community_pool_addr: Option, + credit_purchase_weight: Option, + credit_retirement_weight: Option, + platform_facilitation_weight: Option, + governance_voting_weight: Option, + proposal_submission_weight: Option, + stability_annual_return_bps: Option, + max_stability_share_bps: Option, + min_commitment_amount: Option, + min_lock_months: Option, + max_lock_months: Option, + early_exit_penalty_bps: Option, + period_seconds: Option, + }, +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns contract configuration + #[returns(ConfigResponse)] + Config {}, + + /// Returns mechanism lifecycle state + #[returns(MechanismStateResponse)] + MechanismState {}, + + /// Returns activity score for a participant in a given period + #[returns(ActivityScoreResponse)] + ActivityScore { address: String, period: u32 }, + + /// Returns a stability commitment by ID + #[returns(StabilityCommitmentResponse)] + StabilityCommitment { commitment_id: u64 }, + + /// Returns distribution record for a period + #[returns(DistributionRecordResponse)] + DistributionRecord { period: u32 }, + + /// Returns aggregate participant rewards across a range of periods + #[returns(ParticipantRewardsResponse)] + ParticipantRewards { + address: String, + period_from: u32, + period_to: u32, + }, + + /// Returns unclaimed activity rewards for a participant + #[returns(ClaimableRewardsResponse)] + ClaimableRewards { address: String }, +} + +// ── Query responses ──────────────────────────────────────────────────── + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub community_pool_addr: String, + pub denom: String, + pub credit_purchase_weight: u64, + pub credit_retirement_weight: u64, + pub platform_facilitation_weight: u64, + pub governance_voting_weight: u64, + pub proposal_submission_weight: u64, + pub stability_annual_return_bps: u64, + pub max_stability_share_bps: u64, + pub min_commitment_amount: Uint128, + pub min_lock_months: u64, + pub max_lock_months: u64, + pub early_exit_penalty_bps: u64, + pub period_seconds: u64, +} + +#[cw_serde] +pub struct MechanismStateResponse { + pub status: String, + pub tracking_start: Option, + pub current_period: u32, + pub last_distribution_period: Option, +} + +#[cw_serde] +pub struct ActivityScoreResponse { + pub score: ActivityScore, +} + +#[cw_serde] +pub struct StabilityCommitmentResponse { + pub commitment: StabilityCommitment, +} + +#[cw_serde] +pub struct DistributionRecordResponse { + pub record: DistributionRecord, +} + +#[cw_serde] +pub struct ParticipantRewardsResponse { + pub address: String, + pub period_from: u32, + pub period_to: u32, + /// Total weighted activity rewards earned across the range + pub total_activity_rewards: Uint128, + /// Number of periods with recorded activity + pub active_periods: u32, +} + +#[cw_serde] +pub struct ClaimableRewardsResponse { + pub address: String, + /// Accumulated unclaimed activity rewards + pub amount: Uint128, +} diff --git a/contracts/contribution-rewards/src/state.rs b/contracts/contribution-rewards/src/state.rs new file mode 100644 index 0000000..9b0134f --- /dev/null +++ b/contracts/contribution-rewards/src/state.rs @@ -0,0 +1,176 @@ +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ── Configuration ────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + /// Contract administrator + pub admin: Addr, + /// Community pool address (source of distribution funds) + pub community_pool_addr: Addr, + /// Accepted denomination + pub denom: String, + + // ── Activity weight parameters (basis points, must sum to 10_000) ── + /// Weight for credit purchases (default 3000 = 30%) + pub credit_purchase_weight: u64, + /// Weight for credit retirements (default 3000 = 30%) + pub credit_retirement_weight: u64, + /// Weight for platform facilitation (default 2000 = 20%) + pub platform_facilitation_weight: u64, + /// Weight for governance voting (default 1000 = 10%) + pub governance_voting_weight: u64, + /// Weight for proposal submissions (default 1000 = 10%) + pub proposal_submission_weight: u64, + + // ── Stability tier parameters ── + /// Annual return for stability commitments in bps (default 600 = 6%) + pub stability_annual_return_bps: u64, + /// Maximum share of community pool inflow allocated to stability in bps (default 3000 = 30%) + pub max_stability_share_bps: u64, + /// Minimum commitment amount in micro-denom (default 100_000_000 = 100 REGEN) + pub min_commitment_amount: Uint128, + /// Minimum lock duration in months (default 6) + pub min_lock_months: u64, + /// Maximum lock duration in months (default 24) + pub max_lock_months: u64, + /// Penalty on accrued rewards for early exit in bps (default 5000 = 50%) + pub early_exit_penalty_bps: u64, + + // ── Period parameters ── + /// Distribution period length in seconds (default 604_800 = 7 days) + pub period_seconds: u64, +} + +// ── Mechanism lifecycle ──────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum MechanismStatus { + /// Contract instantiated but mechanism not yet initialized + Inactive, + /// Tracking activity, calibrating weights — no distributions yet + Tracking, + /// Fully active: tracking + distributing rewards + Distributing, +} + +impl std::fmt::Display for MechanismStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MechanismStatus::Inactive => write!(f, "Inactive"), + MechanismStatus::Tracking => write!(f, "Tracking"), + MechanismStatus::Distributing => write!(f, "Distributing"), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct MechanismState { + pub status: MechanismStatus, + /// When tracking started + pub tracking_start: Option, + /// Current period number (increments with each distribution) + pub current_period: u32, + /// Last period that had a distribution executed + pub last_distribution_period: Option, + /// Running total of committed principal across all active stability commitments. + /// Incremented on CommitStability, decremented on ExitStabilityEarly / ClaimMaturedStability. + pub total_committed_principal: Uint128, +} + +// ── Stability commitments ────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum CommitmentStatus { + /// Tokens locked, accruing rewards + Committed, + /// Lock period complete, ready to claim + Matured, + /// Exited before maturity with penalty + EarlyExit, +} + +impl std::fmt::Display for CommitmentStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CommitmentStatus::Committed => write!(f, "Committed"), + CommitmentStatus::Matured => write!(f, "Matured"), + CommitmentStatus::EarlyExit => write!(f, "EarlyExit"), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct StabilityCommitment { + pub id: u64, + pub holder: Addr, + pub amount: Uint128, + pub lock_months: u64, + pub committed_at: Timestamp, + pub matures_at: Timestamp, + /// Rewards accrued so far (updated on each distribution) + pub accrued_rewards: Uint128, + pub status: CommitmentStatus, +} + +// ── Activity tracking ────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, Default)] +pub struct ActivityScore { + /// Participant address (denormalized for query convenience) + pub address: String, + /// Period number + pub period: u32, + /// Value of credits purchased (in micro-denom) + pub credit_purchase_value: Uint128, + /// Value of credits retired (in micro-denom) + pub credit_retirement_value: Uint128, + /// Value of platform facilitation (in micro-denom) + pub platform_facilitation_value: Uint128, + /// Number of governance votes cast + pub governance_votes: u32, + /// Proposal credits (u32 scaled by 100 — so 100 = 1.0, 50 = 0.5) + pub proposal_credits: u32, +} + +// ── Distribution records ─────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct DistributionRecord { + pub period: u32, + /// Amount received from community pool for this period + pub community_pool_inflow: Uint128, + /// Amount allocated to stability tier + pub stability_allocation: Uint128, + /// Amount allocated to activity-based rewards + pub activity_pool: Uint128, + /// Total weighted activity score across all participants + pub total_score: Uint128, + /// When this distribution was executed + pub executed_at: Timestamp, +} + +// ── Storage keys ─────────────────────────────────────────────────────── + +pub const CONFIG: Item = Item::new("config"); +pub const MECHANISM_STATE: Item = Item::new("mechanism_state"); +pub const NEXT_COMMITMENT_ID: Item = Item::new("next_commitment_id"); + +/// Stability commitments by ID +pub const COMMITMENTS: Map = Map::new("commitments"); + +/// Activity scores indexed by (period, participant address) +pub const ACTIVITY_SCORES: Map<(u32, &Addr), ActivityScore> = Map::new("activity_scores"); + +/// Distribution records by period +pub const DISTRIBUTIONS: Map = Map::new("distributions"); + +/// Voter deduplication: (period, voter_address) -> bool +pub const VOTER_DEDUP: Map<(u32, &Addr), bool> = Map::new("voter_dedup"); + +/// Claimable activity rewards per participant (pull model). +/// Accumulated by trigger_distribution, drained by ClaimRewards. +pub const CLAIMABLE_REWARDS: Map<&Addr, Uint128> = Map::new("claimable_rewards"); diff --git a/contracts/credit-class-voting/Cargo.toml b/contracts/credit-class-voting/Cargo.toml new file mode 100644 index 0000000..85dd67c --- /dev/null +++ b/contracts/credit-class-voting/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "credit-class-voting" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true diff --git a/contracts/credit-class-voting/src/contract.rs b/contracts/credit-class-voting/src/contract.rs new file mode 100644 index 0000000..eecc486 --- /dev/null +++ b/contracts/credit-class-voting/src/contract.rs @@ -0,0 +1,1698 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Timestamp, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + ConfigResponse, ExecuteMsg, InstantiateMsg, ProposalResponse, ProposalsResponse, QueryMsg, +}; +use crate::state::{ + AgentRecommendation, Config, Proposal, ProposalStatus, CONFIG, NEXT_PROPOSAL_ID, PROPOSALS, + VOTES, +}; + +const CONTRACT_NAME: &str = "crates.io:credit-class-voting"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const DEFAULT_QUERY_LIMIT: u32 = 10; +const MAX_QUERY_LIMIT: u32 = 30; + +/// Default deposit: 1000 REGEN = 1_000_000_000 uregen +const DEFAULT_DEPOSIT: u128 = 1_000_000_000; +/// Default voting period: 7 days +const DEFAULT_VOTING_PERIOD: u64 = 604_800; +/// Default agent review timeout: 24 hours +const DEFAULT_AGENT_REVIEW_TIMEOUT: u64 = 86_400; +/// Default override window: 6 hours +const DEFAULT_OVERRIDE_WINDOW: u64 = 21_600; + +/// Slash 20% of deposit on rejection +const REJECT_SLASH_BPS: u128 = 2000; +/// Slash 5% of deposit on expiry +const EXPIRE_SLASH_BPS: u128 = 500; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let community_pool = msg + .community_pool + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + + let config = Config { + admin: info.sender.clone(), + registry_agent: deps.api.addr_validate(&msg.registry_agent)?, + deposit_amount: msg.deposit_amount.unwrap_or(Uint128::new(DEFAULT_DEPOSIT)), + denom: msg.denom.unwrap_or_else(|| "uregen".to_string()), + voting_period_seconds: msg.voting_period_seconds.unwrap_or(DEFAULT_VOTING_PERIOD), + agent_review_timeout_seconds: msg + .agent_review_timeout_seconds + .unwrap_or(DEFAULT_AGENT_REVIEW_TIMEOUT), + override_window_seconds: msg + .override_window_seconds + .unwrap_or(DEFAULT_OVERRIDE_WINDOW), + community_pool, + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &config)?; + NEXT_PROPOSAL_ID.save(deps.storage, &1u64)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::SubmitProposal { + admin_address, + credit_type, + methodology_iri, + } => execute_submit_proposal(deps, env, info, admin_address, credit_type, methodology_iri), + ExecuteMsg::SubmitAgentScore { + proposal_id, + score, + confidence, + recommendation, + } => execute_submit_agent_score(deps, env, info, proposal_id, score, confidence, recommendation), + ExecuteMsg::OverrideAgentReject { proposal_id } => { + execute_override_agent_reject(deps, env, info, proposal_id) + } + ExecuteMsg::CastVote { + proposal_id, + vote_yes, + } => execute_cast_vote(deps, env, info, proposal_id, vote_yes), + ExecuteMsg::FinalizeProposal { proposal_id } => { + execute_finalize_proposal(deps, env, info, proposal_id) + } + ExecuteMsg::UpdateConfig { + registry_agent, + deposit_amount, + voting_period_seconds, + agent_review_timeout_seconds, + override_window_seconds, + community_pool, + } => execute_update_config( + deps, + info, + registry_agent, + deposit_amount, + voting_period_seconds, + agent_review_timeout_seconds, + override_window_seconds, + community_pool, + ), + } +} + +// ── Submit Proposal ─────────────────────────────────────────────────── + +fn execute_submit_proposal( + deps: DepsMut, + env: Env, + info: MessageInfo, + admin_address: String, + credit_type: String, + methodology_iri: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Validate inputs + if credit_type.is_empty() { + return Err(ContractError::EmptyCreditType); + } + if methodology_iri.is_empty() { + return Err(ContractError::EmptyMethodologyIri); + } + + // Validate deposit + let sent = info + .funds + .iter() + .find(|c| c.denom == config.denom) + .map(|c| c.amount) + .unwrap_or(Uint128::zero()); + + if sent < config.deposit_amount { + return Err(ContractError::InsufficientFunds { + required: config.deposit_amount.to_string(), + sent: sent.to_string(), + }); + } + + // Check denom — reject if wrong denom sent + for coin in &info.funds { + if coin.denom != config.denom { + return Err(ContractError::WrongDenom { + expected: config.denom.clone(), + got: coin.denom.clone(), + }); + } + } + + let admin_addr = deps.api.addr_validate(&admin_address)?; + + let id = NEXT_PROPOSAL_ID.load(deps.storage)?; + NEXT_PROPOSAL_ID.save(deps.storage, &(id + 1))?; + + let proposal = Proposal { + id, + proposer: info.sender.clone(), + admin_address: admin_addr, + credit_type: credit_type.clone(), + methodology_iri, + status: ProposalStatus::AgentReview, + deposit_amount: config.deposit_amount, + created_at: env.block.time, + agent_score: None, + agent_confidence: None, + agent_recommendation: None, + agent_scored_at: None, + voting_ends_at: None, + yes_votes: 0, + no_votes: 0, + completed_at: None, + }; + + PROPOSALS.save(deps.storage, id, &proposal)?; + + Ok(Response::new() + .add_attribute("action", "submit_proposal") + .add_attribute("proposal_id", id.to_string()) + .add_attribute("proposer", info.sender) + .add_attribute("credit_type", credit_type) + .add_attribute("status", "AgentReview")) +} + +// ── Submit Agent Score ──────────────────────────────────────────────── + +fn execute_submit_agent_score( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, + score: u32, + confidence: u32, + recommendation: AgentRecommendation, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Only registry agent can submit scores + if info.sender != config.registry_agent { + return Err(ContractError::Unauthorized { + reason: "only registry agent can submit scores".to_string(), + }); + } + + // Validate score and confidence ranges + if score > 1000 { + return Err(ContractError::InvalidAgentScore { score }); + } + if confidence > 1000 { + return Err(ContractError::InvalidAgentConfidence { confidence }); + } + + let mut proposal = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::ProposalNotFound { id: proposal_id })?; + + // Must be in AgentReview status + if proposal.status != ProposalStatus::AgentReview { + return Err(ContractError::InvalidStatus { + expected: "AgentReview".to_string(), + actual: proposal.status.to_string(), + }); + } + + proposal.agent_score = Some(score); + proposal.agent_confidence = Some(confidence); + proposal.agent_recommendation = Some(recommendation.clone()); + proposal.agent_scored_at = Some(env.block.time); + + // Decision logic: + // score >= 700 → auto-advance to Voting + // score < 300 && confidence > 900 → auto-reject (with override window) + // else → advance to Voting + let action_detail = if score >= 700 { + proposal.status = ProposalStatus::Voting; + proposal.voting_ends_at = Some(Timestamp::from_seconds( + env.block.time.seconds() + config.voting_period_seconds, + )); + "auto_advance_high_score" + } else if score < 300 && confidence > 900 { + proposal.status = ProposalStatus::AutoRejected; + "auto_reject_low_score_high_confidence" + } else { + proposal.status = ProposalStatus::Voting; + proposal.voting_ends_at = Some(Timestamp::from_seconds( + env.block.time.seconds() + config.voting_period_seconds, + )); + "advance_to_voting" + }; + + PROPOSALS.save(deps.storage, proposal_id, &proposal)?; + + Ok(Response::new() + .add_attribute("action", "submit_agent_score") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("score", score.to_string()) + .add_attribute("confidence", confidence.to_string()) + .add_attribute("recommendation", recommendation.to_string()) + .add_attribute("result", action_detail) + .add_attribute("status", proposal.status.to_string())) +} + +// ── Override Agent Reject ───────────────────────────────────────────── + +fn execute_override_agent_reject( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Only admin can override + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "only admin can override agent rejections".to_string(), + }); + } + + let mut proposal = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::ProposalNotFound { id: proposal_id })?; + + // Must be AutoRejected + if proposal.status != ProposalStatus::AutoRejected { + return Err(ContractError::InvalidStatus { + expected: "AutoRejected".to_string(), + actual: proposal.status.to_string(), + }); + } + + // Must be within override window + let scored_at = proposal.agent_scored_at.ok_or(ContractError::InvalidStatus { + expected: "agent_scored_at set".to_string(), + actual: "agent_scored_at not set".to_string(), + })?; + let window_end = scored_at.seconds() + config.override_window_seconds; + if env.block.time.seconds() > window_end { + return Err(ContractError::OverrideWindowExpired); + } + + // Force to Voting + proposal.status = ProposalStatus::Voting; + proposal.voting_ends_at = Some(Timestamp::from_seconds( + env.block.time.seconds() + config.voting_period_seconds, + )); + + PROPOSALS.save(deps.storage, proposal_id, &proposal)?; + + Ok(Response::new() + .add_attribute("action", "override_agent_reject") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Voting")) +} + +// ── Cast Vote ───────────────────────────────────────────────────────── + +fn execute_cast_vote( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, + vote_yes: bool, +) -> Result { + let proposal = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::ProposalNotFound { id: proposal_id })?; + + // Must be in Voting status + if proposal.status != ProposalStatus::Voting { + return Err(ContractError::InvalidStatus { + expected: "Voting".to_string(), + actual: proposal.status.to_string(), + }); + } + + // Must be within voting period + if let Some(ends_at) = proposal.voting_ends_at { + if env.block.time.seconds() > ends_at.seconds() { + return Err(ContractError::VotingPeriodEnded); + } + } + + // Check for duplicate vote + if VOTES.may_load(deps.storage, (proposal_id, &info.sender))?.is_some() { + return Err(ContractError::AlreadyVoted { id: proposal_id }); + } + + // Record vote + VOTES.save(deps.storage, (proposal_id, &info.sender), &vote_yes)?; + + // Update tally + let mut proposal = proposal; + if vote_yes { + proposal.yes_votes += 1; + } else { + proposal.no_votes += 1; + } + + PROPOSALS.save(deps.storage, proposal_id, &proposal)?; + + Ok(Response::new() + .add_attribute("action", "cast_vote") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("voter", info.sender) + .add_attribute("vote", if vote_yes { "yes" } else { "no" }) + .add_attribute("yes_votes", proposal.yes_votes.to_string()) + .add_attribute("no_votes", proposal.no_votes.to_string())) +} + +// ── Finalize Proposal ───────────────────────────────────────────────── + +fn execute_finalize_proposal( + deps: DepsMut, + env: Env, + _info: MessageInfo, + proposal_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + let mut proposal = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::ProposalNotFound { id: proposal_id })?; + + let mut messages: Vec = vec![]; + + // Slash recipient: community_pool if configured, otherwise admin + let slash_recipient = config + .community_pool + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_else(|| config.admin.to_string()); + + match proposal.status { + ProposalStatus::Voting => { + // Must be after voting period + if let Some(ends_at) = proposal.voting_ends_at { + if env.block.time.seconds() <= ends_at.seconds() { + return Err(ContractError::VotingPeriodNotEnded); + } + } + + let total_votes = proposal.yes_votes + proposal.no_votes; + + if total_votes == 0 { + // No votes cast — treat as expired, slash 5% + let slash = proposal + .deposit_amount + .multiply_ratio(EXPIRE_SLASH_BPS, 10_000u128); + let refund = proposal.deposit_amount - slash; + + proposal.status = ProposalStatus::Expired; + proposal.completed_at = Some(env.block.time); + + if !refund.is_zero() { + messages.push(BankMsg::Send { + to_address: proposal.proposer.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: refund, + }], + }); + } + if !slash.is_zero() { + messages.push(BankMsg::Send { + to_address: slash_recipient.clone(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: slash, + }], + }); + } + } else if proposal.yes_votes > proposal.no_votes { + // Approved — refund full deposit + proposal.status = ProposalStatus::Approved; + proposal.completed_at = Some(env.block.time); + + messages.push(BankMsg::Send { + to_address: proposal.proposer.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: proposal.deposit_amount, + }], + }); + } else { + // Rejected — slash 20% + let slash = proposal + .deposit_amount + .multiply_ratio(REJECT_SLASH_BPS, 10_000u128); + let refund = proposal.deposit_amount - slash; + + proposal.status = ProposalStatus::Rejected; + proposal.completed_at = Some(env.block.time); + + if !refund.is_zero() { + messages.push(BankMsg::Send { + to_address: proposal.proposer.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: refund, + }], + }); + } + if !slash.is_zero() { + messages.push(BankMsg::Send { + to_address: slash_recipient.clone(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: slash, + }], + }); + } + } + } + ProposalStatus::AutoRejected => { + // Finalize an auto-rejected proposal after override window expires + let scored_at = proposal.agent_scored_at.ok_or(ContractError::InvalidStatus { + expected: "agent_scored_at set".to_string(), + actual: "agent_scored_at not set".to_string(), + })?; + let window_end = scored_at.seconds() + config.override_window_seconds; + if env.block.time.seconds() <= window_end { + return Err(ContractError::InvalidStatus { + expected: "override window expired".to_string(), + actual: "override window still active".to_string(), + }); + } + + // Slash 20% on auto-reject finalization + let slash = proposal + .deposit_amount + .multiply_ratio(REJECT_SLASH_BPS, 10_000u128); + let refund = proposal.deposit_amount - slash; + + proposal.status = ProposalStatus::Rejected; + proposal.completed_at = Some(env.block.time); + + if !refund.is_zero() { + messages.push(BankMsg::Send { + to_address: proposal.proposer.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: refund, + }], + }); + } + if !slash.is_zero() { + messages.push(BankMsg::Send { + to_address: slash_recipient.clone(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: slash, + }], + }); + } + } + _ => { + return Err(ContractError::InvalidStatus { + expected: "Voting or AutoRejected".to_string(), + actual: proposal.status.to_string(), + }); + } + } + + PROPOSALS.save(deps.storage, proposal_id, &proposal)?; + + let mut resp = Response::new() + .add_attribute("action", "finalize_proposal") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", proposal.status.to_string()); + + for msg in messages { + resp = resp.add_message(msg); + } + + Ok(resp) +} + +// ── Update Config ───────────────────────────────────────────────────── + +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + registry_agent: Option, + deposit_amount: Option, + voting_period_seconds: Option, + agent_review_timeout_seconds: Option, + override_window_seconds: Option, + community_pool: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "only admin can update config".to_string(), + }); + } + + if let Some(agent) = registry_agent { + config.registry_agent = deps.api.addr_validate(&agent)?; + } + if let Some(amount) = deposit_amount { + config.deposit_amount = amount; + } + if let Some(seconds) = voting_period_seconds { + config.voting_period_seconds = seconds; + } + if let Some(seconds) = agent_review_timeout_seconds { + config.agent_review_timeout_seconds = seconds; + } + if let Some(seconds) = override_window_seconds { + config.override_window_seconds = seconds; + } + if let Some(pool) = community_pool { + config.community_pool = Some(deps.api.addr_validate(&pool)?); + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// ── Query ───────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Proposal { proposal_id } => to_json_binary(&query_proposal(deps, proposal_id)?), + QueryMsg::Proposals { + status, + start_after, + limit, + } => to_json_binary(&query_proposals(deps, status, start_after, limit)?), + QueryMsg::ProposalsByAdmin { + admin_address, + start_after, + limit, + } => to_json_binary(&query_proposals_by_admin( + deps, + admin_address, + start_after, + limit, + )?), + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + admin: config.admin.to_string(), + registry_agent: config.registry_agent.to_string(), + deposit_amount: config.deposit_amount, + denom: config.denom, + voting_period_seconds: config.voting_period_seconds, + agent_review_timeout_seconds: config.agent_review_timeout_seconds, + override_window_seconds: config.override_window_seconds, + community_pool: config.community_pool.map(|a| a.to_string()), + }) +} + +fn query_proposal(deps: Deps, proposal_id: u64) -> StdResult { + let proposal = PROPOSALS.load(deps.storage, proposal_id)?; + Ok(ProposalResponse { proposal }) +} + +fn query_proposals( + deps: Deps, + status: Option, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| cw_storage_plus::Bound::exclusive(s)); + + let proposals: Vec = PROPOSALS + .range(deps.storage, start, None, Order::Ascending) + .filter_map(|item| { + let (_, proposal) = item.ok()?; + if let Some(ref s) = status { + if &proposal.status != s { + return None; + } + } + Some(proposal) + }) + .take(limit) + .collect(); + + Ok(ProposalsResponse { proposals }) +} + +fn query_proposals_by_admin( + deps: Deps, + admin_address: String, + start_after: Option, + limit: Option, +) -> StdResult { + let admin_addr = deps.api.addr_validate(&admin_address)?; + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| cw_storage_plus::Bound::exclusive(s)); + + let proposals: Vec = PROPOSALS + .range(deps.storage, start, None, Order::Ascending) + .filter_map(|item| { + let (_, proposal) = item.ok()?; + if proposal.admin_address != admin_addr { + return None; + } + Some(proposal) + }) + .take(limit) + .collect(); + + Ok(ProposalsResponse { proposals }) +} + +// ── Tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{Addr, Coin, Timestamp, Uint128}; + + const DENOM: &str = "uregen"; + const DEPOSIT: u128 = 1_000_000_000; + + fn addr(input: &str) -> Addr { + MockApi::default().addr_make(input) + } + + fn setup_contract(deps: DepsMut) -> MessageInfo { + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + registry_agent: addr("agent").to_string(), + deposit_amount: Some(Uint128::new(DEPOSIT)), + denom: Some(DENOM.to_string()), + voting_period_seconds: Some(604_800), + agent_review_timeout_seconds: Some(86_400), + override_window_seconds: Some(21_600), + community_pool: None, + }; + instantiate(deps, mock_env(), info.clone(), msg).unwrap(); + info + } + + fn submit_proposal(deps: DepsMut, proposer: &Addr) -> u64 { + let deposit_coins = vec![Coin::new(DEPOSIT, DENOM)]; + let info = message_info(proposer, &deposit_coins); + let msg = ExecuteMsg::SubmitProposal { + admin_address: proposer.to_string(), + credit_type: "C".to_string(), + methodology_iri: "regen:13toVfvC2YxrrfSXWB5h2BGHiC9iJ8j7kp9KXcPoPMKH3p4TvG3r8Fh".to_string(), + }; + let res = execute(deps, mock_env(), info, msg).unwrap(); + res.attributes + .iter() + .find(|a| a.key == "proposal_id") + .unwrap() + .value + .parse() + .unwrap() + } + + fn agent_score( + deps: DepsMut, + proposal_id: u64, + score: u32, + confidence: u32, + recommendation: AgentRecommendation, + ) -> Response { + let agent = addr("agent"); + let info = message_info(&agent, &[]); + let msg = ExecuteMsg::SubmitAgentScore { + proposal_id, + score, + confidence, + recommendation, + }; + execute(deps, mock_env(), info, msg).unwrap() + } + + fn env_at(seconds: u64) -> Env { + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(seconds); + env + } + + // ── Test 1: Instantiate ─────────────────────────────────────────── + + #[test] + fn test_instantiate() { + let mut deps = mock_dependencies(); + let info = setup_contract(deps.as_mut()); + + let config: ConfigResponse = + cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()) + .unwrap(); + assert_eq!(config.admin, info.sender.to_string()); + assert_eq!(config.registry_agent, addr("agent").to_string()); + assert_eq!(config.deposit_amount, Uint128::new(DEPOSIT)); + assert_eq!(config.denom, DENOM); + assert_eq!(config.voting_period_seconds, 604_800); + assert_eq!(config.agent_review_timeout_seconds, 86_400); + assert_eq!(config.override_window_seconds, 21_600); + } + + // ── Test 2: Submit Proposal ─────────────────────────────────────── + + #[test] + fn test_submit_proposal() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + assert_eq!(id, 1); + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::AgentReview); + assert_eq!(resp.proposal.proposer, proposer); + assert_eq!(resp.proposal.credit_type, "C"); + assert_eq!(resp.proposal.deposit_amount, Uint128::new(DEPOSIT)); + assert_eq!(resp.proposal.yes_votes, 0); + assert_eq!(resp.proposal.no_votes, 0); + } + + // ── Test 3: Agent Score → Auto-Advance to Voting ────────────────── + + #[test] + fn test_agent_score_auto_advance() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + // High score → auto advance to Voting + let res = agent_score( + deps.as_mut(), + id, + 800, + 950, + AgentRecommendation::Approve, + ); + assert!(res + .attributes + .iter() + .any(|a| a.key == "result" && a.value == "auto_advance_high_score")); + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Voting); + assert_eq!(resp.proposal.agent_score, Some(800)); + assert_eq!(resp.proposal.agent_confidence, Some(950)); + assert_eq!( + resp.proposal.agent_recommendation, + Some(AgentRecommendation::Approve) + ); + assert!(resp.proposal.voting_ends_at.is_some()); + } + + // ── Test 4: Agent Auto-Reject + Admin Override ──────────────────── + + #[test] + fn test_agent_auto_reject_and_override() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + // Low score + high confidence → auto-reject + let agent = addr("agent"); + let agent_info = message_info(&agent, &[]); + let score_msg = ExecuteMsg::SubmitAgentScore { + proposal_id: id, + score: 150, + confidence: 950, + recommendation: AgentRecommendation::Reject, + }; + + let base_time = mock_env().block.time.seconds(); + let mut env_now = mock_env(); + env_now.block.time = Timestamp::from_seconds(base_time); + execute(deps.as_mut(), env_now, agent_info, score_msg).unwrap(); + + // Verify auto-rejected + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::AutoRejected); + + // Non-admin cannot override + let rando = addr("rando"); + let rando_info = message_info(&rando, &[]); + let override_msg = ExecuteMsg::OverrideAgentReject { proposal_id: id }; + let err = execute( + deps.as_mut(), + env_at(base_time + 100), + rando_info, + override_msg, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + + // Admin override within 6h window + let admin = addr("admin"); + let admin_info = message_info(&admin, &[]); + let override_msg = ExecuteMsg::OverrideAgentReject { proposal_id: id }; + execute( + deps.as_mut(), + env_at(base_time + 100), + admin_info, + override_msg, + ) + .unwrap(); + + // Verify now in Voting + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Voting); + assert!(resp.proposal.voting_ends_at.is_some()); + } + + // ── Test 5: Voting + Finalize (Approval) ────────────────────────── + + #[test] + fn test_voting_and_finalize_approve() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + // Agent advances to voting + agent_score( + deps.as_mut(), + id, + 750, + 900, + AgentRecommendation::Approve, + ); + + let base_time = mock_env().block.time.seconds(); + + // Voter 1 votes yes + let voter1 = addr("voter1"); + let v1_info = message_info(&voter1, &[]); + execute( + deps.as_mut(), + env_at(base_time + 100), + v1_info, + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: true, + }, + ) + .unwrap(); + + // Voter 2 votes yes + let voter2 = addr("voter2"); + let v2_info = message_info(&voter2, &[]); + execute( + deps.as_mut(), + env_at(base_time + 200), + v2_info, + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: true, + }, + ) + .unwrap(); + + // Voter 3 votes no + let voter3 = addr("voter3"); + let v3_info = message_info(&voter3, &[]); + execute( + deps.as_mut(), + env_at(base_time + 300), + v3_info, + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: false, + }, + ) + .unwrap(); + + // Duplicate vote should fail + let v1_info_dup = message_info(&voter1, &[]); + let err = execute( + deps.as_mut(), + env_at(base_time + 400), + v1_info_dup, + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: false, + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::AlreadyVoted { .. })); + + // Cannot finalize before voting period ends + let finalizer = addr("finalizer"); + let fin_info = message_info(&finalizer, &[]); + let err = execute( + deps.as_mut(), + env_at(base_time + 500), + fin_info, + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::VotingPeriodNotEnded)); + + // Finalize after voting period + let fin_info2 = message_info(&addr("finalizer"), &[]); + let res = execute( + deps.as_mut(), + env_at(base_time + 604_800 + 1), + fin_info2, + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + // Should be Approved with refund message + assert!(res + .attributes + .iter() + .any(|a| a.key == "status" && a.value == "Approved")); + assert_eq!(res.messages.len(), 1); // refund + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Approved); + assert_eq!(resp.proposal.yes_votes, 2); + assert_eq!(resp.proposal.no_votes, 1); + assert!(resp.proposal.completed_at.is_some()); + } + + // ── Test 6: Voting + Finalize (Rejection with slash) ────────────── + + #[test] + fn test_voting_and_finalize_reject() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + agent_score( + deps.as_mut(), + id, + 500, + 700, + AgentRecommendation::Conditional, + ); + + let base_time = mock_env().block.time.seconds(); + + // 1 yes, 2 no → rejected + let v1 = addr("v1"); + execute( + deps.as_mut(), + env_at(base_time + 100), + message_info(&v1, &[]), + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: true, + }, + ) + .unwrap(); + + let v2 = addr("v2"); + execute( + deps.as_mut(), + env_at(base_time + 200), + message_info(&v2, &[]), + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: false, + }, + ) + .unwrap(); + + let v3 = addr("v3"); + execute( + deps.as_mut(), + env_at(base_time + 300), + message_info(&v3, &[]), + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: false, + }, + ) + .unwrap(); + + // Finalize + let res = execute( + deps.as_mut(), + env_at(base_time + 604_800 + 1), + message_info(&addr("anyone"), &[]), + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + assert!(res + .attributes + .iter() + .any(|a| a.key == "status" && a.value == "Rejected")); + // Refund message (80% of deposit) + slash transfer (20% to admin) + assert_eq!(res.messages.len(), 2); + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Rejected); + } + + // ── Test 7: Override window expired ─────────────────────────────── + + #[test] + fn test_override_window_expired() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + // Agent auto-rejects + let agent = addr("agent"); + let base_time = mock_env().block.time.seconds(); + let env_now = env_at(base_time); + execute( + deps.as_mut(), + env_now, + message_info(&agent, &[]), + ExecuteMsg::SubmitAgentScore { + proposal_id: id, + score: 100, + confidence: 950, + recommendation: AgentRecommendation::Reject, + }, + ) + .unwrap(); + + // Try override after 6h window + let admin = addr("admin"); + let err = execute( + deps.as_mut(), + env_at(base_time + 21_601), + message_info(&admin, &[]), + ExecuteMsg::OverrideAgentReject { proposal_id: id }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::OverrideWindowExpired)); + } + + // ── Test 8: Agent score unauthorized ────────────────────────────── + + #[test] + fn test_agent_score_unauthorized() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + let rando = addr("rando"); + let info = message_info(&rando, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::SubmitAgentScore { + proposal_id: id, + score: 800, + confidence: 900, + recommendation: AgentRecommendation::Approve, + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + // ── Test 9: Submit proposal insufficient funds ──────────────────── + + #[test] + fn test_submit_proposal_insufficient_funds() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let info = message_info(&proposer, &[Coin::new(100u128, DENOM)]); + let msg = ExecuteMsg::SubmitProposal { + admin_address: proposer.to_string(), + credit_type: "C".to_string(), + methodology_iri: "regen:13toVfvC2YxrrfSXWB5h2BGHiC9iJ8j7kp9KXcPoPMKH3p4TvG3r8Fh" + .to_string(), + }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::InsufficientFunds { .. })); + } + + // ── Test 10: Middle score advances to voting ────────────────────── + + #[test] + fn test_middle_score_advances_to_voting() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + // Score between 300-699 should still go to Voting + let res = agent_score( + deps.as_mut(), + id, + 450, + 600, + AgentRecommendation::Conditional, + ); + assert!(res + .attributes + .iter() + .any(|a| a.key == "result" && a.value == "advance_to_voting")); + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Voting); + } + + // ── Test 11: Update config ──────────────────────────────────────── + + #[test] + fn test_update_config() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + + let new_agent = addr("new_agent"); + let msg = ExecuteMsg::UpdateConfig { + registry_agent: Some(new_agent.to_string()), + deposit_amount: Some(Uint128::new(500_000_000)), + voting_period_seconds: Some(300_000), + agent_review_timeout_seconds: None, + override_window_seconds: None, + community_pool: None, + }; + execute(deps.as_mut(), mock_env(), admin_info, msg).unwrap(); + + let config: ConfigResponse = + cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()) + .unwrap(); + assert_eq!(config.registry_agent, new_agent.to_string()); + assert_eq!(config.deposit_amount, Uint128::new(500_000_000)); + assert_eq!(config.voting_period_seconds, 300_000); + // Unchanged values + assert_eq!(config.agent_review_timeout_seconds, 86_400); + assert_eq!(config.override_window_seconds, 21_600); + } + + // ── Test 12: Update config unauthorized ─────────────────────────── + + #[test] + fn test_update_config_unauthorized() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let rando = addr("rando"); + let info = message_info(&rando, &[]); + let msg = ExecuteMsg::UpdateConfig { + registry_agent: None, + deposit_amount: None, + voting_period_seconds: None, + agent_review_timeout_seconds: None, + override_window_seconds: None, + community_pool: None, + }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + // ── Test 13: Query proposals by status ──────────────────────────── + + #[test] + fn test_query_proposals_by_status() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let p1 = addr("p1"); + let p2 = addr("p2"); + submit_proposal(deps.as_mut(), &p1); + let id2 = submit_proposal(deps.as_mut(), &p2); + + // Advance id2 to Voting + agent_score( + deps.as_mut(), + id2, + 800, + 900, + AgentRecommendation::Approve, + ); + + // Query AgentReview — should only get proposal 1 + let resp: ProposalsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposals { + status: Some(ProposalStatus::AgentReview), + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposals.len(), 1); + assert_eq!(resp.proposals[0].id, 1); + + // Query Voting — should only get proposal 2 + let resp: ProposalsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposals { + status: Some(ProposalStatus::Voting), + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposals.len(), 1); + assert_eq!(resp.proposals[0].id, 2); + } + + // ── Test 14: Proposal IDs increment ─────────────────────────────── + + #[test] + fn test_proposal_ids_increment() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let p1 = addr("p1"); + let p2 = addr("p2"); + let p3 = addr("p3"); + + let id1 = submit_proposal(deps.as_mut(), &p1); + let id2 = submit_proposal(deps.as_mut(), &p2); + let id3 = submit_proposal(deps.as_mut(), &p3); + + assert_eq!(id1, 1); + assert_eq!(id2, 2); + assert_eq!(id3, 3); + } + + // ── Test 15: Expired proposal (no votes) ────────────────────────── + + #[test] + fn test_finalize_expired_no_votes() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + agent_score( + deps.as_mut(), + id, + 600, + 700, + AgentRecommendation::Conditional, + ); + + let base_time = mock_env().block.time.seconds(); + + // Finalize after voting ends with no votes → Expired, 5% slash + let res = execute( + deps.as_mut(), + env_at(base_time + 604_800 + 1), + message_info(&addr("anyone"), &[]), + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + assert!(res + .attributes + .iter() + .any(|a| a.key == "status" && a.value == "Expired")); + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Expired); + } + + // ── Test 16: Reject slash transferred to admin (fallback) ──────── + + #[test] + fn test_reject_slash_transferred_to_admin() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + agent_score( + deps.as_mut(), + id, + 500, + 700, + AgentRecommendation::Conditional, + ); + + let base_time = mock_env().block.time.seconds(); + + // 1 yes, 2 no → rejected + for (name, vote_yes) in [("v1", true), ("v2", false), ("v3", false)] { + let voter = addr(name); + execute( + deps.as_mut(), + env_at(base_time + 100), + message_info(&voter, &[]), + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes, + }, + ) + .unwrap(); + } + + let res = execute( + deps.as_mut(), + env_at(base_time + 604_800 + 1), + message_info(&addr("anyone"), &[]), + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + // 2 messages: refund (80%) + slash (20%) to admin + assert_eq!(res.messages.len(), 2); + + let admin = addr("admin"); + let expected_slash = Uint128::new(DEPOSIT).multiply_ratio(2000u128, 10_000u128); + let expected_refund = Uint128::new(DEPOSIT) - expected_slash; + + // First message: refund to proposer + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[0].msg + { + assert_eq!(to_address, &proposer.to_string()); + assert_eq!(amount[0].amount, expected_refund); + } else { + panic!("Expected BankMsg::Send for refund"); + } + + // Second message: slash to admin (no community_pool configured) + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[1].msg + { + assert_eq!(to_address, &admin.to_string()); + assert_eq!(amount[0].amount, expected_slash); + } else { + panic!("Expected BankMsg::Send for slash"); + } + } + + // ── Test 17: Expired slash transferred to admin ────────────────── + + #[test] + fn test_expired_slash_transferred_to_admin() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + agent_score( + deps.as_mut(), + id, + 600, + 700, + AgentRecommendation::Conditional, + ); + + let base_time = mock_env().block.time.seconds(); + + // Finalize with no votes → Expired, 5% slash + let res = execute( + deps.as_mut(), + env_at(base_time + 604_800 + 1), + message_info(&addr("anyone"), &[]), + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + // 2 messages: refund (95%) + slash (5%) + assert_eq!(res.messages.len(), 2); + + let admin = addr("admin"); + let expected_slash = Uint128::new(DEPOSIT).multiply_ratio(500u128, 10_000u128); + let expected_refund = Uint128::new(DEPOSIT) - expected_slash; + + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[0].msg + { + assert_eq!(to_address, &proposer.to_string()); + assert_eq!(amount[0].amount, expected_refund); + } else { + panic!("Expected BankMsg::Send for refund"); + } + + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[1].msg + { + assert_eq!(to_address, &admin.to_string()); + assert_eq!(amount[0].amount, expected_slash); + } else { + panic!("Expected BankMsg::Send for slash"); + } + } + + // ── Test 18: Slash goes to community_pool when configured ──────── + + #[test] + fn test_slash_transferred_to_community_pool() { + let mut deps = mock_dependencies(); + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let pool = addr("community_pool"); + let msg = InstantiateMsg { + registry_agent: addr("agent").to_string(), + deposit_amount: Some(Uint128::new(DEPOSIT)), + denom: Some(DENOM.to_string()), + voting_period_seconds: Some(604_800), + agent_review_timeout_seconds: Some(86_400), + override_window_seconds: Some(21_600), + community_pool: Some(pool.to_string()), + }; + instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + agent_score( + deps.as_mut(), + id, + 500, + 700, + AgentRecommendation::Conditional, + ); + + let base_time = mock_env().block.time.seconds(); + + // 0 yes, 1 no → rejected + let v1 = addr("v1"); + execute( + deps.as_mut(), + env_at(base_time + 100), + message_info(&v1, &[]), + ExecuteMsg::CastVote { + proposal_id: id, + vote_yes: false, + }, + ) + .unwrap(); + + let res = execute( + deps.as_mut(), + env_at(base_time + 604_800 + 1), + message_info(&addr("anyone"), &[]), + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + assert_eq!(res.messages.len(), 2); + + let expected_slash = Uint128::new(DEPOSIT).multiply_ratio(2000u128, 10_000u128); + + // Slash goes to community_pool, not admin + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[1].msg + { + assert_eq!(to_address, &pool.to_string()); + assert_eq!(amount[0].amount, expected_slash); + } else { + panic!("Expected BankMsg::Send for slash to community_pool"); + } + } + + // ── Test 19: Auto-reject finalization transfers slash ───────────── + + #[test] + fn test_auto_reject_finalize_slash_transferred() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let proposer = addr("proposer"); + let id = submit_proposal(deps.as_mut(), &proposer); + + // Agent auto-rejects + let agent = addr("agent"); + let base_time = mock_env().block.time.seconds(); + execute( + deps.as_mut(), + env_at(base_time), + message_info(&agent, &[]), + ExecuteMsg::SubmitAgentScore { + proposal_id: id, + score: 100, + confidence: 950, + recommendation: AgentRecommendation::Reject, + }, + ) + .unwrap(); + + // Finalize after override window (6h = 21600s) + let res = execute( + deps.as_mut(), + env_at(base_time + 21_601), + message_info(&addr("anyone"), &[]), + ExecuteMsg::FinalizeProposal { proposal_id: id }, + ) + .unwrap(); + + // 2 messages: refund (80%) + slash (20%) to admin + assert_eq!(res.messages.len(), 2); + + let admin = addr("admin"); + let expected_slash = Uint128::new(DEPOSIT).multiply_ratio(2000u128, 10_000u128); + let expected_refund = Uint128::new(DEPOSIT) - expected_slash; + + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[0].msg + { + assert_eq!(to_address, &proposer.to_string()); + assert_eq!(amount[0].amount, expected_refund); + } else { + panic!("Expected BankMsg::Send for refund"); + } + + if let cosmwasm_std::CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + &res.messages[1].msg + { + assert_eq!(to_address, &admin.to_string()); + assert_eq!(amount[0].amount, expected_slash); + } else { + panic!("Expected BankMsg::Send for slash"); + } + + let resp: ProposalResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.proposal.status, ProposalStatus::Rejected); + } +} diff --git a/contracts/credit-class-voting/src/error.rs b/contracts/credit-class-voting/src/error.rs new file mode 100644 index 0000000..a93eb44 --- /dev/null +++ b/contracts/credit-class-voting/src/error.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + #[error("Proposal {id} not found")] + ProposalNotFound { id: u64 }, + + #[error("Invalid proposal status: expected {expected}, got {actual}")] + InvalidStatus { expected: String, actual: String }, + + #[error("Insufficient funds: required {required}, sent {sent}")] + InsufficientFunds { required: String, sent: String }, + + #[error("Wrong denomination: expected {expected}, got {got}")] + WrongDenom { expected: String, got: String }, + + #[error("Credit type must not be empty")] + EmptyCreditType, + + #[error("Methodology IRI must not be empty")] + EmptyMethodologyIri, + + #[error("Agent score must be between 0 and 1000, got {score}")] + InvalidAgentScore { score: u32 }, + + #[error("Agent confidence must be between 0 and 1000, got {confidence}")] + InvalidAgentConfidence { confidence: u32 }, + + #[error("Voting period has not ended yet")] + VotingPeriodNotEnded, + + #[error("Voting period has ended")] + VotingPeriodEnded, + + #[error("Already voted on proposal {id}")] + AlreadyVoted { id: u64 }, + + #[error("Override window has expired")] + OverrideWindowExpired, + + #[error("Agent review has timed out")] + AgentReviewTimedOut, +} diff --git a/contracts/credit-class-voting/src/lib.rs b/contracts/credit-class-voting/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/credit-class-voting/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/credit-class-voting/src/msg.rs b/contracts/credit-class-voting/src/msg.rs new file mode 100644 index 0000000..95d626b --- /dev/null +++ b/contracts/credit-class-voting/src/msg.rs @@ -0,0 +1,124 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use crate::state::{AgentRecommendation, Proposal, ProposalStatus}; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cw_serde] +pub struct InstantiateMsg { + /// Registry Agent (AGENT-001) address + pub registry_agent: String, + /// Deposit amount in uregen (default 1_000_000_000 = 1000 REGEN) + pub deposit_amount: Option, + /// Accepted denomination (default "uregen") + pub denom: Option, + /// Voting period in seconds (default 604_800 = 7 days) + pub voting_period_seconds: Option, + /// Agent review timeout in seconds (default 86_400 = 24h) + pub agent_review_timeout_seconds: Option, + /// Override window in seconds (default 21_600 = 6h) + pub override_window_seconds: Option, + /// Address that receives slashed deposit funds (defaults to admin) + pub community_pool: Option, +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cw_serde] +pub enum ExecuteMsg { + /// Submit a new credit class proposal (must attach deposit) + SubmitProposal { + admin_address: String, + credit_type: String, + methodology_iri: String, + }, + + /// Agent submits score and recommendation (registry_agent only) + SubmitAgentScore { + proposal_id: u64, + score: u32, + confidence: u32, + recommendation: AgentRecommendation, + }, + + /// Admin overrides an agent auto-reject within the override window + OverrideAgentReject { + proposal_id: u64, + }, + + /// Cast a vote on a proposal in Voting status + CastVote { + proposal_id: u64, + vote_yes: bool, + }, + + /// Finalize a proposal after the voting period ends + FinalizeProposal { + proposal_id: u64, + }, + + /// Admin updates contract configuration + UpdateConfig { + registry_agent: Option, + deposit_amount: Option, + voting_period_seconds: Option, + agent_review_timeout_seconds: Option, + override_window_seconds: Option, + community_pool: Option, + }, +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the contract configuration + #[returns(ConfigResponse)] + Config {}, + + /// Returns a single proposal by ID + #[returns(ProposalResponse)] + Proposal { proposal_id: u64 }, + + /// Returns proposals filtered by status (paginated) + #[returns(ProposalsResponse)] + Proposals { + status: Option, + start_after: Option, + limit: Option, + }, + + /// Returns proposals for a specific admin address + #[returns(ProposalsResponse)] + ProposalsByAdmin { + admin_address: String, + start_after: Option, + limit: Option, + }, +} + +// ── Query responses ──────────────────────────────────────────────────── + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub registry_agent: String, + pub deposit_amount: Uint128, + pub denom: String, + pub voting_period_seconds: u64, + pub agent_review_timeout_seconds: u64, + pub override_window_seconds: u64, + pub community_pool: Option, +} + +#[cw_serde] +pub struct ProposalResponse { + pub proposal: Proposal, +} + +#[cw_serde] +pub struct ProposalsResponse { + pub proposals: Vec, +} diff --git a/contracts/credit-class-voting/src/state.rs b/contracts/credit-class-voting/src/state.rs new file mode 100644 index 0000000..18f1472 --- /dev/null +++ b/contracts/credit-class-voting/src/state.rs @@ -0,0 +1,117 @@ +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ── Configuration ────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + /// Contract administrator + pub admin: Addr, + /// Registry Agent (AGENT-001) address — only this address can submit scores + pub registry_agent: Addr, + /// Deposit amount required to submit a proposal (default 1000 REGEN) + pub deposit_amount: Uint128, + /// Accepted deposit denomination + pub denom: String, + /// Voting period in seconds (default 7 days = 604_800) + pub voting_period_seconds: u64, + /// Agent review timeout in seconds (default 24h = 86_400) + pub agent_review_timeout_seconds: u64, + /// Override window in seconds after agent auto-reject (default 6h = 21_600) + pub override_window_seconds: u64, + /// Address that receives slashed deposit funds (falls back to admin if None) + pub community_pool: Option, +} + +// ── Proposal Status ─────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ProposalStatus { + Draft, + AgentReview, + Voting, + Approved, + Rejected, + Expired, + AutoRejected, +} + +impl std::fmt::Display for ProposalStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProposalStatus::Draft => write!(f, "Draft"), + ProposalStatus::AgentReview => write!(f, "AgentReview"), + ProposalStatus::Voting => write!(f, "Voting"), + ProposalStatus::Approved => write!(f, "Approved"), + ProposalStatus::Rejected => write!(f, "Rejected"), + ProposalStatus::Expired => write!(f, "Expired"), + ProposalStatus::AutoRejected => write!(f, "AutoRejected"), + } + } +} + +// ── Agent Recommendation ────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum AgentRecommendation { + Approve, + Conditional, + Reject, +} + +impl std::fmt::Display for AgentRecommendation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AgentRecommendation::Approve => write!(f, "Approve"), + AgentRecommendation::Conditional => write!(f, "Conditional"), + AgentRecommendation::Reject => write!(f, "Reject"), + } + } +} + +// ── Proposal ────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Proposal { + pub id: u64, + /// Address that submitted the proposal + pub proposer: Addr, + /// Admin address associated with the credit class + pub admin_address: Addr, + /// Credit type abbreviation (e.g. "C", "BIO") + pub credit_type: String, + /// IRI pointing to the methodology document + pub methodology_iri: String, + /// Current lifecycle status + pub status: ProposalStatus, + /// Deposit locked with this proposal + pub deposit_amount: Uint128, + /// Block time when proposal was created + pub created_at: Timestamp, + /// Agent score (0-1000), set after agent review + pub agent_score: Option, + /// Agent confidence (0-1000), set after agent review + pub agent_confidence: Option, + /// Agent recommendation, set after agent review + pub agent_recommendation: Option, + /// Block time when agent submitted score + pub agent_scored_at: Option, + /// Block time when voting period ends + pub voting_ends_at: Option, + /// Tally of yes votes + pub yes_votes: u64, + /// Tally of no votes + pub no_votes: u64, + /// Block time when proposal reached terminal state + pub completed_at: Option, +} + +// ── Storage keys ─────────────────────────────────────────────────────── + +pub const CONFIG: Item = Item::new("config"); +pub const NEXT_PROPOSAL_ID: Item = Item::new("next_proposal_id"); +pub const PROPOSALS: Map = Map::new("proposals"); +/// Tracks votes: (proposal_id, voter_addr) -> voted_yes +pub const VOTES: Map<(u64, &Addr), bool> = Map::new("votes"); diff --git a/contracts/integration-tests/Cargo.toml b/contracts/integration-tests/Cargo.toml new file mode 100644 index 0000000..c38e3ed --- /dev/null +++ b/contracts/integration-tests/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "integration-tests" +version = "0.1.0" +edition = "2021" + +[[test]] +name = "integration" +path = "tests/integration.rs" + +[dev-dependencies] +cosmwasm-std = { workspace = true } +cw-multi-test = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +attestation-bonding = { path = "../attestation-bonding", features = ["library"] } +credit-class-voting = { path = "../credit-class-voting", features = ["library"] } +marketplace-curation = { path = "../marketplace-curation", features = ["library"] } +service-escrow = { path = "../service-escrow", features = ["library"] } +contribution-rewards = { path = "../contribution-rewards", features = ["library"] } +validator-governance = { path = "../validator-governance", features = ["library"] } diff --git a/contracts/integration-tests/src/lib.rs b/contracts/integration-tests/src/lib.rs new file mode 100644 index 0000000..eba26e0 --- /dev/null +++ b/contracts/integration-tests/src/lib.rs @@ -0,0 +1 @@ +// Integration test crate — no library code, tests live in tests/integration.rs diff --git a/contracts/integration-tests/tests/integration.rs b/contracts/integration-tests/tests/integration.rs new file mode 100644 index 0000000..c765a3b --- /dev/null +++ b/contracts/integration-tests/tests/integration.rs @@ -0,0 +1,1728 @@ +use cosmwasm_std::testing::MockApi; +use cosmwasm_std::{Addr, Coin, Empty, Uint128}; +use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor}; + +// ── Constants ───────────────────────────────────────────────────────── + +const DENOM: &str = "uregen"; + +// ── Address helpers ─────────────────────────────────────────────────── + +fn addr(label: &str) -> Addr { + MockApi::default().addr_make(label) +} + +// ── Contract wrappers ───────────────────────────────────────────────── + +fn attestation_bonding_contract() -> Box> { + Box::new(ContractWrapper::new( + attestation_bonding::contract::execute, + attestation_bonding::contract::instantiate, + attestation_bonding::contract::query, + )) +} + +fn credit_class_voting_contract() -> Box> { + Box::new(ContractWrapper::new( + credit_class_voting::contract::execute, + credit_class_voting::contract::instantiate, + credit_class_voting::contract::query, + )) +} + +fn marketplace_curation_contract() -> Box> { + Box::new(ContractWrapper::new( + marketplace_curation::contract::execute, + marketplace_curation::contract::instantiate, + marketplace_curation::contract::query, + )) +} + +fn service_escrow_contract() -> Box> { + Box::new(ContractWrapper::new( + service_escrow::contract::execute, + service_escrow::contract::instantiate, + service_escrow::contract::query, + )) +} + +fn contribution_rewards_contract() -> Box> { + Box::new(ContractWrapper::new( + contribution_rewards::contract::execute, + contribution_rewards::contract::instantiate, + contribution_rewards::contract::query, + )) +} + +fn validator_governance_contract() -> Box> { + Box::new(ContractWrapper::new( + validator_governance::contract::execute, + validator_governance::contract::instantiate, + validator_governance::contract::query, + )) +} + +// ── App builder helper ──────────────────────────────────────────────── + +/// Build an App with initial balances for the given addresses. +fn build_app(balances: &[(&Addr, u128)]) -> App { + AppBuilder::new().build(|router, _api, storage| { + for (addr, amount) in balances { + router + .bank + .init_balance( + storage, + addr, + vec![Coin::new(*amount, DENOM)], + ) + .unwrap(); + } + }) +} + +// ═══════════════════════════════════════════════════════════════════════ +// Test 1: Attestation -> Quality Score -> Curation Flow (M008 + M011) +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_attestation_quality_score_curation_flow() { + let admin = addr("admin"); + let arbiter = addr("arbiter"); + let community = addr("community"); + let attester = addr("attester"); + let curator = addr("curator"); + let challenger = addr("challenger"); + + let mut app = build_app(&[ + (&admin, 10_000_000_000), + (&attester, 10_000_000_000), + (&curator, 10_000_000_000), + (&challenger, 10_000_000_000), + ]); + + // ── Deploy attestation-bonding ─────────────────────────────────── + let ab_code_id = app.store_code(attestation_bonding_contract()); + let ab_addr = app + .instantiate_contract( + ab_code_id, + admin.clone(), + &attestation_bonding::msg::InstantiateMsg { + arbiter_dao: arbiter.to_string(), + community_pool: community.to_string(), + denom: DENOM.to_string(), + challenge_deposit_ratio_bps: Some(1000), // 10% + arbiter_fee_ratio_bps: Some(500), // 5% + activation_delay_seconds: Some(100), + }, + &[], + "attestation-bonding", + None, + ) + .unwrap(); + + // ── Deploy marketplace-curation ────────────────────────────────── + let mc_code_id = app.store_code(marketplace_curation_contract()); + let mc_addr = app + .instantiate_contract( + mc_code_id, + admin.clone(), + &marketplace_curation::msg::InstantiateMsg { + community_pool: community.to_string(), + min_curation_bond: Some(Uint128::new(1_000_000_000)), + curation_fee_rate_bps: Some(50), + challenge_deposit: Some(Uint128::new(100_000_000)), + slash_percentage_bps: Some(2000), + activation_delay_seconds: Some(100), + unbonding_period_seconds: Some(200), + bond_top_up_window_seconds: Some(100), + min_quality_score: Some(300), + max_collections_per_curator: Some(5), + denom: DENOM.to_string(), + }, + &[], + "marketplace-curation", + None, + ) + .unwrap(); + + // ── Step 1: Create attestation with bond ───────────────────────── + let create_msg = attestation_bonding::msg::ExecuteMsg::CreateAttestation { + attestation_type: attestation_bonding::state::AttestationType::ProjectBoundary, + iri: "regen:attestation/boundary/1".to_string(), + beneficiary: None, + }; + app.execute_contract( + attester.clone(), + ab_addr.clone(), + &create_msg, + &[Coin::new(500_000_000u128, DENOM)], + ) + .unwrap(); + + // Verify attestation is Bonded + let att_resp: attestation_bonding::msg::AttestationResponse = app + .wrap() + .query_wasm_smart( + ab_addr.clone(), + &attestation_bonding::msg::QueryMsg::Attestation { attestation_id: 1 }, + ) + .unwrap(); + assert_eq!( + att_resp.attestation.status, + attestation_bonding::state::AttestationStatus::Bonded + ); + + // ── Step 2: Advance time and activate attestation ──────────────── + app.update_block(|block| { + block.time = block.time.plus_seconds(101); + }); + + app.execute_contract( + admin.clone(), + ab_addr.clone(), + &attestation_bonding::msg::ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + &[], + ) + .unwrap(); + + let att_resp: attestation_bonding::msg::AttestationResponse = app + .wrap() + .query_wasm_smart( + ab_addr.clone(), + &attestation_bonding::msg::QueryMsg::Attestation { attestation_id: 1 }, + ) + .unwrap(); + assert_eq!( + att_resp.attestation.status, + attestation_bonding::state::AttestationStatus::Active + ); + + // ── Step 3: Submit a quality score for a batch on curation ─────── + let batch_denom = "regen:batch/C01-001-20250101-20251231-001"; + app.execute_contract( + admin.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::SubmitQualityScore { + batch_denom: batch_denom.to_string(), + score: 750, + confidence: 900, + }, + &[], + ) + .unwrap(); + + // Verify quality score + let qs_resp: marketplace_curation::msg::QualityScoreResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::QualityScore { + batch_denom: batch_denom.to_string(), + }, + ) + .unwrap(); + assert_eq!(qs_resp.quality_score.as_ref().unwrap().score, 750); + + // ── Step 4: Create a curated collection ────────────────────────── + app.execute_contract( + curator.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::CreateCollection { + name: "Verified Carbon Credits".to_string(), + criteria: "Score >= 700, active attestation required".to_string(), + }, + &[Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + + // Activate the collection after delay + app.update_block(|block| { + block.time = block.time.plus_seconds(101); + }); + + app.execute_contract( + curator.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::ActivateCollection { collection_id: 1 }, + &[], + ) + .unwrap(); + + // ── Step 5: Add batch to collection (passes quality check) ─────── + app.execute_contract( + curator.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::AddToCollection { + collection_id: 1, + batch_denom: batch_denom.to_string(), + }, + &[], + ) + .unwrap(); + + // Verify batch is in collection + let coll_resp: marketplace_curation::msg::CollectionResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(); + assert!(coll_resp.collection.batches.contains(&batch_denom.to_string())); + + // ── Step 6: Challenge the batch inclusion ──────────────────────── + app.execute_contract( + challenger.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::ChallengeBatchInclusion { + collection_id: 1, + batch_denom: batch_denom.to_string(), + evidence: "The boundary attestation has not been independently verified".to_string(), + }, + &[Coin::new(100_000_000u128, DENOM)], + ) + .unwrap(); + + // Verify collection is UnderReview + let coll_resp: marketplace_curation::msg::CollectionResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(); + assert_eq!( + coll_resp.collection.status, + marketplace_curation::state::CollectionStatus::UnderReview + ); + + // ── Step 7: Resolve the challenge (curator wins) ───────────────── + app.execute_contract( + admin.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: marketplace_curation::state::ChallengeResolution::CuratorWins, + }, + &[], + ) + .unwrap(); + + // After curator wins, collection should go back to Active + let coll_resp: marketplace_curation::msg::CollectionResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(); + assert_eq!( + coll_resp.collection.status, + marketplace_curation::state::CollectionStatus::Active + ); + + // Verify challenge resolved + let ch_resp: marketplace_curation::msg::ChallengeResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::Challenge { challenge_id: 1 }, + ) + .unwrap(); + assert_eq!( + ch_resp.challenge.resolution, + Some(marketplace_curation::state::ChallengeResolution::CuratorWins) + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Test 2: Credit Class Approval Flow (M001-ENH) +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_credit_class_approval_flow() { + let admin = addr("admin"); + let agent = addr("registry_agent"); + let proposer = addr("proposer"); + let voter1 = addr("voter1"); + let voter2 = addr("voter2"); + let voter3 = addr("voter3"); + + let mut app = build_app(&[ + (&admin, 10_000_000_000), + (&proposer, 10_000_000_000), + ]); + + // ── Deploy credit-class-voting ─────────────────────────────────── + let ccv_code_id = app.store_code(credit_class_voting_contract()); + let ccv_addr = app + .instantiate_contract( + ccv_code_id, + admin.clone(), + &credit_class_voting::msg::InstantiateMsg { + registry_agent: agent.to_string(), + deposit_amount: Some(Uint128::new(1_000_000_000)), + denom: Some(DENOM.to_string()), + voting_period_seconds: Some(200), // short for testing + agent_review_timeout_seconds: Some(100), + override_window_seconds: Some(50), + community_pool: None, + }, + &[], + "credit-class-voting", + None, + ) + .unwrap(); + + // ── Step 1: Submit proposal with deposit ───────────────────────── + app.execute_contract( + proposer.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::SubmitProposal { + admin_address: admin.to_string(), + credit_type: "C".to_string(), + methodology_iri: "regen:methodology/carbon-v1".to_string(), + }, + &[Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + + // Verify proposal is in AgentReview + let prop_resp: credit_class_voting::msg::ProposalResponse = app + .wrap() + .query_wasm_smart( + ccv_addr.clone(), + &credit_class_voting::msg::QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(); + assert_eq!( + prop_resp.proposal.status, + credit_class_voting::state::ProposalStatus::AgentReview + ); + + // ── Step 2: Agent submits score > 700 (auto-advances to Voting) ── + app.execute_contract( + agent.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::SubmitAgentScore { + proposal_id: 1, + score: 800, + confidence: 900, + recommendation: credit_class_voting::state::AgentRecommendation::Approve, + }, + &[], + ) + .unwrap(); + + let prop_resp: credit_class_voting::msg::ProposalResponse = app + .wrap() + .query_wasm_smart( + ccv_addr.clone(), + &credit_class_voting::msg::QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(); + assert_eq!( + prop_resp.proposal.status, + credit_class_voting::state::ProposalStatus::Voting + ); + assert_eq!(prop_resp.proposal.agent_score, Some(800)); + + // ── Step 3: Cast votes (yes majority) ──────────────────────────── + app.execute_contract( + voter1.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::CastVote { + proposal_id: 1, + vote_yes: true, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + voter2.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::CastVote { + proposal_id: 1, + vote_yes: true, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + voter3.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::CastVote { + proposal_id: 1, + vote_yes: false, + }, + &[], + ) + .unwrap(); + + // Verify vote tallies + let prop_resp: credit_class_voting::msg::ProposalResponse = app + .wrap() + .query_wasm_smart( + ccv_addr.clone(), + &credit_class_voting::msg::QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(); + assert_eq!(prop_resp.proposal.yes_votes, 2); + assert_eq!(prop_resp.proposal.no_votes, 1); + + // ── Step 4: Advance past voting period and finalize ────────────── + app.update_block(|block| { + block.time = block.time.plus_seconds(201); + }); + + let proposer_balance_before = app.wrap().query_balance(&proposer, DENOM).unwrap(); + + app.execute_contract( + admin.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::FinalizeProposal { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Verify: Approved, deposit refunded + let prop_resp: credit_class_voting::msg::ProposalResponse = app + .wrap() + .query_wasm_smart( + ccv_addr.clone(), + &credit_class_voting::msg::QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(); + assert_eq!( + prop_resp.proposal.status, + credit_class_voting::state::ProposalStatus::Approved + ); + + let proposer_balance_after = app.wrap().query_balance(&proposer, DENOM).unwrap(); + assert_eq!( + proposer_balance_after.amount - proposer_balance_before.amount, + Uint128::new(1_000_000_000), + "Deposit should be refunded on approval" + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Test 3: Service Escrow Lifecycle (M009) +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_service_escrow_lifecycle() { + let admin = addr("admin"); + let arbiter = addr("arbiter"); + let community = addr("community"); + let client = addr("client"); + let provider = addr("provider"); + + let mut app = build_app(&[ + (&admin, 10_000_000_000), + (&client, 10_000_000_000), + (&provider, 10_000_000_000), + ]); + + // ── Deploy service-escrow ──────────────────────────────────────── + let se_code_id = app.store_code(service_escrow_contract()); + let se_addr = app + .instantiate_contract( + se_code_id, + admin.clone(), + &service_escrow::msg::InstantiateMsg { + arbiter_dao: arbiter.to_string(), + community_pool: community.to_string(), + provider_bond_ratio_bps: Some(1000), // 10% + platform_fee_rate_bps: Some(100), // 1% + cancellation_fee_rate_bps: Some(200), // 2% + arbiter_fee_rate_bps: Some(500), // 5% + review_period_seconds: Some(200), + max_milestones: Some(10), + max_revisions: Some(3), + denom: DENOM.to_string(), + }, + &[], + "service-escrow", + None, + ) + .unwrap(); + + // ── Step 1: Client proposes agreement ──────────────────────────── + let milestone1_payment = Uint128::new(600_000_000); // 600 REGEN + let milestone2_payment = Uint128::new(400_000_000); // 400 REGEN + let total_escrow = milestone1_payment + milestone2_payment; // 1000 REGEN + + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::ProposeAgreement { + provider: provider.to_string(), + service_type: "Ecological monitoring".to_string(), + description: "6-month soil carbon measurement program".to_string(), + milestones: vec![ + service_escrow::msg::MilestoneInput { + description: "Install sensors and baseline measurement".to_string(), + payment_amount: milestone1_payment, + }, + service_escrow::msg::MilestoneInput { + description: "Final report and data delivery".to_string(), + payment_amount: milestone2_payment, + }, + ], + }, + &[], + ) + .unwrap(); + + // Verify agreement is Proposed + let agree_resp: service_escrow::msg::AgreementResponse = app + .wrap() + .query_wasm_smart( + se_addr.clone(), + &service_escrow::msg::QueryMsg::Agreement { agreement_id: 1 }, + ) + .unwrap(); + assert_eq!( + agree_resp.agreement.status, + service_escrow::state::AgreementStatus::Proposed + ); + assert_eq!(agree_resp.agreement.milestones.len(), 2); + + // ── Step 2: Provider accepts with bond (10% of escrow = 100M) ─── + let bond_amount = total_escrow.multiply_ratio(1000u128, 10_000u128); // 10% + app.execute_contract( + provider.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::AcceptAgreement { agreement_id: 1 }, + &[Coin::new(bond_amount.u128(), DENOM)], + ) + .unwrap(); + + // ── Step 3: Client funds the escrow ────────────────────────────── + // Fund slightly more than escrow to cover the completion fee (1% of escrow) + let completion_fee = total_escrow.multiply_ratio(100u128, 10_000u128); // 1% + let fund_amount = total_escrow + completion_fee; + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::FundAgreement { agreement_id: 1 }, + &[Coin::new(fund_amount.u128(), DENOM)], + ) + .unwrap(); + + // ── Step 4: Start the agreement ────────────────────────────────── + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::StartAgreement { agreement_id: 1 }, + &[], + ) + .unwrap(); + + let agree_resp: service_escrow::msg::AgreementResponse = app + .wrap() + .query_wasm_smart( + se_addr.clone(), + &service_escrow::msg::QueryMsg::Agreement { agreement_id: 1 }, + ) + .unwrap(); + assert_eq!( + agree_resp.agreement.status, + service_escrow::state::AgreementStatus::InProgress + ); + + // ── Step 5: Provider submits milestone 0 ───────────────────────── + app.execute_contract( + provider.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::SubmitMilestone { + agreement_id: 1, + milestone_index: 0, + deliverable_iri: "regen:deliverable/sensors-installed".to_string(), + }, + &[], + ) + .unwrap(); + + // ── Step 6: Client approves milestone 0 ────────────────────────── + let provider_balance_before = app.wrap().query_balance(&provider, DENOM).unwrap(); + + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::ApproveMilestone { + agreement_id: 1, + milestone_index: 0, + }, + &[], + ) + .unwrap(); + + // Provider should receive milestone payment minus platform fee + let provider_balance_after = app.wrap().query_balance(&provider, DENOM).unwrap(); + let provider_received = provider_balance_after.amount - provider_balance_before.amount; + // Payment is 600M, platform fee is 1% = 6M, so provider gets 594M + assert_eq!(provider_received, Uint128::new(594_000_000)); + + // ── Step 7: Submit and approve milestone 1 to complete ─────────── + app.execute_contract( + provider.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::SubmitMilestone { + agreement_id: 1, + milestone_index: 1, + deliverable_iri: "regen:deliverable/final-report".to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::ApproveMilestone { + agreement_id: 1, + milestone_index: 1, + }, + &[], + ) + .unwrap(); + + // Verify agreement completed + let agree_resp: service_escrow::msg::AgreementResponse = app + .wrap() + .query_wasm_smart( + se_addr.clone(), + &service_escrow::msg::QueryMsg::Agreement { agreement_id: 1 }, + ) + .unwrap(); + assert_eq!( + agree_resp.agreement.status, + service_escrow::state::AgreementStatus::Completed + ); + + // Verify escrow balance + let escrow_resp: service_escrow::msg::EscrowBalanceResponse = app + .wrap() + .query_wasm_smart( + se_addr.clone(), + &service_escrow::msg::QueryMsg::EscrowBalance { agreement_id: 1 }, + ) + .unwrap(); + assert_eq!(escrow_resp.remaining_escrow, Uint128::zero()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Test 4: Validator Governance + Compensation (M014) +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_validator_governance_and_compensation() { + let admin = addr("admin"); + let community = addr("community"); + let val1 = addr("validator_infra_1"); + let val2 = addr("validator_refi_1"); + let val3 = addr("validator_eco_1"); + let participant = addr("participant"); + + let mut app = build_app(&[ + (&admin, 50_000_000_000), + (&community, 50_000_000_000), + ]); + + // ── Deploy validator-governance ────────────────────────────────── + let vg_code_id = app.store_code(validator_governance_contract()); + let vg_addr = app + .instantiate_contract( + vg_code_id, + admin.clone(), + &validator_governance::msg::InstantiateMsg { + min_validators: Some(1), // low for testing + max_validators: Some(21), + term_length_seconds: Some(1000), + probation_period_seconds: Some(100), + min_uptime_bps: Some(9950), + performance_threshold_bps: Some(7000), + uptime_weight_bps: Some(4000), + governance_weight_bps: Some(3000), + ecosystem_weight_bps: Some(3000), + base_compensation_share_bps: Some(9000), + performance_bonus_share_bps: Some(1000), + min_per_category: Some(0), // no per-category minimum for test + denom: DENOM.to_string(), + }, + &[], + "validator-governance", + None, + ) + .unwrap(); + + // ── Deploy contribution-rewards ────────────────────────────────── + let cr_code_id = app.store_code(contribution_rewards_contract()); + let cr_addr = app + .instantiate_contract( + cr_code_id, + admin.clone(), + &contribution_rewards::msg::InstantiateMsg { + community_pool_addr: community.to_string(), + denom: DENOM.to_string(), + }, + &[], + "contribution-rewards", + None, + ) + .unwrap(); + + // ── Step 1: Apply, approve, activate 3 validators (one per category) + // Validator 1 — InfrastructureBuilders + app.execute_contract( + val1.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApplyForValidator { + category: validator_governance::state::ValidatorCategory::InfrastructureBuilders, + application_data: "Core developer, 5 years experience".to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApproveValidator { + applicant: val1.to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ActivateValidator { + validator: val1.to_string(), + }, + &[], + ) + .unwrap(); + + // Validator 2 — TrustedRefiPartners + app.execute_contract( + val2.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApplyForValidator { + category: validator_governance::state::ValidatorCategory::TrustedRefiPartners, + application_data: "ReFi partner, Toucan Protocol integrator".to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApproveValidator { + applicant: val2.to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ActivateValidator { + validator: val2.to_string(), + }, + &[], + ) + .unwrap(); + + // Validator 3 — EcologicalDataStewards + app.execute_contract( + val3.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApplyForValidator { + category: validator_governance::state::ValidatorCategory::EcologicalDataStewards, + application_data: "Ecological data scientist, peer-reviewed publications".to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApproveValidator { + applicant: val3.to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ActivateValidator { + validator: val3.to_string(), + }, + &[], + ) + .unwrap(); + + // Verify all 3 active + let state_resp: validator_governance::msg::ModuleStateResponse = app + .wrap() + .query_wasm_smart( + vg_addr.clone(), + &validator_governance::msg::QueryMsg::ModuleState {}, + ) + .unwrap(); + assert_eq!(state_resp.state.total_active, 3); + + // Verify composition breakdown + let comp_resp: validator_governance::msg::CompositionBreakdownResponse = app + .wrap() + .query_wasm_smart( + vg_addr.clone(), + &validator_governance::msg::QueryMsg::CompositionBreakdown {}, + ) + .unwrap(); + assert_eq!(comp_resp.infrastructure_builders, 1); + assert_eq!(comp_resp.trusted_refi_partners, 1); + assert_eq!(comp_resp.ecological_data_stewards, 1); + + // ── Step 2: Submit performance reports ──────────────────────────── + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::SubmitPerformanceReport { + validator: val1.to_string(), + uptime_bps: 9990, + governance_participation_bps: 8000, + ecosystem_contribution_bps: 7000, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::SubmitPerformanceReport { + validator: val2.to_string(), + uptime_bps: 9970, + governance_participation_bps: 9000, + ecosystem_contribution_bps: 8500, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::SubmitPerformanceReport { + validator: val3.to_string(), + uptime_bps: 9980, + governance_participation_bps: 7500, + ecosystem_contribution_bps: 9000, + }, + &[], + ) + .unwrap(); + + // ── Step 3: Fund the validator fund and distribute compensation ── + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::UpdateValidatorFund {}, + &[Coin::new(3_000_000_000u128, DENOM)], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::DistributeCompensation {}, + &[], + ) + .unwrap(); + + // Verify each validator has compensation due + for val_addr in [&val1, &val2, &val3] { + let val_resp: validator_governance::msg::ValidatorResponse = app + .wrap() + .query_wasm_smart( + vg_addr.clone(), + &validator_governance::msg::QueryMsg::Validator { + address: val_addr.to_string(), + }, + ) + .unwrap(); + assert!( + !val_resp.validator.compensation_due.is_zero(), + "Validator {} should have compensation due", + val_addr + ); + } + + // ── Step 4: Record activity and trigger distribution on contribution-rewards + // Initialize mechanism + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::InitializeMechanism {}, + &[], + ) + .unwrap(); + + // Activate distribution + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::ActivateDistribution {}, + &[], + ) + .unwrap(); + + // Record activity for a participant + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::RecordActivity { + participant: participant.to_string(), + credit_purchase_value: Uint128::new(500_000_000), + credit_retirement_value: Uint128::new(200_000_000), + platform_facilitation_value: Uint128::new(100_000_000), + governance_votes: 5, + proposal_credits: 200, + }, + &[], + ) + .unwrap(); + + // Verify mechanism state before distribution + let mech_resp: contribution_rewards::msg::MechanismStateResponse = app + .wrap() + .query_wasm_smart( + cr_addr.clone(), + &contribution_rewards::msg::QueryMsg::MechanismState {}, + ) + .unwrap(); + let current_period = mech_resp.current_period; + + // Trigger distribution with community pool inflow + let dist_res = app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(1_000_000_000), + }, + &[Coin::new(1_000_000_000u128, DENOM)], + ); + assert!(dist_res.is_ok(), "TriggerDistribution failed: {:?}", dist_res.err()); + + // Verify distribution record exists (use the period we read before triggering) + let dist_resp: contribution_rewards::msg::DistributionRecordResponse = app + .wrap() + .query_wasm_smart( + cr_addr.clone(), + &contribution_rewards::msg::QueryMsg::DistributionRecord { period: current_period }, + ) + .unwrap(); + assert_eq!( + dist_resp.record.community_pool_inflow, + Uint128::new(1_000_000_000) + ); + assert!(!dist_resp.record.activity_pool.is_zero()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Test 5: Full Ecosystem Flow — all 6 contracts +// ═══════════════════════════════════════════════════════════════════════ + +#[test] +fn test_full_ecosystem_flow() { + let admin = addr("admin"); + let arbiter = addr("arbiter"); + let community = addr("community"); + let agent = addr("registry_agent"); + let attester = addr("attester"); + let curator = addr("curator"); + let proposer = addr("proposer"); + let voter1 = addr("voter1"); + let voter2 = addr("voter2"); + let client = addr("client"); + let provider = addr("provider"); + let participant = addr("participant"); + let val1 = addr("val_infra"); + let val2 = addr("val_refi"); + let val3 = addr("val_eco"); + + let mut app = build_app(&[ + (&admin, 100_000_000_000), + (&attester, 10_000_000_000), + (&curator, 10_000_000_000), + (&proposer, 10_000_000_000), + (&client, 10_000_000_000), + (&provider, 10_000_000_000), + (&community, 50_000_000_000), + ]); + + // ── Deploy all 6 contracts ─────────────────────────────────────── + + let ab_code_id = app.store_code(attestation_bonding_contract()); + let ab_addr = app + .instantiate_contract( + ab_code_id, + admin.clone(), + &attestation_bonding::msg::InstantiateMsg { + arbiter_dao: arbiter.to_string(), + community_pool: community.to_string(), + denom: DENOM.to_string(), + challenge_deposit_ratio_bps: Some(1000), + arbiter_fee_ratio_bps: Some(500), + activation_delay_seconds: Some(100), + }, + &[], + "attestation-bonding", + None, + ) + .unwrap(); + + let mc_code_id = app.store_code(marketplace_curation_contract()); + let mc_addr = app + .instantiate_contract( + mc_code_id, + admin.clone(), + &marketplace_curation::msg::InstantiateMsg { + community_pool: community.to_string(), + min_curation_bond: Some(Uint128::new(1_000_000_000)), + curation_fee_rate_bps: Some(50), + challenge_deposit: Some(Uint128::new(100_000_000)), + slash_percentage_bps: Some(2000), + activation_delay_seconds: Some(100), + unbonding_period_seconds: Some(200), + bond_top_up_window_seconds: Some(100), + min_quality_score: Some(300), + max_collections_per_curator: Some(5), + denom: DENOM.to_string(), + }, + &[], + "marketplace-curation", + None, + ) + .unwrap(); + + let ccv_code_id = app.store_code(credit_class_voting_contract()); + let ccv_addr = app + .instantiate_contract( + ccv_code_id, + admin.clone(), + &credit_class_voting::msg::InstantiateMsg { + registry_agent: agent.to_string(), + deposit_amount: Some(Uint128::new(1_000_000_000)), + denom: Some(DENOM.to_string()), + voting_period_seconds: Some(200), + agent_review_timeout_seconds: Some(100), + override_window_seconds: Some(50), + community_pool: None, + }, + &[], + "credit-class-voting", + None, + ) + .unwrap(); + + let se_code_id = app.store_code(service_escrow_contract()); + let se_addr = app + .instantiate_contract( + se_code_id, + admin.clone(), + &service_escrow::msg::InstantiateMsg { + arbiter_dao: arbiter.to_string(), + community_pool: community.to_string(), + provider_bond_ratio_bps: Some(1000), + platform_fee_rate_bps: Some(100), + cancellation_fee_rate_bps: Some(200), + arbiter_fee_rate_bps: Some(500), + review_period_seconds: Some(200), + max_milestones: Some(10), + max_revisions: Some(3), + denom: DENOM.to_string(), + }, + &[], + "service-escrow", + None, + ) + .unwrap(); + + let cr_code_id = app.store_code(contribution_rewards_contract()); + let cr_addr = app + .instantiate_contract( + cr_code_id, + admin.clone(), + &contribution_rewards::msg::InstantiateMsg { + community_pool_addr: community.to_string(), + denom: DENOM.to_string(), + }, + &[], + "contribution-rewards", + None, + ) + .unwrap(); + + let vg_code_id = app.store_code(validator_governance_contract()); + let vg_addr = app + .instantiate_contract( + vg_code_id, + admin.clone(), + &validator_governance::msg::InstantiateMsg { + min_validators: Some(1), + max_validators: Some(21), + term_length_seconds: Some(1000), + probation_period_seconds: Some(100), + min_uptime_bps: Some(9950), + performance_threshold_bps: Some(7000), + uptime_weight_bps: Some(4000), + governance_weight_bps: Some(3000), + ecosystem_weight_bps: Some(3000), + base_compensation_share_bps: Some(9000), + performance_bonus_share_bps: Some(1000), + min_per_category: Some(0), + denom: DENOM.to_string(), + }, + &[], + "validator-governance", + None, + ) + .unwrap(); + + // ═══ Phase 1: Attestation -> Quality Score -> Curated Collection (M008 + M011) ═══ + + // Create attestation + app.execute_contract( + attester.clone(), + ab_addr.clone(), + &attestation_bonding::msg::ExecuteMsg::CreateAttestation { + attestation_type: attestation_bonding::state::AttestationType::BaselineMeasurement, + iri: "regen:attestation/baseline/1".to_string(), + beneficiary: None, + }, + &[Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + + // Activate after delay + app.update_block(|block| { + block.time = block.time.plus_seconds(101); + }); + + app.execute_contract( + admin.clone(), + ab_addr.clone(), + &attestation_bonding::msg::ExecuteMsg::ActivateAttestation { attestation_id: 1 }, + &[], + ) + .unwrap(); + + // Submit quality score + let batch_denom = "regen:batch/C02-001-20250301-20260301-001"; + app.execute_contract( + admin.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::SubmitQualityScore { + batch_denom: batch_denom.to_string(), + score: 850, + confidence: 950, + }, + &[], + ) + .unwrap(); + + // Create and activate collection + app.execute_contract( + curator.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::CreateCollection { + name: "Premium Verified Credits".to_string(), + criteria: "Score >= 800, attestation bonded".to_string(), + }, + &[Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + + app.update_block(|block| { + block.time = block.time.plus_seconds(101); + }); + + app.execute_contract( + curator.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::ActivateCollection { collection_id: 1 }, + &[], + ) + .unwrap(); + + // Add batch to collection + app.execute_contract( + curator.clone(), + mc_addr.clone(), + &marketplace_curation::msg::ExecuteMsg::AddToCollection { + collection_id: 1, + batch_denom: batch_denom.to_string(), + }, + &[], + ) + .unwrap(); + + let coll_resp: marketplace_curation::msg::CollectionResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(); + assert_eq!( + coll_resp.collection.status, + marketplace_curation::state::CollectionStatus::Active + ); + assert!(coll_resp.collection.batches.contains(&batch_denom.to_string())); + + // ═══ Phase 2: Credit class proposal -> agent screen -> vote -> approve (M001-ENH) ═══ + + app.execute_contract( + proposer.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::SubmitProposal { + admin_address: admin.to_string(), + credit_type: "BIO".to_string(), + methodology_iri: "regen:methodology/biodiversity-v2".to_string(), + }, + &[Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + + // Agent approves + app.execute_contract( + agent.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::SubmitAgentScore { + proposal_id: 1, + score: 850, + confidence: 920, + recommendation: credit_class_voting::state::AgentRecommendation::Approve, + }, + &[], + ) + .unwrap(); + + // Votes + app.execute_contract( + voter1.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::CastVote { + proposal_id: 1, + vote_yes: true, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + voter2.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::CastVote { + proposal_id: 1, + vote_yes: true, + }, + &[], + ) + .unwrap(); + + // Advance past voting period and finalize + app.update_block(|block| { + block.time = block.time.plus_seconds(201); + }); + + app.execute_contract( + admin.clone(), + ccv_addr.clone(), + &credit_class_voting::msg::ExecuteMsg::FinalizeProposal { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let prop_resp: credit_class_voting::msg::ProposalResponse = app + .wrap() + .query_wasm_smart( + ccv_addr.clone(), + &credit_class_voting::msg::QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(); + assert_eq!( + prop_resp.proposal.status, + credit_class_voting::state::ProposalStatus::Approved + ); + + // ═══ Phase 3: Commission service via escrow (M009) ═══ + + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::ProposeAgreement { + provider: provider.to_string(), + service_type: "Biodiversity monitoring".to_string(), + description: "Quarterly species survey".to_string(), + milestones: vec![service_escrow::msg::MilestoneInput { + description: "Complete survey and deliver report".to_string(), + payment_amount: Uint128::new(500_000_000), + }], + }, + &[], + ) + .unwrap(); + + // Provider accepts with bond + let bond = Uint128::new(50_000_000); // 10% of 500M + app.execute_contract( + provider.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::AcceptAgreement { agreement_id: 1 }, + &[Coin::new(bond.u128(), DENOM)], + ) + .unwrap(); + + // Client funds (extra to cover completion fee of 1% of escrow) + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::FundAgreement { agreement_id: 1 }, + &[Coin::new(505_000_000u128, DENOM)], + ) + .unwrap(); + + // Start + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::StartAgreement { agreement_id: 1 }, + &[], + ) + .unwrap(); + + // Submit and approve milestone + app.execute_contract( + provider.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::SubmitMilestone { + agreement_id: 1, + milestone_index: 0, + deliverable_iri: "regen:deliverable/survey-q1".to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + client.clone(), + se_addr.clone(), + &service_escrow::msg::ExecuteMsg::ApproveMilestone { + agreement_id: 1, + milestone_index: 0, + }, + &[], + ) + .unwrap(); + + let agree_resp: service_escrow::msg::AgreementResponse = app + .wrap() + .query_wasm_smart( + se_addr.clone(), + &service_escrow::msg::QueryMsg::Agreement { agreement_id: 1 }, + ) + .unwrap(); + assert_eq!( + agree_resp.agreement.status, + service_escrow::state::AgreementStatus::Completed + ); + + // ═══ Phase 4: Record ecosystem activity -> distribute rewards (M015) ═══ + + // Initialize and activate contribution rewards + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::InitializeMechanism {}, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::ActivateDistribution {}, + &[], + ) + .unwrap(); + + // Record activity for participants reflecting the ecosystem work done above + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::RecordActivity { + participant: participant.to_string(), + credit_purchase_value: Uint128::new(1_000_000_000), + credit_retirement_value: Uint128::new(500_000_000), + platform_facilitation_value: Uint128::new(200_000_000), + governance_votes: 3, + proposal_credits: 100, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::RecordActivity { + participant: curator.to_string(), + credit_purchase_value: Uint128::new(200_000_000), + credit_retirement_value: Uint128::new(100_000_000), + platform_facilitation_value: Uint128::new(800_000_000), + governance_votes: 2, + proposal_credits: 50, + }, + &[], + ) + .unwrap(); + + // Read current period before triggering distribution + let mech_resp: contribution_rewards::msg::MechanismStateResponse = app + .wrap() + .query_wasm_smart( + cr_addr.clone(), + &contribution_rewards::msg::QueryMsg::MechanismState {}, + ) + .unwrap(); + let dist_period = mech_resp.current_period; + + // Trigger distribution + app.execute_contract( + admin.clone(), + cr_addr.clone(), + &contribution_rewards::msg::ExecuteMsg::TriggerDistribution { + community_pool_inflow: Uint128::new(2_000_000_000), + }, + &[Coin::new(2_000_000_000u128, DENOM)], + ) + .unwrap(); + + let dist_resp: contribution_rewards::msg::DistributionRecordResponse = app + .wrap() + .query_wasm_smart( + cr_addr.clone(), + &contribution_rewards::msg::QueryMsg::DistributionRecord { period: dist_period }, + ) + .unwrap(); + assert!(!dist_resp.record.activity_pool.is_zero()); + assert_eq!( + dist_resp.record.community_pool_inflow, + Uint128::new(2_000_000_000) + ); + + // ═══ Phase 5: Verify validator governance operational (M014) ═══ + + // Apply, approve, activate validators + for (val, cat, data) in [ + ( + &val1, + validator_governance::state::ValidatorCategory::InfrastructureBuilders, + "Core dev", + ), + ( + &val2, + validator_governance::state::ValidatorCategory::TrustedRefiPartners, + "ReFi integrator", + ), + ( + &val3, + validator_governance::state::ValidatorCategory::EcologicalDataStewards, + "Data scientist", + ), + ] { + app.execute_contract( + val.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApplyForValidator { + category: cat, + application_data: data.to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ApproveValidator { + applicant: val.to_string(), + }, + &[], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::ActivateValidator { + validator: val.to_string(), + }, + &[], + ) + .unwrap(); + } + + // Verify 3 active + let state_resp: validator_governance::msg::ModuleStateResponse = app + .wrap() + .query_wasm_smart( + vg_addr.clone(), + &validator_governance::msg::QueryMsg::ModuleState {}, + ) + .unwrap(); + assert_eq!(state_resp.state.total_active, 3); + + // Submit performance and distribute compensation + for val in [&val1, &val2, &val3] { + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::SubmitPerformanceReport { + validator: val.to_string(), + uptime_bps: 9990, + governance_participation_bps: 8500, + ecosystem_contribution_bps: 8000, + }, + &[], + ) + .unwrap(); + } + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::UpdateValidatorFund {}, + &[Coin::new(3_000_000_000u128, DENOM)], + ) + .unwrap(); + + app.execute_contract( + admin.clone(), + vg_addr.clone(), + &validator_governance::msg::ExecuteMsg::DistributeCompensation {}, + &[], + ) + .unwrap(); + + // Verify all validators have compensation + for val in [&val1, &val2, &val3] { + let val_resp: validator_governance::msg::ValidatorResponse = app + .wrap() + .query_wasm_smart( + vg_addr.clone(), + &validator_governance::msg::QueryMsg::Validator { + address: val.to_string(), + }, + ) + .unwrap(); + assert!( + !val_resp.validator.compensation_due.is_zero(), + "Validator {} should have compensation", + val + ); + assert_eq!( + val_resp.validator.status, + validator_governance::state::ValidatorStatus::Active + ); + } + + // ═══ Final verification: all contracts deployed and operational ═══ + + // Attestation bonding: attestation active + let att_resp: attestation_bonding::msg::AttestationResponse = app + .wrap() + .query_wasm_smart( + ab_addr.clone(), + &attestation_bonding::msg::QueryMsg::Attestation { attestation_id: 1 }, + ) + .unwrap(); + assert_eq!( + att_resp.attestation.status, + attestation_bonding::state::AttestationStatus::Active + ); + + // Marketplace curation: collection active with batch + let coll_resp: marketplace_curation::msg::CollectionResponse = app + .wrap() + .query_wasm_smart( + mc_addr.clone(), + &marketplace_curation::msg::QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(); + assert_eq!( + coll_resp.collection.status, + marketplace_curation::state::CollectionStatus::Active + ); + + // Credit class voting: proposal approved + let prop_resp: credit_class_voting::msg::ProposalResponse = app + .wrap() + .query_wasm_smart( + ccv_addr.clone(), + &credit_class_voting::msg::QueryMsg::Proposal { proposal_id: 1 }, + ) + .unwrap(); + assert_eq!( + prop_resp.proposal.status, + credit_class_voting::state::ProposalStatus::Approved + ); + + // Service escrow: agreement completed + let agree_resp: service_escrow::msg::AgreementResponse = app + .wrap() + .query_wasm_smart( + se_addr.clone(), + &service_escrow::msg::QueryMsg::Agreement { agreement_id: 1 }, + ) + .unwrap(); + assert_eq!( + agree_resp.agreement.status, + service_escrow::state::AgreementStatus::Completed + ); + + // Contribution rewards: distribution executed + let mech_resp: contribution_rewards::msg::MechanismStateResponse = app + .wrap() + .query_wasm_smart( + cr_addr.clone(), + &contribution_rewards::msg::QueryMsg::MechanismState {}, + ) + .unwrap(); + assert_eq!(mech_resp.status, "Distributing"); + + // Validator governance: 3 active, fund distributed + let state_resp: validator_governance::msg::ModuleStateResponse = app + .wrap() + .query_wasm_smart( + vg_addr.clone(), + &validator_governance::msg::QueryMsg::ModuleState {}, + ) + .unwrap(); + assert_eq!(state_resp.state.total_active, 3); + assert!(state_resp.state.last_compensation_distribution.is_some()); +} diff --git a/contracts/marketplace-curation/Cargo.toml b/contracts/marketplace-curation/Cargo.toml new file mode 100644 index 0000000..d68a69b --- /dev/null +++ b/contracts/marketplace-curation/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "marketplace-curation" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true diff --git a/contracts/marketplace-curation/src/contract.rs b/contracts/marketplace-curation/src/contract.rs new file mode 100644 index 0000000..f1a0a4e --- /dev/null +++ b/contracts/marketplace-curation/src/contract.rs @@ -0,0 +1,1735 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + BondStatusResponse, ChallengeResponse, CollectionResponse, CollectionsResponse, + ConfigResponse, CuratorStatsResponse, ExecuteMsg, InstantiateMsg, QualityScoreResponse, + QueryMsg, +}; +use crate::state::{ + Challenge, ChallengeResolution, Collection, CollectionStatus, Config, QualityScore, + BATCH_COLLECTIONS, CHALLENGES, COLLECTIONS, COLLECTION_CHALLENGES, CONFIG, + CURATOR_COLLECTION_COUNT, NEXT_CHALLENGE_ID, NEXT_COLLECTION_ID, QUALITY_SCORES, +}; + +const CONTRACT_NAME: &str = "crates.io:marketplace-curation"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const DEFAULT_QUERY_LIMIT: u32 = 10; +const MAX_QUERY_LIMIT: u32 = 30; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let config = Config { + admin: info.sender.clone(), + community_pool: deps.api.addr_validate(&msg.community_pool)?, + min_curation_bond: msg.min_curation_bond.unwrap_or(Uint128::new(1_000_000_000)), + curation_fee_rate_bps: msg.curation_fee_rate_bps.unwrap_or(50), + challenge_deposit: msg.challenge_deposit.unwrap_or(Uint128::new(100_000_000)), + slash_percentage_bps: msg.slash_percentage_bps.unwrap_or(2000), + activation_delay_seconds: msg.activation_delay_seconds.unwrap_or(172_800), // 48h + unbonding_period_seconds: msg.unbonding_period_seconds.unwrap_or(1_209_600), // 14 days + bond_top_up_window_seconds: msg.bond_top_up_window_seconds.unwrap_or(604_800), // 7 days + min_quality_score: msg.min_quality_score.unwrap_or(300), + max_collections_per_curator: msg.max_collections_per_curator.unwrap_or(5), + denom: msg.denom, + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &config)?; + NEXT_COLLECTION_ID.save(deps.storage, &1u64)?; + NEXT_CHALLENGE_ID.save(deps.storage, &1u64)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::CreateCollection { name, criteria } => { + execute_create_collection(deps, env, info, name, criteria) + } + ExecuteMsg::ActivateCollection { collection_id } => { + execute_activate_collection(deps, env, info, collection_id) + } + ExecuteMsg::AddToCollection { + collection_id, + batch_denom, + } => execute_add_to_collection(deps, env, info, collection_id, batch_denom), + ExecuteMsg::RemoveFromCollection { + collection_id, + batch_denom, + } => execute_remove_from_collection(deps, info, collection_id, batch_denom), + ExecuteMsg::CloseCollection { collection_id } => { + execute_close_collection(deps, env, info, collection_id) + } + ExecuteMsg::TopUpBond { collection_id } => { + execute_top_up_bond(deps, info, collection_id) + } + ExecuteMsg::ChallengeBatchInclusion { + collection_id, + batch_denom, + evidence, + } => execute_challenge_batch(deps, env, info, collection_id, batch_denom, evidence), + ExecuteMsg::ResolveChallenge { + challenge_id, + resolution, + } => execute_resolve_challenge(deps, env, info, challenge_id, resolution), + ExecuteMsg::SubmitQualityScore { + batch_denom, + score, + confidence, + } => execute_submit_quality_score(deps, env, info, batch_denom, score, confidence), + ExecuteMsg::WithdrawBond { collection_id } => { + execute_withdraw_bond(deps, env, info, collection_id) + } + ExecuteMsg::UpdateConfig { + community_pool, + min_curation_bond, + curation_fee_rate_bps, + challenge_deposit, + slash_percentage_bps, + activation_delay_seconds, + unbonding_period_seconds, + bond_top_up_window_seconds, + min_quality_score, + max_collections_per_curator, + } => execute_update_config( + deps, + info, + community_pool, + min_curation_bond, + curation_fee_rate_bps, + challenge_deposit, + slash_percentage_bps, + activation_delay_seconds, + unbonding_period_seconds, + bond_top_up_window_seconds, + min_quality_score, + max_collections_per_curator, + ), + } +} + +// ── Execute handlers ─────────────────────────────────────────────────── + +fn execute_create_collection( + deps: DepsMut, + env: Env, + info: MessageInfo, + name: String, + criteria: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Verify bond sent + let sent = must_pay(&info, &config.denom)?; + if sent < config.min_curation_bond { + return Err(ContractError::InsufficientBond { + required: config.min_curation_bond.to_string(), + sent: sent.to_string(), + }); + } + + // Check curator hasn't exceeded max collections + let count = CURATOR_COLLECTION_COUNT + .may_load(deps.storage, &info.sender)? + .unwrap_or(0); + if count >= config.max_collections_per_curator { + return Err(ContractError::MaxCollectionsExceeded { + max: config.max_collections_per_curator, + }); + } + + let id = NEXT_COLLECTION_ID.load(deps.storage)?; + let activates_at = env + .block + .time + .plus_seconds(config.activation_delay_seconds); + + let collection = Collection { + id, + curator: info.sender.clone(), + name, + criteria, + status: CollectionStatus::Proposed, + bond_amount: sent, + batches: vec![], + created_at: env.block.time, + activates_at, + suspension_expires_at: None, + closed_at: None, + }; + + COLLECTIONS.save(deps.storage, id, &collection)?; + NEXT_COLLECTION_ID.save(deps.storage, &(id + 1))?; + CURATOR_COLLECTION_COUNT.save(deps.storage, &info.sender, &(count + 1))?; + COLLECTION_CHALLENGES.save(deps.storage, id, &vec![])?; + + Ok(Response::new() + .add_attribute("action", "create_collection") + .add_attribute("collection_id", id.to_string()) + .add_attribute("curator", info.sender) + .add_attribute("bond_amount", sent)) +} + +fn execute_activate_collection( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection_id: u64, +) -> Result { + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Only curator can activate + if info.sender != collection.curator { + return Err(ContractError::Unauthorized { + reason: "only the curator can activate".to_string(), + }); + } + + if collection.status != CollectionStatus::Proposed { + return Err(ContractError::InvalidCollectionStatus { + expected: "Proposed".to_string(), + actual: collection.status.to_string(), + }); + } + + // Check activation delay + if env.block.time < collection.activates_at { + return Err(ContractError::ActivationDelayNotElapsed); + } + + // Check no pending challenges + let challenge_ids = COLLECTION_CHALLENGES + .may_load(deps.storage, collection_id)? + .unwrap_or_default(); + for cid in &challenge_ids { + let challenge = CHALLENGES.load(deps.storage, *cid)?; + if challenge.resolution.is_none() { + return Err(ContractError::PendingChallenges); + } + } + + collection.status = CollectionStatus::Active; + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + Ok(Response::new() + .add_attribute("action", "activate_collection") + .add_attribute("collection_id", collection_id.to_string())) +} + +fn execute_add_to_collection( + deps: DepsMut, + _env: Env, + info: MessageInfo, + collection_id: u64, + batch_denom: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Only curator + if info.sender != collection.curator { + return Err(ContractError::Unauthorized { + reason: "only the curator can add batches".to_string(), + }); + } + + // Must be Active + if collection.status != CollectionStatus::Active { + return Err(ContractError::InvalidCollectionStatus { + expected: "Active".to_string(), + actual: collection.status.to_string(), + }); + } + + // Check not already in collection + if collection.batches.contains(&batch_denom) { + return Err(ContractError::BatchAlreadyInCollection { + batch_denom, + collection_id, + }); + } + + // Check quality score exists and meets minimum + let qs = QUALITY_SCORES + .may_load(deps.storage, &batch_denom)? + .ok_or(ContractError::NoQualityScore { + batch_denom: batch_denom.clone(), + })?; + if qs.score < config.min_quality_score { + return Err(ContractError::QualityScoreTooLow { + batch_denom, + score: qs.score, + min: config.min_quality_score, + }); + } + + collection.batches.push(batch_denom.clone()); + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + // Maintain reverse index: batch_denom -> collection IDs + let mut batch_colls = BATCH_COLLECTIONS + .may_load(deps.storage, &batch_denom)? + .unwrap_or_default(); + batch_colls.push(collection_id); + BATCH_COLLECTIONS.save(deps.storage, &batch_denom, &batch_colls)?; + + Ok(Response::new() + .add_attribute("action", "add_to_collection") + .add_attribute("collection_id", collection_id.to_string()) + .add_attribute("batch_denom", batch_denom)) +} + +fn execute_remove_from_collection( + deps: DepsMut, + info: MessageInfo, + collection_id: u64, + batch_denom: String, +) -> Result { + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Only curator + if info.sender != collection.curator { + return Err(ContractError::Unauthorized { + reason: "only the curator can remove batches".to_string(), + }); + } + + let pos = collection + .batches + .iter() + .position(|b| b == &batch_denom) + .ok_or(ContractError::BatchNotInCollection { + batch_denom: batch_denom.clone(), + collection_id, + })?; + + collection.batches.remove(pos); + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + // Maintain reverse index: remove this collection from the batch's list + let mut batch_colls = BATCH_COLLECTIONS + .may_load(deps.storage, &batch_denom)? + .unwrap_or_default(); + batch_colls.retain(|&cid| cid != collection_id); + if batch_colls.is_empty() { + BATCH_COLLECTIONS.remove(deps.storage, &batch_denom); + } else { + BATCH_COLLECTIONS.save(deps.storage, &batch_denom, &batch_colls)?; + } + + Ok(Response::new() + .add_attribute("action", "remove_from_collection") + .add_attribute("collection_id", collection_id.to_string()) + .add_attribute("batch_denom", batch_denom)) +} + +fn execute_close_collection( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection_id: u64, +) -> Result { + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Only curator + if info.sender != collection.curator { + return Err(ContractError::Unauthorized { + reason: "only the curator can close".to_string(), + }); + } + + // Check no pending challenges + let challenge_ids = COLLECTION_CHALLENGES + .may_load(deps.storage, collection_id)? + .unwrap_or_default(); + for cid in &challenge_ids { + let challenge = CHALLENGES.load(deps.storage, *cid)?; + if challenge.resolution.is_none() { + return Err(ContractError::PendingChallenges); + } + } + + collection.status = CollectionStatus::Closed; + collection.closed_at = Some(env.block.time); + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + Ok(Response::new() + .add_attribute("action", "close_collection") + .add_attribute("collection_id", collection_id.to_string())) +} + +fn execute_top_up_bond( + deps: DepsMut, + info: MessageInfo, + collection_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Only curator + if info.sender != collection.curator { + return Err(ContractError::Unauthorized { + reason: "only the curator can top up bond".to_string(), + }); + } + + // Must be Suspended + if collection.status != CollectionStatus::Suspended { + return Err(ContractError::InvalidCollectionStatus { + expected: "Suspended".to_string(), + actual: collection.status.to_string(), + }); + } + + let sent = must_pay(&info, &config.denom)?; + collection.bond_amount += sent; + + // Restore to Active if bond is now sufficient + if collection.bond_amount >= config.min_curation_bond { + collection.status = CollectionStatus::Active; + collection.suspension_expires_at = None; + } + + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + Ok(Response::new() + .add_attribute("action", "top_up_bond") + .add_attribute("collection_id", collection_id.to_string()) + .add_attribute("new_bond", collection.bond_amount) + .add_attribute("status", collection.status.to_string())) +} + +fn execute_challenge_batch( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection_id: u64, + batch_denom: String, + evidence: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Verify deposit + let sent = must_pay(&info, &config.denom)?; + if sent < config.challenge_deposit { + return Err(ContractError::InsufficientDeposit { + required: config.challenge_deposit.to_string(), + sent: sent.to_string(), + }); + } + + // Collection must be Active or Proposed (not already Closed/Suspended) + if collection.status != CollectionStatus::Active + && collection.status != CollectionStatus::Proposed + { + return Err(ContractError::InvalidCollectionStatus { + expected: "Active or Proposed".to_string(), + actual: collection.status.to_string(), + }); + } + + let challenge_id = NEXT_CHALLENGE_ID.load(deps.storage)?; + let challenge = Challenge { + id: challenge_id, + collection_id, + challenger: info.sender.clone(), + batch_denom: batch_denom.clone(), + deposit: sent, + evidence, + filed_at: env.block.time, + resolution: None, + }; + + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + NEXT_CHALLENGE_ID.save(deps.storage, &(challenge_id + 1))?; + + // Add to collection's challenge list + let mut challenge_ids = COLLECTION_CHALLENGES + .may_load(deps.storage, collection_id)? + .unwrap_or_default(); + challenge_ids.push(challenge_id); + COLLECTION_CHALLENGES.save(deps.storage, collection_id, &challenge_ids)?; + + // Move collection to UnderReview + collection.status = CollectionStatus::UnderReview; + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + Ok(Response::new() + .add_attribute("action", "challenge_batch") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("collection_id", collection_id.to_string()) + .add_attribute("batch_denom", batch_denom) + .add_attribute("challenger", info.sender)) +} + +fn execute_resolve_challenge( + deps: DepsMut, + env: Env, + info: MessageInfo, + challenge_id: u64, + resolution: ChallengeResolution, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Admin only + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "only admin can resolve challenges".to_string(), + }); + } + + let mut challenge = CHALLENGES + .may_load(deps.storage, challenge_id)? + .ok_or(ContractError::ChallengeNotFound { id: challenge_id })?; + + if challenge.resolution.is_some() { + return Err(ContractError::ChallengeAlreadyResolved); + } + + challenge.resolution = Some(resolution.clone()); + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + + let mut collection = COLLECTIONS.load(deps.storage, challenge.collection_id)?; + let mut msgs: Vec = vec![]; + + match resolution { + ChallengeResolution::CuratorWins => { + // Challenger loses deposit — send to community pool + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: challenge.deposit, + }], + }); + + // Restore collection to Active if no other pending challenges + if all_challenges_resolved(deps.storage, challenge.collection_id)? { + collection.status = CollectionStatus::Active; + } + } + ChallengeResolution::ChallengerWins => { + // Slash curator bond by slash_percentage_bps + let slash_amount = collection + .bond_amount + .multiply_ratio(config.slash_percentage_bps, 10_000u64); + + collection.bond_amount = collection.bond_amount.saturating_sub(slash_amount); + + // Split: 50% to challenger, 50% to community pool + let challenger_share = slash_amount.multiply_ratio(1u128, 2u128); + let community_share = slash_amount - challenger_share; + + // Return challenger deposit + their share of slash + let challenger_total = challenge.deposit + challenger_share; + msgs.push(BankMsg::Send { + to_address: challenge.challenger.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: challenger_total, + }], + }); + + if !community_share.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { + denom: config.denom.clone(), + amount: community_share, + }], + }); + } + + // Suspend if bond is below minimum + if collection.bond_amount < config.min_curation_bond { + collection.status = CollectionStatus::Suspended; + collection.suspension_expires_at = Some( + env.block + .time + .plus_seconds(config.bond_top_up_window_seconds), + ); + } else if all_challenges_resolved(deps.storage, challenge.collection_id)? { + collection.status = CollectionStatus::Active; + } + } + } + + COLLECTIONS.save(deps.storage, challenge.collection_id, &collection)?; + + let mut resp = Response::new() + .add_attribute("action", "resolve_challenge") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("collection_id", challenge.collection_id.to_string()) + .add_attribute("status", collection.status.to_string()); + + for msg in msgs { + resp = resp.add_message(msg); + } + + Ok(resp) +} + +fn execute_submit_quality_score( + deps: DepsMut, + env: Env, + info: MessageInfo, + batch_denom: String, + score: u32, + confidence: u32, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Admin/agent only + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "only admin/agent can submit quality scores".to_string(), + }); + } + + if score > 1000 { + return Err(ContractError::InvalidScore { value: score }); + } + if confidence > 1000 { + return Err(ContractError::InvalidConfidence { value: confidence }); + } + + let qs = QualityScore { + batch_denom: batch_denom.clone(), + score, + confidence, + computed_at: env.block.time, + }; + QUALITY_SCORES.save(deps.storage, &batch_denom, &qs)?; + + // Auto-remove from any collections if score dropped below minimum + let mut removed_from: Vec = vec![]; + if score < config.min_quality_score { + // Use reverse index to find only the collections containing this batch + let batch_colls = BATCH_COLLECTIONS + .may_load(deps.storage, &batch_denom)? + .unwrap_or_default(); + + for cid in batch_colls { + let mut coll = COLLECTIONS.load(deps.storage, cid)?; + if let Some(pos) = coll.batches.iter().position(|b| b == &batch_denom) { + coll.batches.remove(pos); + COLLECTIONS.save(deps.storage, cid, &coll)?; + removed_from.push(cid); + } + } + + // Clean up the reverse index entry since batch is removed from all collections + if !removed_from.is_empty() { + BATCH_COLLECTIONS.remove(deps.storage, &batch_denom); + } + } + + Ok(Response::new() + .add_attribute("action", "submit_quality_score") + .add_attribute("batch_denom", batch_denom) + .add_attribute("score", score.to_string()) + .add_attribute("confidence", confidence.to_string()) + .add_attribute("auto_removed_from", format!("{:?}", removed_from))) +} + +fn execute_withdraw_bond( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut collection = COLLECTIONS + .may_load(deps.storage, collection_id)? + .ok_or(ContractError::CollectionNotFound { id: collection_id })?; + + // Only curator + if info.sender != collection.curator { + return Err(ContractError::Unauthorized { + reason: "only the curator can withdraw bond".to_string(), + }); + } + + // Must be Closed + if collection.status != CollectionStatus::Closed { + return Err(ContractError::InvalidCollectionStatus { + expected: "Closed".to_string(), + actual: collection.status.to_string(), + }); + } + + // Check unbonding period + let closed_at = collection.closed_at.unwrap_or(env.block.time); + let unbonds_at = closed_at.plus_seconds(config.unbonding_period_seconds); + if env.block.time < unbonds_at { + return Err(ContractError::UnbondingNotComplete); + } + + let amount = collection.bond_amount; + collection.bond_amount = Uint128::zero(); + COLLECTIONS.save(deps.storage, collection_id, &collection)?; + + // Decrement curator count + let count = CURATOR_COLLECTION_COUNT + .may_load(deps.storage, &info.sender)? + .unwrap_or(1); + CURATOR_COLLECTION_COUNT.save(deps.storage, &info.sender, &count.saturating_sub(1))?; + + let msg = BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: config.denom, + amount, + }], + }; + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "withdraw_bond") + .add_attribute("collection_id", collection_id.to_string()) + .add_attribute("amount", amount)) +} + +#[allow(clippy::too_many_arguments)] +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + community_pool: Option, + min_curation_bond: Option, + curation_fee_rate_bps: Option, + challenge_deposit: Option, + slash_percentage_bps: Option, + activation_delay_seconds: Option, + unbonding_period_seconds: Option, + bond_top_up_window_seconds: Option, + min_quality_score: Option, + max_collections_per_curator: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "only admin can update config".to_string(), + }); + } + + if let Some(cp) = community_pool { + config.community_pool = deps.api.addr_validate(&cp)?; + } + if let Some(v) = min_curation_bond { + config.min_curation_bond = v; + } + if let Some(v) = curation_fee_rate_bps { + config.curation_fee_rate_bps = v; + } + if let Some(v) = challenge_deposit { + config.challenge_deposit = v; + } + if let Some(v) = slash_percentage_bps { + config.slash_percentage_bps = v; + } + if let Some(v) = activation_delay_seconds { + config.activation_delay_seconds = v; + } + if let Some(v) = unbonding_period_seconds { + config.unbonding_period_seconds = v; + } + if let Some(v) = bond_top_up_window_seconds { + config.bond_top_up_window_seconds = v; + } + if let Some(v) = min_quality_score { + config.min_quality_score = v; + } + if let Some(v) = max_collections_per_curator { + config.max_collections_per_curator = v; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Collection { collection_id } => { + to_json_binary(&query_collection(deps, collection_id)?) + } + QueryMsg::Collections { + status, + curator, + start_after, + limit, + } => to_json_binary(&query_collections(deps, status, curator, start_after, limit)?), + QueryMsg::QualityScore { batch_denom } => { + to_json_binary(&query_quality_score(deps, batch_denom)?) + } + QueryMsg::Challenge { challenge_id } => { + to_json_binary(&query_challenge(deps, challenge_id)?) + } + QueryMsg::CuratorStats { curator } => to_json_binary(&query_curator_stats(deps, curator)?), + QueryMsg::BondStatus { collection_id } => { + to_json_binary(&query_bond_status(deps, collection_id)?) + } + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + admin: config.admin.to_string(), + community_pool: config.community_pool.to_string(), + min_curation_bond: config.min_curation_bond, + curation_fee_rate_bps: config.curation_fee_rate_bps, + challenge_deposit: config.challenge_deposit, + slash_percentage_bps: config.slash_percentage_bps, + activation_delay_seconds: config.activation_delay_seconds, + unbonding_period_seconds: config.unbonding_period_seconds, + bond_top_up_window_seconds: config.bond_top_up_window_seconds, + min_quality_score: config.min_quality_score, + max_collections_per_curator: config.max_collections_per_curator, + denom: config.denom, + }) +} + +fn query_collection(deps: Deps, collection_id: u64) -> StdResult { + let collection = COLLECTIONS.load(deps.storage, collection_id)?; + Ok(CollectionResponse { collection }) +} + +fn query_collections( + deps: Deps, + status: Option, + curator: Option, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| cw_storage_plus::Bound::exclusive(s)); + + let curator_addr = curator + .map(|c| deps.api.addr_validate(&c)) + .transpose()?; + + let collections: Vec = COLLECTIONS + .range(deps.storage, start, None, Order::Ascending) + .filter_map(|item| { + let (_, coll) = item.ok()?; + if let Some(ref s) = status { + if &coll.status != s { + return None; + } + } + if let Some(ref c) = curator_addr { + if &coll.curator != c { + return None; + } + } + Some(coll) + }) + .take(limit) + .collect(); + + Ok(CollectionsResponse { collections }) +} + +fn query_quality_score(deps: Deps, batch_denom: String) -> StdResult { + let qs = QUALITY_SCORES.may_load(deps.storage, &batch_denom)?; + Ok(QualityScoreResponse { quality_score: qs }) +} + +fn query_challenge(deps: Deps, challenge_id: u64) -> StdResult { + let challenge = CHALLENGES.load(deps.storage, challenge_id)?; + Ok(ChallengeResponse { challenge }) +} + +fn query_curator_stats(deps: Deps, curator: String) -> StdResult { + let curator_addr = deps.api.addr_validate(&curator)?; + let config = CONFIG.load(deps.storage)?; + let count = CURATOR_COLLECTION_COUNT + .may_load(deps.storage, &curator_addr)? + .unwrap_or(0); + Ok(CuratorStatsResponse { + curator, + collection_count: count, + max_collections: config.max_collections_per_curator, + }) +} + +fn query_bond_status(deps: Deps, collection_id: u64) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let collection = COLLECTIONS.load(deps.storage, collection_id)?; + Ok(BondStatusResponse { + collection_id, + bond_amount: collection.bond_amount, + min_required: config.min_curation_bond, + is_sufficient: collection.bond_amount >= config.min_curation_bond, + denom: config.denom, + }) +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +/// Extract the single coin payment matching expected denom +fn must_pay(info: &MessageInfo, denom: &str) -> Result { + if info.funds.len() != 1 { + return Err(ContractError::WrongDenom { + expected: denom.to_string(), + got: format!("{} coins sent", info.funds.len()), + }); + } + let coin = &info.funds[0]; + if coin.denom != denom { + return Err(ContractError::WrongDenom { + expected: denom.to_string(), + got: coin.denom.clone(), + }); + } + Ok(coin.amount) +} + +/// Check if all challenges for a collection are resolved +fn all_challenges_resolved( + storage: &dyn cosmwasm_std::Storage, + collection_id: u64, +) -> StdResult { + let challenge_ids = COLLECTION_CHALLENGES + .may_load(storage, collection_id)? + .unwrap_or_default(); + for cid in challenge_ids { + let challenge = CHALLENGES.load(storage, cid)?; + if challenge.resolution.is_none() { + return Ok(false); + } + } + Ok(true) +} + +// ── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{Addr, Coin, Uint128}; + + const DENOM: &str = "uregen"; + const MIN_BOND: u128 = 1_000_000_000; + const CHALLENGE_DEP: u128 = 100_000_000; + + fn addr(input: &str) -> Addr { + MockApi::default().addr_make(input) + } + + fn setup_contract(deps: DepsMut) -> MessageInfo { + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + community_pool: addr("community_pool").to_string(), + min_curation_bond: None, + curation_fee_rate_bps: None, + challenge_deposit: None, + slash_percentage_bps: None, + activation_delay_seconds: Some(0), // no delay for easier testing + unbonding_period_seconds: Some(0), // no unbonding for easier testing + bond_top_up_window_seconds: None, + min_quality_score: None, + max_collections_per_curator: None, + denom: DENOM.to_string(), + }; + instantiate(deps, mock_env(), info.clone(), msg).unwrap(); + info + } + + fn setup_contract_with_delays(deps: DepsMut) -> MessageInfo { + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + community_pool: addr("community_pool").to_string(), + min_curation_bond: None, + curation_fee_rate_bps: None, + challenge_deposit: None, + slash_percentage_bps: None, + activation_delay_seconds: Some(172_800), // 48h + unbonding_period_seconds: Some(1_209_600), // 14 days + bond_top_up_window_seconds: None, + min_quality_score: None, + max_collections_per_curator: None, + denom: DENOM.to_string(), + }; + instantiate(deps, mock_env(), info.clone(), msg).unwrap(); + info + } + + fn create_collection(deps: DepsMut, curator: &Addr) -> u64 { + let info = message_info(curator, &[Coin::new(MIN_BOND, DENOM)]); + let msg = ExecuteMsg::CreateCollection { + name: "Top Carbon Batches".to_string(), + criteria: "Verified carbon removal credits with >90% permanence".to_string(), + }; + let res = execute(deps, mock_env(), info, msg).unwrap(); + res.attributes + .iter() + .find(|a| a.key == "collection_id") + .unwrap() + .value + .parse() + .unwrap() + } + + fn activate_collection(deps: DepsMut, curator: &Addr, collection_id: u64) { + let info = message_info(curator, &[]); + execute( + deps, + mock_env(), + info, + ExecuteMsg::ActivateCollection { collection_id }, + ) + .unwrap(); + } + + fn submit_quality_score(deps: DepsMut, admin: &Addr, batch_denom: &str, score: u32) { + let info = message_info(admin, &[]); + execute( + deps, + mock_env(), + info, + ExecuteMsg::SubmitQualityScore { + batch_denom: batch_denom.to_string(), + score, + confidence: 800, + }, + ) + .unwrap(); + } + + // ── Test 1: Instantiate ─────────────────────────────────────────── + + #[test] + fn test_instantiate() { + let mut deps = mock_dependencies(); + let info = setup_contract(deps.as_mut()); + + let config: ConfigResponse = + cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()) + .unwrap(); + + assert_eq!(config.admin, info.sender.to_string()); + assert_eq!(config.min_curation_bond, Uint128::new(MIN_BOND)); + assert_eq!(config.curation_fee_rate_bps, 50); + assert_eq!(config.challenge_deposit, Uint128::new(CHALLENGE_DEP)); + assert_eq!(config.slash_percentage_bps, 2000); + assert_eq!(config.activation_delay_seconds, 0); // overridden for tests + assert_eq!(config.unbonding_period_seconds, 0); + assert_eq!(config.bond_top_up_window_seconds, 604_800); + assert_eq!(config.min_quality_score, 300); + assert_eq!(config.max_collections_per_curator, 5); + assert_eq!(config.denom, DENOM); + } + + // ── Test 2: Create + Activate Collection ────────────────────────── + + #[test] + fn test_create_and_activate_collection() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let curator = addr("curator1"); + let id = create_collection(deps.as_mut(), &curator); + assert_eq!(id, 1); + + // Query collection — should be Proposed + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Proposed); + assert_eq!(resp.collection.curator, curator); + assert_eq!(resp.collection.bond_amount, Uint128::new(MIN_BOND)); + assert!(resp.collection.batches.is_empty()); + + // Activate (delay=0 in test config) + activate_collection(deps.as_mut(), &curator, 1); + + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { collection_id: 1 }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Active); + + // Curator stats + let stats: CuratorStatsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::CuratorStats { + curator: curator.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(stats.collection_count, 1); + assert_eq!(stats.max_collections, 5); + } + + // ── Test 3: Add Batch to Collection ─────────────────────────────── + + #[test] + fn test_add_batch_to_collection() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let curator = addr("curator1"); + let batch = "C01-001-20250101-20251231-001"; + + // Create + activate collection + let id = create_collection(deps.as_mut(), &curator); + activate_collection(deps.as_mut(), &curator, id); + + // Submit quality score for the batch + submit_quality_score(deps.as_mut(), &admin, batch, 500); + + // Add batch + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: id, + batch_denom: batch.to_string(), + }, + ) + .unwrap(); + + // Verify batch is in collection + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { collection_id: id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.batches.len(), 1); + assert_eq!(resp.collection.batches[0], batch); + + // Adding batch with score below minimum should fail + let low_batch = "C01-001-20250101-20251231-002"; + submit_quality_score(deps.as_mut(), &admin, low_batch, 200); + + let info = message_info(&curator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: id, + batch_denom: low_batch.to_string(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::QualityScoreTooLow { .. })); + + // Adding batch with no quality score should fail + let unknown_batch = "C01-001-20250101-20251231-003"; + let info = message_info(&curator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: id, + batch_denom: unknown_batch.to_string(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::NoQualityScore { .. })); + } + + // ── Test 4: Challenge + Resolve ─────────────────────────────────── + + #[test] + fn test_challenge_and_resolve() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let curator = addr("curator1"); + let challenger = addr("challenger1"); + let batch = "C01-001-20250101-20251231-001"; + + // Create + activate + add batch + let coll_id = create_collection(deps.as_mut(), &curator); + activate_collection(deps.as_mut(), &curator, coll_id); + submit_quality_score(deps.as_mut(), &admin, batch, 500); + + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: coll_id, + batch_denom: batch.to_string(), + }, + ) + .unwrap(); + + // File challenge + let info = message_info(&challenger, &[Coin::new(CHALLENGE_DEP, DENOM)]); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ChallengeBatchInclusion { + collection_id: coll_id, + batch_denom: batch.to_string(), + evidence: "Batch credits are from a revoked project".to_string(), + }, + ) + .unwrap(); + + let challenge_id: u64 = res + .attributes + .iter() + .find(|a| a.key == "challenge_id") + .unwrap() + .value + .parse() + .unwrap(); + assert_eq!(challenge_id, 1); + + // Collection should be UnderReview + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::UnderReview); + + // Resolve: CuratorWins — challenger loses deposit + let info = message_info(&admin, &[]); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id, + resolution: ChallengeResolution::CuratorWins, + }, + ) + .unwrap(); + + // Should have one bank message (deposit to community) + assert_eq!(res.messages.len(), 1); + + // Collection back to Active + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Active); + // Bond unchanged + assert_eq!(resp.collection.bond_amount, Uint128::new(MIN_BOND)); + } + + // ── Test 5: Challenge ChallengerWins — slash + suspend ──────────── + + #[test] + fn test_challenge_challenger_wins_slash() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let curator = addr("curator1"); + let challenger = addr("challenger1"); + let batch = "C01-001-20250101-20251231-001"; + + let coll_id = create_collection(deps.as_mut(), &curator); + activate_collection(deps.as_mut(), &curator, coll_id); + submit_quality_score(deps.as_mut(), &admin, batch, 500); + + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: coll_id, + batch_denom: batch.to_string(), + }, + ) + .unwrap(); + + // File challenge + let info = message_info(&challenger, &[Coin::new(CHALLENGE_DEP, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ChallengeBatchInclusion { + collection_id: coll_id, + batch_denom: batch.to_string(), + evidence: "Invalid methodology".to_string(), + }, + ) + .unwrap(); + + // Resolve: ChallengerWins + let info = message_info(&admin, &[]); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolution::ChallengerWins, + }, + ) + .unwrap(); + + // Should have two bank messages: challenger gets deposit + slash share, community gets slash share + assert_eq!(res.messages.len(), 2); + + // Bond should be slashed by 20% + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + + // 1_000_000_000 * 20% = 200_000_000 slashed, remaining = 800_000_000 + assert_eq!( + resp.collection.bond_amount, + Uint128::new(800_000_000) + ); + // Bond below min (1B) so should be Suspended + assert_eq!(resp.collection.status, CollectionStatus::Suspended); + assert!(resp.collection.suspension_expires_at.is_some()); + + // Bond status query + let bond: BondStatusResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::BondStatus { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert!(!bond.is_sufficient); + assert_eq!(bond.bond_amount, Uint128::new(800_000_000)); + } + + // ── Test 6: Quality Score Auto-Removal ──────────────────────────── + + #[test] + fn test_quality_score_auto_removal() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let curator = addr("curator1"); + let batch = "C01-001-20250101-20251231-001"; + + let coll_id = create_collection(deps.as_mut(), &curator); + activate_collection(deps.as_mut(), &curator, coll_id); + + // Submit good score, add batch + submit_quality_score(deps.as_mut(), &admin, batch, 500); + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: coll_id, + batch_denom: batch.to_string(), + }, + ) + .unwrap(); + + // Verify batch is in collection + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.batches.len(), 1); + + // Now submit a score below minimum (300) — should auto-remove + submit_quality_score(deps.as_mut(), &admin, batch, 100); + + // Batch should be removed + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert!(resp.collection.batches.is_empty()); + + // Quality score query should return the updated score + let qs: QualityScoreResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::QualityScore { + batch_denom: batch.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(qs.quality_score.unwrap().score, 100); + } + + // ── Test 7: Max collections per curator ─────────────────────────── + + #[test] + fn test_max_collections_per_curator() { + let mut deps = mock_dependencies(); + + // Setup with max 2 collections + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + community_pool: addr("community_pool").to_string(), + min_curation_bond: None, + curation_fee_rate_bps: None, + challenge_deposit: None, + slash_percentage_bps: None, + activation_delay_seconds: Some(0), + unbonding_period_seconds: Some(0), + bond_top_up_window_seconds: None, + min_quality_score: None, + max_collections_per_curator: Some(2), + denom: DENOM.to_string(), + }; + instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let curator = addr("curator1"); + create_collection(deps.as_mut(), &curator); + create_collection(deps.as_mut(), &curator); + + // Third should fail + let info = message_info(&curator, &[Coin::new(MIN_BOND, DENOM)]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CreateCollection { + name: "Third".to_string(), + criteria: "Too many".to_string(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::MaxCollectionsExceeded { max: 2 })); + } + + // ── Test 8: Activation delay enforcement ────────────────────────── + + #[test] + fn test_activation_delay_enforcement() { + let mut deps = mock_dependencies(); + setup_contract_with_delays(deps.as_mut()); + + let curator = addr("curator1"); + let coll_id = create_collection(deps.as_mut(), &curator); + + // Try activating immediately — should fail + let info = message_info(&curator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ActivateCollection { + collection_id: coll_id, + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::ActivationDelayNotElapsed)); + + // Advance time past 48h + let mut env = mock_env(); + env.block.time = env.block.time.plus_seconds(172_801); + + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ActivateCollection { + collection_id: coll_id, + }, + ) + .unwrap(); + + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Active); + } + + // ── Test 9: Close + withdraw bond ───────────────────────────────── + + #[test] + fn test_close_and_withdraw_bond() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); // unbonding = 0 for easy testing + + let curator = addr("curator1"); + let coll_id = create_collection(deps.as_mut(), &curator); + activate_collection(deps.as_mut(), &curator, coll_id); + + // Close collection + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::CloseCollection { + collection_id: coll_id, + }, + ) + .unwrap(); + + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Closed); + + // Withdraw bond + let info = message_info(&curator, &[]); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::WithdrawBond { + collection_id: coll_id, + }, + ) + .unwrap(); + + // Should send bond back + assert_eq!(res.messages.len(), 1); + + // Curator count should decrement + let stats: CuratorStatsResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::CuratorStats { + curator: curator.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(stats.collection_count, 0); + } + + // ── Test 10: Top up bond restores active ────────────────────────── + + #[test] + fn test_top_up_bond_restores_active() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let curator = addr("curator1"); + let challenger = addr("challenger1"); + let batch = "C01-001-20250101-20251231-001"; + + let coll_id = create_collection(deps.as_mut(), &curator); + activate_collection(deps.as_mut(), &curator, coll_id); + submit_quality_score(deps.as_mut(), &admin, batch, 500); + + let info = message_info(&curator, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddToCollection { + collection_id: coll_id, + batch_denom: batch.to_string(), + }, + ) + .unwrap(); + + // Challenge + ChallengerWins to get it suspended + let info = message_info(&challenger, &[Coin::new(CHALLENGE_DEP, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ChallengeBatchInclusion { + collection_id: coll_id, + batch_denom: batch.to_string(), + evidence: "Bad batch".to_string(), + }, + ) + .unwrap(); + + let info = message_info(&admin, &[]); + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + resolution: ChallengeResolution::ChallengerWins, + }, + ) + .unwrap(); + + // Confirm suspended + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Suspended); + + // Top up with enough to restore (need 200M more to reach 1B) + let info = message_info(&curator, &[Coin::new(200_000_000u128, DENOM)]); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::TopUpBond { + collection_id: coll_id, + }, + ) + .unwrap(); + + // Should be Active again + let status_attr = res + .attributes + .iter() + .find(|a| a.key == "status") + .unwrap(); + assert_eq!(status_attr.value, "Active"); + + let resp: CollectionResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Collection { + collection_id: coll_id, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.collection.status, CollectionStatus::Active); + assert_eq!(resp.collection.bond_amount, Uint128::new(1_000_000_000)); + } +} diff --git a/contracts/marketplace-curation/src/error.rs b/contracts/marketplace-curation/src/error.rs new file mode 100644 index 0000000..6a36544 --- /dev/null +++ b/contracts/marketplace-curation/src/error.rs @@ -0,0 +1,75 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + #[error("Collection {id} not found")] + CollectionNotFound { id: u64 }, + + #[error("Challenge {id} not found")] + ChallengeNotFound { id: u64 }, + + #[error("Invalid collection status: expected {expected}, got {actual}")] + InvalidCollectionStatus { expected: String, actual: String }, + + #[error("Insufficient bond: required {required}, sent {sent}")] + InsufficientBond { required: String, sent: String }, + + #[error("Insufficient deposit: required {required}, sent {sent}")] + InsufficientDeposit { required: String, sent: String }, + + #[error("Wrong denomination: expected {expected}, got {got}")] + WrongDenom { expected: String, got: String }, + + #[error("Curator has reached maximum collections ({max})")] + MaxCollectionsExceeded { max: u32 }, + + #[error("Activation delay has not elapsed yet")] + ActivationDelayNotElapsed, + + #[error("Collection has pending challenges")] + PendingChallenges, + + #[error("Batch {batch_denom} quality score {score} is below minimum {min}")] + QualityScoreTooLow { + batch_denom: String, + score: u32, + min: u32, + }, + + #[error("Batch {batch_denom} has no quality score on record")] + NoQualityScore { batch_denom: String }, + + #[error("Batch {batch_denom} is already in collection {collection_id}")] + BatchAlreadyInCollection { + batch_denom: String, + collection_id: u64, + }, + + #[error("Batch {batch_denom} is not in collection {collection_id}")] + BatchNotInCollection { + batch_denom: String, + collection_id: u64, + }, + + #[error("Challenge already resolved")] + ChallengeAlreadyResolved, + + #[error("Quality score must be between 0 and 1000, got {value}")] + InvalidScore { value: u32 }, + + #[error("Confidence must be between 0 and 1000, got {value}")] + InvalidConfidence { value: u32 }, + + #[error("Bond is below minimum after slash — collection suspended")] + BondBelowMinimum, + + #[error("Unbonding period has not elapsed yet")] + UnbondingNotComplete, +} diff --git a/contracts/marketplace-curation/src/lib.rs b/contracts/marketplace-curation/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/marketplace-curation/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/marketplace-curation/src/msg.rs b/contracts/marketplace-curation/src/msg.rs new file mode 100644 index 0000000..5291b5d --- /dev/null +++ b/contracts/marketplace-curation/src/msg.rs @@ -0,0 +1,202 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use crate::state::{Challenge, ChallengeResolution, Collection, CollectionStatus, QualityScore}; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cw_serde] +pub struct InstantiateMsg { + /// Community pool address for fee/slash collection + pub community_pool: String, + /// Minimum bond to create a collection (default 1_000_000_000 uregen = 1000 REGEN) + pub min_curation_bond: Option, + /// Curation fee rate in basis points (default 50 = 0.5%) + pub curation_fee_rate_bps: Option, + /// Challenge deposit amount (default 100_000_000 uregen = 100 REGEN) + pub challenge_deposit: Option, + /// Slash percentage on lost challenge in bps (default 2000 = 20%) + pub slash_percentage_bps: Option, + /// Seconds before a proposed collection can activate (default 172800 = 48h) + pub activation_delay_seconds: Option, + /// Unbonding period in seconds (default 1209600 = 14 days) + pub unbonding_period_seconds: Option, + /// Window to top up bond after suspension in seconds (default 604800 = 7 days) + pub bond_top_up_window_seconds: Option, + /// Minimum quality score for batch inclusion (default 300) + pub min_quality_score: Option, + /// Max collections per curator (default 5) + pub max_collections_per_curator: Option, + /// Accepted payment denomination + pub denom: String, +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cw_serde] +pub enum ExecuteMsg { + /// Create a new curated collection (must attach bond >= min_curation_bond) + CreateCollection { + name: String, + criteria: String, + }, + + /// Activate a Proposed collection after activation delay has elapsed + ActivateCollection { + collection_id: u64, + }, + + /// Add a batch to an Active collection (curator only, batch must meet quality threshold) + AddToCollection { + collection_id: u64, + batch_denom: String, + }, + + /// Remove a batch from a collection (curator only) + RemoveFromCollection { + collection_id: u64, + batch_denom: String, + }, + + /// Close a collection and begin unbonding (no pending challenges allowed) + CloseCollection { + collection_id: u64, + }, + + /// Top up bond on a Suspended collection (attach funds); restores to Active if bond >= min + TopUpBond { + collection_id: u64, + }, + + /// Challenge a batch's inclusion in a collection (must attach challenge deposit) + ChallengeBatchInclusion { + collection_id: u64, + batch_denom: String, + evidence: String, + }, + + /// Admin resolves a challenge + ResolveChallenge { + challenge_id: u64, + resolution: ChallengeResolution, + }, + + /// Submit a quality score for a batch (admin/agent only) + SubmitQualityScore { + batch_denom: String, + score: u32, + confidence: u32, + }, + + /// Withdraw bond after collection is Closed and unbonding period has elapsed + WithdrawBond { + collection_id: u64, + }, + + /// Admin updates governance parameters + UpdateConfig { + community_pool: Option, + min_curation_bond: Option, + curation_fee_rate_bps: Option, + challenge_deposit: Option, + slash_percentage_bps: Option, + activation_delay_seconds: Option, + unbonding_period_seconds: Option, + bond_top_up_window_seconds: Option, + min_quality_score: Option, + max_collections_per_curator: Option, + }, +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the contract configuration + #[returns(ConfigResponse)] + Config {}, + + /// Returns a single collection by ID + #[returns(CollectionResponse)] + Collection { collection_id: u64 }, + + /// Returns collections filtered by status and/or curator (paginated) + #[returns(CollectionsResponse)] + Collections { + status: Option, + curator: Option, + start_after: Option, + limit: Option, + }, + + /// Returns the quality score for a batch denom + #[returns(QualityScoreResponse)] + QualityScore { batch_denom: String }, + + /// Returns a single challenge by ID + #[returns(ChallengeResponse)] + Challenge { challenge_id: u64 }, + + /// Returns stats for a curator + #[returns(CuratorStatsResponse)] + CuratorStats { curator: String }, + + /// Returns bond status for a collection (amount, min required, is_sufficient) + #[returns(BondStatusResponse)] + BondStatus { collection_id: u64 }, +} + +// ── Query responses ──────────────────────────────────────────────────── + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub community_pool: String, + pub min_curation_bond: Uint128, + pub curation_fee_rate_bps: u64, + pub challenge_deposit: Uint128, + pub slash_percentage_bps: u64, + pub activation_delay_seconds: u64, + pub unbonding_period_seconds: u64, + pub bond_top_up_window_seconds: u64, + pub min_quality_score: u32, + pub max_collections_per_curator: u32, + pub denom: String, +} + +#[cw_serde] +pub struct CollectionResponse { + pub collection: Collection, +} + +#[cw_serde] +pub struct CollectionsResponse { + pub collections: Vec, +} + +#[cw_serde] +pub struct QualityScoreResponse { + pub quality_score: Option, +} + +#[cw_serde] +pub struct ChallengeResponse { + pub challenge: Challenge, +} + +#[cw_serde] +pub struct CuratorStatsResponse { + pub curator: String, + pub collection_count: u32, + pub max_collections: u32, +} + +#[cw_serde] +pub struct BondStatusResponse { + pub collection_id: u64, + pub bond_amount: Uint128, + pub min_required: Uint128, + pub is_sufficient: bool, + pub denom: String, +} diff --git a/contracts/marketplace-curation/src/state.rs b/contracts/marketplace-curation/src/state.rs new file mode 100644 index 0000000..7fe76b7 --- /dev/null +++ b/contracts/marketplace-curation/src/state.rs @@ -0,0 +1,146 @@ +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ── Configuration ────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + /// Contract administrator (can resolve challenges, update config) + pub admin: Addr, + /// Community pool address for fee/slash collection + pub community_pool: Addr, + /// Minimum bond a curator must attach to create a collection (default 1000 REGEN) + pub min_curation_bond: Uint128, + /// Fee rate in basis points curators earn on trades (default 50 = 0.5%) + pub curation_fee_rate_bps: u64, + /// Deposit required to file a challenge (default 100 REGEN) + pub challenge_deposit: Uint128, + /// Percentage of curator bond slashed on lost challenge (default 2000 = 20%) + pub slash_percentage_bps: u64, + /// Seconds after creation before a collection can activate (default 172800 = 48h) + pub activation_delay_seconds: u64, + /// Seconds a curator must wait after closing before bond is returned (default 1209600 = 14 days) + pub unbonding_period_seconds: u64, + /// Window in seconds to top up bond on a suspended collection (default 604800 = 7 days) + pub bond_top_up_window_seconds: u64, + /// Minimum quality score for a batch to be added to a collection (default 300, scale 0-1000) + pub min_quality_score: u32, + /// Maximum collections a single curator can have (default 5) + pub max_collections_per_curator: u32, + /// Accepted payment denomination + pub denom: String, +} + +// ── Collection ───────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum CollectionStatus { + Proposed, + Active, + UnderReview, + Suspended, + Closed, +} + +impl std::fmt::Display for CollectionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CollectionStatus::Proposed => write!(f, "Proposed"), + CollectionStatus::Active => write!(f, "Active"), + CollectionStatus::UnderReview => write!(f, "UnderReview"), + CollectionStatus::Suspended => write!(f, "Suspended"), + CollectionStatus::Closed => write!(f, "Closed"), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Collection { + pub id: u64, + /// Curator who created and manages this collection + pub curator: Addr, + /// Human-readable collection name + pub name: String, + /// Curation criteria description + pub criteria: String, + /// Current lifecycle status + pub status: CollectionStatus, + /// Amount of REGEN bonded by curator + pub bond_amount: Uint128, + /// Credit batch denoms included in this collection + pub batches: Vec, + /// When the collection was created + pub created_at: Timestamp, + /// Earliest time the collection can be activated + pub activates_at: Timestamp, + /// If suspended, when the suspension window expires (curator must top up by then) + pub suspension_expires_at: Option, + /// When the collection was closed (for unbonding calculation) + pub closed_at: Option, +} + +// ── Challenge ────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ChallengeResolution { + CuratorWins, + ChallengerWins, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Challenge { + pub id: u64, + pub collection_id: u64, + /// Address that filed the challenge + pub challenger: Addr, + /// The specific batch being challenged + pub batch_denom: String, + /// Deposit attached by challenger + pub deposit: Uint128, + /// Evidence supporting the challenge + pub evidence: String, + /// When the challenge was filed + pub filed_at: Timestamp, + /// Resolution outcome, None if still pending + pub resolution: Option, +} + +// ── Quality Score ────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct QualityScore { + /// The credit batch denomination + pub batch_denom: String, + /// Quality score (0-1000) + pub score: u32, + /// Confidence level (0-1000) + pub confidence: u32, + /// When the score was computed/submitted + pub computed_at: Timestamp, +} + +// ── Storage keys ─────────────────────────────────────────────────────── + +pub const CONFIG: Item = Item::new("config"); +pub const NEXT_COLLECTION_ID: Item = Item::new("next_collection_id"); +pub const NEXT_CHALLENGE_ID: Item = Item::new("next_challenge_id"); + +/// Collection ID -> Collection +pub const COLLECTIONS: Map = Map::new("collections"); + +/// Challenge ID -> Challenge +pub const CHALLENGES: Map = Map::new("challenges"); + +/// Collection ID -> Vec of Challenge IDs +pub const COLLECTION_CHALLENGES: Map> = Map::new("collection_challenges"); + +/// Batch denom -> QualityScore +pub const QUALITY_SCORES: Map<&str, QualityScore> = Map::new("quality_scores"); + +/// Reverse index: batch denom -> list of collection IDs containing that batch +pub const BATCH_COLLECTIONS: Map<&str, Vec> = Map::new("batch_collections"); + +/// Curator address -> number of collections they own +pub const CURATOR_COLLECTION_COUNT: Map<&Addr, u32> = Map::new("curator_collection_count"); diff --git a/contracts/service-escrow/Cargo.toml b/contracts/service-escrow/Cargo.toml new file mode 100644 index 0000000..54a3427 --- /dev/null +++ b/contracts/service-escrow/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "service-escrow" +version = "0.1.0" +edition = "2021" +description = "M009 Service Provision Escrow — milestone-based payment escrow with dispute resolution for Regen Network ecosystem services" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true diff --git a/contracts/service-escrow/src/contract.rs b/contracts/service-escrow/src/contract.rs new file mode 100644 index 0000000..5e67a7d --- /dev/null +++ b/contracts/service-escrow/src/contract.rs @@ -0,0 +1,1639 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + AgreementResponse, AgreementsResponse, ConfigResponse, DisputeResponse, EscrowBalanceResponse, + ExecuteMsg, InstantiateMsg, MilestoneInput, MilestonesResponse, QueryMsg, +}; +use crate::state::{ + AgreementStatus, Config, Dispute, DisputeResolution, Milestone, MilestoneStatus, + ServiceAgreement, AGREEMENTS, CONFIG, DISPUTES, NEXT_AGREEMENT_ID, +}; + +const CONTRACT_NAME: &str = "crates.io:service-escrow"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const DEFAULT_QUERY_LIMIT: u32 = 10; +const MAX_QUERY_LIMIT: u32 = 30; + +// Governance parameter bounds (basis points) +const MIN_BOND_RATIO: u64 = 500; // 5% +const MAX_BOND_RATIO: u64 = 2500; // 25% +const MIN_PLATFORM_FEE: u64 = 0; +const MAX_PLATFORM_FEE: u64 = 500; // 5% +const MIN_CANCEL_FEE: u64 = 0; +const MAX_CANCEL_FEE: u64 = 1000; // 10% +const MIN_ARBITER_FEE: u64 = 100; // 1% +const MAX_ARBITER_FEE: u64 = 1500; // 15% + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let bond_ratio = msg.provider_bond_ratio_bps.unwrap_or(1000); + let platform_fee = msg.platform_fee_rate_bps.unwrap_or(100); + let cancel_fee = msg.cancellation_fee_rate_bps.unwrap_or(200); + let arbiter_fee = msg.arbiter_fee_rate_bps.unwrap_or(500); + + validate_bond_ratio(bond_ratio)?; + validate_fee_rate(platform_fee, MIN_PLATFORM_FEE, MAX_PLATFORM_FEE, "platform")?; + validate_fee_rate(cancel_fee, MIN_CANCEL_FEE, MAX_CANCEL_FEE, "cancellation")?; + validate_fee_rate(arbiter_fee, MIN_ARBITER_FEE, MAX_ARBITER_FEE, "arbiter")?; + + let config = Config { + admin: info.sender.clone(), + arbiter_dao: deps.api.addr_validate(&msg.arbiter_dao)?, + community_pool: deps.api.addr_validate(&msg.community_pool)?, + provider_bond_ratio_bps: bond_ratio, + platform_fee_rate_bps: platform_fee, + cancellation_fee_rate_bps: cancel_fee, + arbiter_fee_rate_bps: arbiter_fee, + review_period_seconds: msg.review_period_seconds.unwrap_or(1_209_600), // 14 days + max_milestones: msg.max_milestones.unwrap_or(20), + max_revisions: msg.max_revisions.unwrap_or(3), + denom: msg.denom, + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &config)?; + NEXT_AGREEMENT_ID.save(deps.storage, &1u64)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::ProposeAgreement { + provider, + service_type, + description, + milestones, + } => execute_propose(deps, env, info, provider, service_type, description, milestones), + ExecuteMsg::AcceptAgreement { agreement_id } => { + execute_accept(deps, env, info, agreement_id) + } + ExecuteMsg::FundAgreement { agreement_id } => { + execute_fund(deps, env, info, agreement_id) + } + ExecuteMsg::StartAgreement { agreement_id } => { + execute_start(deps, env, info, agreement_id) + } + ExecuteMsg::SubmitMilestone { + agreement_id, + milestone_index, + deliverable_iri, + } => execute_submit_milestone(deps, env, info, agreement_id, milestone_index, deliverable_iri), + ExecuteMsg::ApproveMilestone { + agreement_id, + milestone_index, + } => execute_approve_milestone(deps, env, info, agreement_id, milestone_index), + ExecuteMsg::ReviseMilestone { + agreement_id, + milestone_index, + deliverable_iri, + } => execute_revise_milestone(deps, env, info, agreement_id, milestone_index, deliverable_iri), + ExecuteMsg::DisputeMilestone { + agreement_id, + milestone_index, + reason, + } => execute_dispute_milestone(deps, env, info, agreement_id, milestone_index, reason), + ExecuteMsg::ResolveDispute { + agreement_id, + resolution, + } => execute_resolve_dispute(deps, env, info, agreement_id, resolution), + ExecuteMsg::CancelAgreement { agreement_id } => { + execute_cancel(deps, env, info, agreement_id) + } + ExecuteMsg::UpdateConfig { + arbiter_dao, + community_pool, + provider_bond_ratio_bps, + platform_fee_rate_bps, + cancellation_fee_rate_bps, + arbiter_fee_rate_bps, + review_period_seconds, + max_milestones, + max_revisions, + } => execute_update_config( + deps, info, arbiter_dao, community_pool, provider_bond_ratio_bps, + platform_fee_rate_bps, cancellation_fee_rate_bps, arbiter_fee_rate_bps, + review_period_seconds, max_milestones, max_revisions, + ), + } +} + +fn execute_propose( + deps: DepsMut, env: Env, info: MessageInfo, + provider: String, service_type: String, description: String, + milestones: Vec, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let provider_addr = deps.api.addr_validate(&provider)?; + + if info.sender == provider_addr { + return Err(ContractError::SelfAgreement); + } + + let ms_count = milestones.len() as u32; + if ms_count == 0 || ms_count > config.max_milestones { + return Err(ContractError::InvalidMilestoneCount { + max: config.max_milestones, got: ms_count, + }); + } + + let total_escrow: Uint128 = milestones.iter().map(|m| m.payment_amount).sum(); + if total_escrow.is_zero() { + return Err(ContractError::InsufficientFunds { + required: "non-zero".to_string(), sent: "0".to_string(), + }); + } + + let provider_bond = total_escrow.multiply_ratio(config.provider_bond_ratio_bps, 10_000u128); + let id = NEXT_AGREEMENT_ID.load(deps.storage)?; + + let ms: Vec = milestones.iter().enumerate().map(|(i, m)| Milestone { + index: i as u32, description: m.description.clone(), payment: m.payment_amount, + status: MilestoneStatus::Pending, deliverable_iri: None, + submitted_at: None, approved_at: None, revision_count: 0, + }).collect(); + + let agreement = ServiceAgreement { + id, client: info.sender.clone(), provider: provider_addr, + service_type, description, escrow_amount: total_escrow, provider_bond, + milestones: ms, current_milestone: 0, status: AgreementStatus::Proposed, + created_at: env.block.time, funded_at: None, started_at: None, + completed_at: None, provider_accepted: false, client_funded: false, + total_released: Uint128::zero(), total_fees: Uint128::zero(), + }; + + AGREEMENTS.save(deps.storage, id, &agreement)?; + NEXT_AGREEMENT_ID.save(deps.storage, &(id + 1))?; + + Ok(Response::new() + .add_attribute("action", "propose_agreement") + .add_attribute("agreement_id", id.to_string()) + .add_attribute("client", info.sender) + .add_attribute("escrow_amount", total_escrow) + .add_attribute("provider_bond", provider_bond)) +} + +fn execute_accept( + deps: DepsMut, env: Env, info: MessageInfo, agreement_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.provider { + return Err(ContractError::Unauthorized { + reason: "Only the designated provider can accept".to_string(), + }); + } + if agreement.status != AgreementStatus::Proposed { + return Err(ContractError::InvalidStatus { + expected: "Proposed".to_string(), actual: agreement.status.to_string(), + }); + } + if agreement.provider_accepted { + return Err(ContractError::InvalidStatus { + expected: "not yet accepted".to_string(), actual: "already accepted".to_string(), + }); + } + + let bond_coin = must_pay(&info, &config.denom)?; + if bond_coin < agreement.provider_bond { + return Err(ContractError::InsufficientFunds { + required: agreement.provider_bond.to_string(), sent: bond_coin.to_string(), + }); + } + + agreement.provider_accepted = true; + if agreement.client_funded { + agreement.status = AgreementStatus::Funded; + agreement.funded_at = Some(env.block.time); + } + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + Ok(Response::new() + .add_attribute("action", "accept_agreement") + .add_attribute("agreement_id", agreement_id.to_string()) + .add_attribute("bond_posted", bond_coin)) +} + +fn execute_fund( + deps: DepsMut, env: Env, info: MessageInfo, agreement_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.client { + return Err(ContractError::Unauthorized { + reason: "Only the client can fund the escrow".to_string(), + }); + } + if agreement.status != AgreementStatus::Proposed { + return Err(ContractError::InvalidStatus { + expected: "Proposed".to_string(), actual: agreement.status.to_string(), + }); + } + if agreement.client_funded { + return Err(ContractError::InvalidStatus { + expected: "not yet funded".to_string(), actual: "already funded".to_string(), + }); + } + + let paid = must_pay(&info, &config.denom)?; + if paid < agreement.escrow_amount { + return Err(ContractError::InsufficientFunds { + required: agreement.escrow_amount.to_string(), sent: paid.to_string(), + }); + } + + agreement.client_funded = true; + if agreement.provider_accepted { + agreement.status = AgreementStatus::Funded; + agreement.funded_at = Some(env.block.time); + } + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + Ok(Response::new() + .add_attribute("action", "fund_agreement") + .add_attribute("agreement_id", agreement_id.to_string()) + .add_attribute("escrow_funded", paid)) +} + +fn execute_start( + deps: DepsMut, env: Env, info: MessageInfo, agreement_id: u64, +) -> Result { + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.client && info.sender != agreement.provider { + return Err(ContractError::Unauthorized { + reason: "Only client or provider can start the agreement".to_string(), + }); + } + if agreement.status != AgreementStatus::Funded { + return Err(ContractError::InvalidStatus { + expected: "Funded".to_string(), actual: agreement.status.to_string(), + }); + } + + agreement.status = AgreementStatus::InProgress; + agreement.started_at = Some(env.block.time); + if !agreement.milestones.is_empty() { + agreement.milestones[0].status = MilestoneStatus::InProgress; + } + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + Ok(Response::new() + .add_attribute("action", "start_agreement") + .add_attribute("agreement_id", agreement_id.to_string())) +} + +fn execute_submit_milestone( + deps: DepsMut, env: Env, info: MessageInfo, + agreement_id: u64, milestone_index: u32, deliverable_iri: String, +) -> Result { + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.provider { + return Err(ContractError::Unauthorized { + reason: "Only the provider can submit milestones".to_string(), + }); + } + if agreement.status != AgreementStatus::InProgress { + return Err(ContractError::InvalidStatus { + expected: "InProgress".to_string(), actual: agreement.status.to_string(), + }); + } + if milestone_index != agreement.current_milestone { + return Err(ContractError::InvalidMilestoneIndex { + expected: agreement.current_milestone, got: milestone_index, + }); + } + let ms = &agreement.milestones[milestone_index as usize]; + if ms.status != MilestoneStatus::InProgress { + return Err(ContractError::InvalidMilestoneStatus { + index: milestone_index, expected_status: "InProgress".to_string(), + }); + } + + agreement.milestones[milestone_index as usize].status = MilestoneStatus::Submitted; + agreement.milestones[milestone_index as usize].deliverable_iri = Some(deliverable_iri.clone()); + agreement.milestones[milestone_index as usize].submitted_at = Some(env.block.time); + agreement.status = AgreementStatus::MilestoneReview; + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + Ok(Response::new() + .add_attribute("action", "submit_milestone") + .add_attribute("agreement_id", agreement_id.to_string()) + .add_attribute("milestone_index", milestone_index.to_string()) + .add_attribute("deliverable_iri", deliverable_iri)) +} + +fn execute_approve_milestone( + deps: DepsMut, env: Env, info: MessageInfo, + agreement_id: u64, milestone_index: u32, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.client { + return Err(ContractError::Unauthorized { + reason: "Only the client can approve milestones".to_string(), + }); + } + if agreement.status != AgreementStatus::MilestoneReview { + return Err(ContractError::InvalidStatus { + expected: "MilestoneReview".to_string(), actual: agreement.status.to_string(), + }); + } + if milestone_index != agreement.current_milestone { + return Err(ContractError::InvalidMilestoneIndex { + expected: agreement.current_milestone, got: milestone_index, + }); + } + if agreement.milestones[milestone_index as usize].status != MilestoneStatus::Submitted { + return Err(ContractError::InvalidMilestoneStatus { + index: milestone_index, expected_status: "Submitted".to_string(), + }); + } + + let milestone_payment = agreement.milestones[milestone_index as usize].payment; + let platform_fee = milestone_payment.multiply_ratio(config.platform_fee_rate_bps, 10_000u128); + let provider_payment = milestone_payment - platform_fee; + + agreement.milestones[milestone_index as usize].status = MilestoneStatus::Approved; + agreement.milestones[milestone_index as usize].approved_at = Some(env.block.time); + agreement.total_released += provider_payment; + agreement.total_fees += platform_fee; + + let mut msgs = vec![]; + + if !provider_payment.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.provider.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: provider_payment }], + }); + } + if !platform_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: platform_fee }], + }); + } + + let next_idx = milestone_index + 1; + if next_idx >= agreement.milestones.len() as u32 { + agreement.status = AgreementStatus::Completed; + agreement.completed_at = Some(env.block.time); + + if !agreement.provider_bond.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.provider.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: agreement.provider_bond }], + }); + } + let completion_fee = agreement.escrow_amount.multiply_ratio(config.platform_fee_rate_bps, 10_000u128); + if !completion_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: completion_fee }], + }); + agreement.total_fees += completion_fee; + } + } else { + agreement.current_milestone = next_idx; + agreement.milestones[next_idx as usize].status = MilestoneStatus::InProgress; + agreement.status = AgreementStatus::InProgress; + } + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + let mut resp = Response::new() + .add_attribute("action", "approve_milestone") + .add_attribute("agreement_id", agreement_id.to_string()) + .add_attribute("milestone_index", milestone_index.to_string()) + .add_attribute("provider_payment", provider_payment) + .add_attribute("platform_fee", platform_fee); + + for msg in msgs { resp = resp.add_message(msg); } + Ok(resp) +} + +fn execute_revise_milestone( + deps: DepsMut, env: Env, info: MessageInfo, + agreement_id: u64, milestone_index: u32, deliverable_iri: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.provider { + return Err(ContractError::Unauthorized { + reason: "Only the provider can revise milestones".to_string(), + }); + } + if agreement.status != AgreementStatus::MilestoneReview { + return Err(ContractError::InvalidStatus { + expected: "MilestoneReview".to_string(), actual: agreement.status.to_string(), + }); + } + if milestone_index != agreement.current_milestone { + return Err(ContractError::InvalidMilestoneIndex { + expected: agreement.current_milestone, got: milestone_index, + }); + } + + { + let ms = &agreement.milestones[milestone_index as usize]; + if ms.status != MilestoneStatus::Submitted { + return Err(ContractError::InvalidMilestoneStatus { + index: milestone_index, expected_status: "Submitted".to_string(), + }); + } + if ms.revision_count >= config.max_revisions { + return Err(ContractError::MaxRevisionsExceeded { + max: config.max_revisions, index: milestone_index, + }); + } + } + + let idx = milestone_index as usize; + agreement.milestones[idx].revision_count += 1; + agreement.milestones[idx].deliverable_iri = Some(deliverable_iri.clone()); + agreement.milestones[idx].submitted_at = Some(env.block.time); + + let new_revision_count = agreement.milestones[idx].revision_count; + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + Ok(Response::new() + .add_attribute("action", "revise_milestone") + .add_attribute("agreement_id", agreement_id.to_string()) + .add_attribute("milestone_index", milestone_index.to_string()) + .add_attribute("revision_count", new_revision_count.to_string()) + .add_attribute("deliverable_iri", deliverable_iri)) +} + +fn execute_dispute_milestone( + deps: DepsMut, env: Env, info: MessageInfo, + agreement_id: u64, milestone_index: u32, reason: String, +) -> Result { + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != agreement.client { + return Err(ContractError::Unauthorized { + reason: "Only the client can raise a dispute".to_string(), + }); + } + if agreement.status != AgreementStatus::MilestoneReview { + return Err(ContractError::InvalidStatus { + expected: "MilestoneReview".to_string(), actual: agreement.status.to_string(), + }); + } + if milestone_index != agreement.current_milestone { + return Err(ContractError::InvalidMilestoneIndex { + expected: agreement.current_milestone, got: milestone_index, + }); + } + if DISPUTES.may_load(deps.storage, agreement_id)?.is_some() { + return Err(ContractError::DisputeAlreadyExists); + } + + agreement.milestones[milestone_index as usize].status = MilestoneStatus::Disputed; + agreement.status = AgreementStatus::Disputed; + + let dispute = Dispute { + agreement_id, milestone_index, reason: reason.clone(), + raised_by: info.sender.clone(), raised_at: env.block.time, + resolved_at: None, resolution: None, + }; + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + DISPUTES.save(deps.storage, agreement_id, &dispute)?; + + Ok(Response::new() + .add_attribute("action", "dispute_milestone") + .add_attribute("agreement_id", agreement_id.to_string()) + .add_attribute("milestone_index", milestone_index.to_string()) + .add_attribute("reason", reason)) +} + +fn execute_resolve_dispute( + deps: DepsMut, env: Env, info: MessageInfo, + agreement_id: u64, resolution: DisputeResolution, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + if info.sender != config.arbiter_dao { + return Err(ContractError::Unauthorized { + reason: "Only the arbiter DAO can resolve disputes".to_string(), + }); + } + if agreement.status != AgreementStatus::Disputed { + return Err(ContractError::InvalidStatus { + expected: "Disputed".to_string(), actual: agreement.status.to_string(), + }); + } + + let mut dispute = DISPUTES.load(deps.storage, agreement_id) + .map_err(|_| ContractError::NoActiveDispute)?; + + if let DisputeResolution::Split { client_percent } = &resolution { + if *client_percent == 0 || *client_percent >= 100 { + return Err(ContractError::InvalidSplitPercent { got: *client_percent }); + } + } + + let milestone_idx = dispute.milestone_index as usize; + let disputed_amount = agreement.milestones[milestone_idx].payment; + let arbiter_fee = disputed_amount.multiply_ratio(config.arbiter_fee_rate_bps, 10_000u128); + + let mut msgs = vec![]; + + match &resolution { + DisputeResolution::ClientWins => { + let client_receives = disputed_amount - arbiter_fee; + if !client_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: client_receives }], + }); + } + let bond_half = agreement.provider_bond.multiply_ratio(1u128, 2u128); + if !bond_half.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: bond_half }], + }); + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: bond_half }], + }); + } + if !arbiter_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: arbiter_fee }], + }); + } + let remaining = remaining_escrow(&agreement); + if !remaining.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: remaining }], + }); + } + agreement.status = AgreementStatus::Completed; + agreement.completed_at = Some(env.block.time); + } + DisputeResolution::ProviderWins => { + let provider_receives = disputed_amount + agreement.provider_bond - arbiter_fee; + if !provider_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.provider.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: provider_receives }], + }); + } + if !arbiter_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: arbiter_fee }], + }); + } + let next_idx = dispute.milestone_index + 1; + if next_idx < agreement.milestones.len() as u32 { + agreement.current_milestone = next_idx; + agreement.milestones[next_idx as usize].status = MilestoneStatus::InProgress; + agreement.status = AgreementStatus::InProgress; + agreement.provider_bond = Uint128::zero(); + } else { + agreement.status = AgreementStatus::Completed; + agreement.completed_at = Some(env.block.time); + } + } + DisputeResolution::Split { client_percent } => { + let client_share = disputed_amount.multiply_ratio(*client_percent as u128, 100u128); + let provider_share = disputed_amount - client_share; + let arbiter_fee_half = arbiter_fee.multiply_ratio(1u128, 2u128); + + let client_receives = client_share.saturating_sub(arbiter_fee_half); + if !client_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: client_receives }], + }); + } + let provider_receives = (provider_share + agreement.provider_bond).saturating_sub(arbiter_fee_half); + if !provider_receives.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.provider.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: provider_receives }], + }); + } + if !arbiter_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: arbiter_fee }], + }); + } + let remaining = remaining_escrow(&agreement); + if !remaining.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: remaining }], + }); + } + agreement.status = AgreementStatus::Completed; + agreement.completed_at = Some(env.block.time); + } + } + + agreement.milestones[milestone_idx].status = MilestoneStatus::Approved; + dispute.resolved_at = Some(env.block.time); + dispute.resolution = Some(resolution); + + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + DISPUTES.save(deps.storage, agreement_id, &dispute)?; + + let mut resp = Response::new() + .add_attribute("action", "resolve_dispute") + .add_attribute("agreement_id", agreement_id.to_string()); + for msg in msgs { resp = resp.add_message(msg); } + Ok(resp) +} + +fn execute_cancel( + deps: DepsMut, env: Env, info: MessageInfo, agreement_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut agreement = load_agreement(deps.as_ref(), agreement_id)?; + + match agreement.status { + AgreementStatus::Proposed => { + if info.sender != agreement.client && info.sender != agreement.provider { + return Err(ContractError::Unauthorized { + reason: "Only client or provider can cancel a proposed agreement".to_string(), + }); + } + } + AgreementStatus::Funded => { + if info.sender != agreement.client { + return Err(ContractError::Unauthorized { + reason: "Only the client can cancel a funded agreement".to_string(), + }); + } + } + _ => { + return Err(ContractError::InvalidStatus { + expected: "Proposed or Funded".to_string(), actual: agreement.status.to_string(), + }); + } + } + + let mut msgs = vec![]; + + if agreement.status == AgreementStatus::Funded { + let cancel_fee = agreement.escrow_amount.multiply_ratio(config.cancellation_fee_rate_bps, 10_000u128); + let client_refund = agreement.escrow_amount - cancel_fee; + + if !client_refund.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: client_refund }], + }); + } + if !cancel_fee.is_zero() { + msgs.push(BankMsg::Send { + to_address: config.community_pool.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: cancel_fee }], + }); + } + if !agreement.provider_bond.is_zero() { + msgs.push(BankMsg::Send { + to_address: agreement.provider.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: agreement.provider_bond }], + }); + } + } else { + if agreement.client_funded { + msgs.push(BankMsg::Send { + to_address: agreement.client.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: agreement.escrow_amount }], + }); + } + if agreement.provider_accepted { + msgs.push(BankMsg::Send { + to_address: agreement.provider.to_string(), + amount: vec![Coin { denom: config.denom.clone(), amount: agreement.provider_bond }], + }); + } + } + + agreement.status = AgreementStatus::Cancelled; + agreement.completed_at = Some(env.block.time); + AGREEMENTS.save(deps.storage, agreement_id, &agreement)?; + + let mut resp = Response::new() + .add_attribute("action", "cancel_agreement") + .add_attribute("agreement_id", agreement_id.to_string()); + for msg in msgs { resp = resp.add_message(msg); } + Ok(resp) +} + +fn execute_update_config( + deps: DepsMut, info: MessageInfo, + arbiter_dao: Option, community_pool: Option, + provider_bond_ratio_bps: Option, platform_fee_rate_bps: Option, + cancellation_fee_rate_bps: Option, arbiter_fee_rate_bps: Option, + review_period_seconds: Option, max_milestones: Option, max_revisions: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "Only admin can update config".to_string(), + }); + } + + if let Some(v) = arbiter_dao { config.arbiter_dao = deps.api.addr_validate(&v)?; } + if let Some(v) = community_pool { config.community_pool = deps.api.addr_validate(&v)?; } + if let Some(v) = provider_bond_ratio_bps { validate_bond_ratio(v)?; config.provider_bond_ratio_bps = v; } + if let Some(v) = platform_fee_rate_bps { validate_fee_rate(v, MIN_PLATFORM_FEE, MAX_PLATFORM_FEE, "platform")?; config.platform_fee_rate_bps = v; } + if let Some(v) = cancellation_fee_rate_bps { validate_fee_rate(v, MIN_CANCEL_FEE, MAX_CANCEL_FEE, "cancellation")?; config.cancellation_fee_rate_bps = v; } + if let Some(v) = arbiter_fee_rate_bps { validate_fee_rate(v, MIN_ARBITER_FEE, MAX_ARBITER_FEE, "arbiter")?; config.arbiter_fee_rate_bps = v; } + if let Some(v) = review_period_seconds { config.review_period_seconds = v; } + if let Some(v) = max_milestones { config.max_milestones = v; } + if let Some(v) = max_revisions { config.max_revisions = v; } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Agreement { agreement_id } => to_json_binary(&query_agreement(deps, agreement_id)?), + QueryMsg::Agreements { status, start_after, limit } => to_json_binary(&query_agreements(deps, status, start_after, limit)?), + QueryMsg::AgreementsByClient { client, start_after, limit } => to_json_binary(&query_agreements_by_client(deps, client, start_after, limit)?), + QueryMsg::AgreementsByProvider { provider, start_after, limit } => to_json_binary(&query_agreements_by_provider(deps, provider, start_after, limit)?), + QueryMsg::EscrowBalance { agreement_id } => to_json_binary(&query_escrow_balance(deps, agreement_id)?), + QueryMsg::Milestones { agreement_id } => to_json_binary(&query_milestones(deps, agreement_id)?), + QueryMsg::Dispute { agreement_id } => to_json_binary(&query_dispute(deps, agreement_id)?), + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + admin: config.admin.to_string(), arbiter_dao: config.arbiter_dao.to_string(), + community_pool: config.community_pool.to_string(), + provider_bond_ratio_bps: config.provider_bond_ratio_bps, + platform_fee_rate_bps: config.platform_fee_rate_bps, + cancellation_fee_rate_bps: config.cancellation_fee_rate_bps, + arbiter_fee_rate_bps: config.arbiter_fee_rate_bps, + review_period_seconds: config.review_period_seconds, + max_milestones: config.max_milestones, max_revisions: config.max_revisions, + denom: config.denom, + }) +} + +fn query_agreement(deps: Deps, agreement_id: u64) -> StdResult { + let agreement = AGREEMENTS.load(deps.storage, agreement_id)?; + Ok(AgreementResponse { agreement }) +} + +fn query_agreements(deps: Deps, status: Option, start_after: Option, limit: Option) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| s + 1).unwrap_or(0); + let agreements: Vec = AGREEMENTS + .range(deps.storage, Some(cw_storage_plus::Bound::inclusive(start)), None, Order::Ascending) + .filter_map(|r| r.ok()).map(|(_, a)| a) + .filter(|a| status.as_ref().is_none_or(|s| a.status == *s)) + .take(limit).collect(); + Ok(AgreementsResponse { agreements }) +} + +fn query_agreements_by_client(deps: Deps, client: String, start_after: Option, limit: Option) -> StdResult { + let client_addr = deps.api.addr_validate(&client)?; + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| s + 1).unwrap_or(0); + let agreements: Vec = AGREEMENTS + .range(deps.storage, Some(cw_storage_plus::Bound::inclusive(start)), None, Order::Ascending) + .filter_map(|r| r.ok()).map(|(_, a)| a) + .filter(|a| a.client == client_addr) + .take(limit).collect(); + Ok(AgreementsResponse { agreements }) +} + +fn query_agreements_by_provider(deps: Deps, provider: String, start_after: Option, limit: Option) -> StdResult { + let provider_addr = deps.api.addr_validate(&provider)?; + let limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(|s| s + 1).unwrap_or(0); + let agreements: Vec = AGREEMENTS + .range(deps.storage, Some(cw_storage_plus::Bound::inclusive(start)), None, Order::Ascending) + .filter_map(|r| r.ok()).map(|(_, a)| a) + .filter(|a| a.provider == provider_addr) + .take(limit).collect(); + Ok(AgreementsResponse { agreements }) +} + +fn query_escrow_balance(deps: Deps, agreement_id: u64) -> StdResult { + let agreement = AGREEMENTS.load(deps.storage, agreement_id)?; + let config = CONFIG.load(deps.storage)?; + let remaining = agreement.escrow_amount.saturating_sub(agreement.total_released).saturating_sub(agreement.total_fees); + Ok(EscrowBalanceResponse { + agreement_id, escrow_amount: agreement.escrow_amount, + provider_bond: agreement.provider_bond, total_released: agreement.total_released, + total_fees: agreement.total_fees, remaining_escrow: remaining, denom: config.denom, + }) +} + +fn query_milestones(deps: Deps, agreement_id: u64) -> StdResult { + let agreement = AGREEMENTS.load(deps.storage, agreement_id)?; + Ok(MilestonesResponse { agreement_id, milestones: agreement.milestones, current_milestone: agreement.current_milestone }) +} + +fn query_dispute(deps: Deps, agreement_id: u64) -> StdResult { + let dispute = DISPUTES.may_load(deps.storage, agreement_id)?; + Ok(DisputeResponse { dispute }) +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +fn load_agreement(deps: Deps, id: u64) -> Result { + AGREEMENTS.may_load(deps.storage, id)?.ok_or(ContractError::AgreementNotFound { id }) +} + +fn must_pay(info: &MessageInfo, expected_denom: &str) -> Result { + if info.funds.len() != 1 { + return Err(ContractError::InsufficientFunds { + required: format!("exactly one coin in {}", expected_denom), + sent: format!("{} coins", info.funds.len()), + }); + } + let coin = &info.funds[0]; + if coin.denom != expected_denom { + return Err(ContractError::WrongDenom { expected: expected_denom.to_string(), got: coin.denom.clone() }); + } + Ok(coin.amount) +} + +fn remaining_escrow(agreement: &ServiceAgreement) -> Uint128 { + agreement.milestones.iter() + .filter(|m| m.status != MilestoneStatus::Approved && m.status != MilestoneStatus::Disputed) + .map(|m| m.payment).sum() +} + +fn validate_bond_ratio(value: u64) -> Result<(), ContractError> { + if value < MIN_BOND_RATIO || value > MAX_BOND_RATIO { + return Err(ContractError::BondRatioOutOfRange { value, min: MIN_BOND_RATIO, max: MAX_BOND_RATIO }); + } + Ok(()) +} + +fn validate_fee_rate(value: u64, min: u64, max: u64, _name: &str) -> Result<(), ContractError> { + if value < min || value > max { + return Err(ContractError::FeeRateOutOfRange { value, min, max }); + } + Ok(()) +} + +// ── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{Addr, Coin, Uint128}; + use crate::msg::MilestoneInput; + + const DENOM: &str = "uregen"; + + fn addr(input: &str) -> Addr { + MockApi::default().addr_make(input) + } + + fn setup_contract(deps: DepsMut) -> MessageInfo { + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + arbiter_dao: addr("arbiter_dao").to_string(), + community_pool: addr("community_pool").to_string(), + provider_bond_ratio_bps: None, + platform_fee_rate_bps: None, + cancellation_fee_rate_bps: None, + arbiter_fee_rate_bps: None, + review_period_seconds: None, + max_milestones: None, + max_revisions: None, + denom: DENOM.to_string(), + }; + instantiate(deps, mock_env(), info.clone(), msg).unwrap(); + info + } + + fn milestones_3() -> Vec { + vec![ + MilestoneInput { description: "Phase 1: Assessment".to_string(), payment_amount: Uint128::new(3000) }, + MilestoneInput { description: "Phase 2: Implementation".to_string(), payment_amount: Uint128::new(5000) }, + MilestoneInput { description: "Phase 3: Final Report".to_string(), payment_amount: Uint128::new(2000) }, + ] + } + + fn propose_agreement(deps: DepsMut, client: &Addr, provider: &Addr) -> u64 { + let info = message_info(client, &[]); + let msg = ExecuteMsg::ProposeAgreement { + provider: provider.to_string(), + service_type: "ProjectVerification".to_string(), + description: "Verify carbon credits".to_string(), + milestones: milestones_3(), + }; + let res = execute(deps, mock_env(), info, msg).unwrap(); + res.attributes.iter().find(|a| a.key == "agreement_id").unwrap().value.parse().unwrap() + } + + fn fund_and_accept(deps: DepsMut, agreement_id: u64, _client: &Addr, provider: &Addr) { + let accept_info = message_info(provider, &[Coin::new(1000u128, DENOM)]); + execute(deps, mock_env(), accept_info, ExecuteMsg::AcceptAgreement { agreement_id }).unwrap(); + } + + fn fund_escrow(deps: DepsMut, agreement_id: u64, client: &Addr) { + let fund_info = message_info(client, &[Coin::new(10000u128, DENOM)]); + execute(deps, mock_env(), fund_info, ExecuteMsg::FundAgreement { agreement_id }).unwrap(); + } + + fn start_agreement(deps: DepsMut, agreement_id: u64, client: &Addr) { + let info = message_info(client, &[]); + execute(deps, mock_env(), info, ExecuteMsg::StartAgreement { agreement_id }).unwrap(); + } + + #[test] + fn test_instantiate() { + let mut deps = mock_dependencies(); + let info = setup_contract(deps.as_mut()); + let config: ConfigResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(config.admin, info.sender.to_string()); + assert_eq!(config.provider_bond_ratio_bps, 1000); + assert_eq!(config.platform_fee_rate_bps, 100); + assert_eq!(config.cancellation_fee_rate_bps, 200); + assert_eq!(config.arbiter_fee_rate_bps, 500); + assert_eq!(config.review_period_seconds, 1_209_600); + assert_eq!(config.max_milestones, 20); + assert_eq!(config.max_revisions, 3); + assert_eq!(config.denom, DENOM); + } + + #[test] + fn test_instantiate_custom_params() { + let mut deps = mock_dependencies(); + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + arbiter_dao: addr("arbiter").to_string(), + community_pool: addr("pool").to_string(), + provider_bond_ratio_bps: Some(1500), platform_fee_rate_bps: Some(200), + cancellation_fee_rate_bps: Some(300), arbiter_fee_rate_bps: Some(1000), + review_period_seconds: Some(604800), max_milestones: Some(10), + max_revisions: Some(5), denom: "ustake".to_string(), + }; + instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + let config: ConfigResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(config.provider_bond_ratio_bps, 1500); + assert_eq!(config.platform_fee_rate_bps, 200); + assert_eq!(config.max_milestones, 10); + assert_eq!(config.denom, "ustake"); + } + + #[test] + fn test_instantiate_invalid_bond_ratio() { + let mut deps = mock_dependencies(); + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + arbiter_dao: addr("arbiter").to_string(), community_pool: addr("pool").to_string(), + provider_bond_ratio_bps: Some(100), platform_fee_rate_bps: None, + cancellation_fee_rate_bps: None, arbiter_fee_rate_bps: None, + review_period_seconds: None, max_milestones: None, max_revisions: None, + denom: DENOM.to_string(), + }; + let err = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::BondRatioOutOfRange { .. })); + } + + #[test] + fn test_propose_agreement() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + assert_eq!(id, 1); + let resp: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: 1 }).unwrap()).unwrap(); + let a = resp.agreement; + assert_eq!(a.status, AgreementStatus::Proposed); + assert_eq!(a.escrow_amount, Uint128::new(10000)); + assert_eq!(a.provider_bond, Uint128::new(1000)); + assert_eq!(a.milestones.len(), 3); + assert_eq!(a.current_milestone, 0); + assert!(!a.provider_accepted); + assert!(!a.client_funded); + } + + #[test] + fn test_propose_self_agreement_fails() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let info = message_info(&client, &[]); + let msg = ExecuteMsg::ProposeAgreement { + provider: client.to_string(), service_type: "MRVSetup".to_string(), + description: "Self deal".to_string(), milestones: milestones_3(), + }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::SelfAgreement)); + } + + #[test] + fn test_propose_too_many_milestones() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let info = message_info(&client, &[]); + let many: Vec = (0..21).map(|i| MilestoneInput { description: format!("M{}", i), payment_amount: Uint128::new(100) }).collect(); + let msg = ExecuteMsg::ProposeAgreement { provider: provider.to_string(), service_type: "MRVSetup".to_string(), description: "Too many".to_string(), milestones: many }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::InvalidMilestoneCount { max: 20, got: 21 })); + } + + #[test] + fn test_propose_zero_milestones() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let info = message_info(&client, &[]); + let msg = ExecuteMsg::ProposeAgreement { provider: provider.to_string(), service_type: "MRVSetup".to_string(), description: "None".to_string(), milestones: vec![] }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::InvalidMilestoneCount { .. })); + } + + #[test] + fn test_accept_agreement() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let accept_info = message_info(&provider, &[Coin::new(1000u128, DENOM)]); + execute(deps.as_mut(), mock_env(), accept_info, ExecuteMsg::AcceptAgreement { agreement_id: id }).unwrap(); + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert!(a.agreement.provider_accepted); + assert_eq!(a.agreement.status, AgreementStatus::Proposed); + } + + #[test] + fn test_accept_insufficient_bond() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let accept_info = message_info(&provider, &[Coin::new(500u128, DENOM)]); + let err = execute(deps.as_mut(), mock_env(), accept_info, ExecuteMsg::AcceptAgreement { agreement_id: id }).unwrap_err(); + assert!(matches!(err, ContractError::InsufficientFunds { .. })); + } + + #[test] + fn test_accept_wrong_sender() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let rando = addr("rando"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let info = message_info(&rando, &[Coin::new(1000u128, DENOM)]); + let err = execute(deps.as_mut(), mock_env(), info, ExecuteMsg::AcceptAgreement { agreement_id: id }).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + #[test] + fn test_fund_and_accept_transitions_to_funded() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Proposed); + assert!(a.agreement.client_funded); + fund_and_accept(deps.as_mut(), id, &client, &provider); + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Funded); + } + + #[test] + fn test_happy_path_3_milestones() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + for ms_idx in 0..3u32 { + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: ms_idx, deliverable_iri: format!("regen:iri/phase{}", ms_idx) }).unwrap(); + let approve_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), approve_info, ExecuteMsg::ApproveMilestone { agreement_id: id, milestone_index: ms_idx }).unwrap(); + } + + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Completed); + assert!(a.agreement.completed_at.is_some()); + } + + #[test] + fn test_cancel_proposed() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), info, ExecuteMsg::CancelAgreement { agreement_id: id }).unwrap(); + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Cancelled); + } + + #[test] + fn test_cancel_funded_applies_fee() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + let info = message_info(&client, &[]); + let res = execute(deps.as_mut(), mock_env(), info, ExecuteMsg::CancelAgreement { agreement_id: id }).unwrap(); + assert_eq!(res.messages.len(), 3); + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Cancelled); + } + + #[test] + fn test_cancel_in_progress_fails() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + let info = message_info(&client, &[]); + let err = execute(deps.as_mut(), mock_env(), info, ExecuteMsg::CancelAgreement { agreement_id: id }).unwrap_err(); + assert!(matches!(err, ContractError::InvalidStatus { .. })); + } + + #[test] + fn test_dispute_client_wins() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/bad".to_string() }).unwrap(); + let dispute_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), dispute_info, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Incomplete".to_string() }).unwrap(); + + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Disputed); + + let d: DisputeResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Dispute { agreement_id: id }).unwrap()).unwrap(); + assert!(d.dispute.is_some()); + assert_eq!(d.dispute.as_ref().unwrap().milestone_index, 0); + + let arbiter = addr("arbiter_dao"); + let arbiter_info = message_info(&arbiter, &[]); + let res = execute(deps.as_mut(), mock_env(), arbiter_info, ExecuteMsg::ResolveDispute { agreement_id: id, resolution: DisputeResolution::ClientWins }).unwrap(); + assert!(!res.messages.is_empty()); + + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Completed); + } + + #[test] + fn test_dispute_provider_wins() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/good".to_string() }).unwrap(); + let dispute_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), dispute_info, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Unfounded".to_string() }).unwrap(); + + let arbiter = addr("arbiter_dao"); + let arbiter_info = message_info(&arbiter, &[]); + execute(deps.as_mut(), mock_env(), arbiter_info, ExecuteMsg::ResolveDispute { agreement_id: id, resolution: DisputeResolution::ProviderWins }).unwrap(); + + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::InProgress); + assert_eq!(a.agreement.current_milestone, 1); + } + + #[test] + fn test_dispute_split() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/partial".to_string() }).unwrap(); + let dispute_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), dispute_info, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Partial".to_string() }).unwrap(); + + let arbiter = addr("arbiter_dao"); + let arbiter_info = message_info(&arbiter, &[]); + let res = execute(deps.as_mut(), mock_env(), arbiter_info, ExecuteMsg::ResolveDispute { agreement_id: id, resolution: DisputeResolution::Split { client_percent: 60 } }).unwrap(); + assert!(!res.messages.is_empty()); + + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Completed); + } + + #[test] + fn test_dispute_invalid_split_percent() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/x".to_string() }).unwrap(); + let dispute_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), dispute_info, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Bad".to_string() }).unwrap(); + + let arbiter = addr("arbiter_dao"); + let arbiter_info = message_info(&arbiter, &[]); + let err = execute(deps.as_mut(), mock_env(), arbiter_info, ExecuteMsg::ResolveDispute { agreement_id: id, resolution: DisputeResolution::Split { client_percent: 100 } }).unwrap_err(); + assert!(matches!(err, ContractError::InvalidSplitPercent { got: 100 })); + } + + #[test] + fn test_dispute_unauthorized_resolver() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let rando = addr("rando"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/x".to_string() }).unwrap(); + let dispute_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), dispute_info, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Bad".to_string() }).unwrap(); + + let rando_info = message_info(&rando, &[]); + let err = execute(deps.as_mut(), mock_env(), rando_info, ExecuteMsg::ResolveDispute { agreement_id: id, resolution: DisputeResolution::ClientWins }).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + #[test] + fn test_revise_milestone() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/v1".to_string() }).unwrap(); + + let revise_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), revise_info, ExecuteMsg::ReviseMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/v2".to_string() }).unwrap(); + + let ms: MilestonesResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Milestones { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(ms.milestones[0].revision_count, 1); + assert_eq!(ms.milestones[0].deliverable_iri, Some("regen:iri/v2".to_string())); + } + + #[test] + fn test_max_revisions_exceeded() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/v1".to_string() }).unwrap(); + + for i in 2..=5 { + let revise_info = message_info(&provider, &[]); + let result = execute(deps.as_mut(), mock_env(), revise_info, ExecuteMsg::ReviseMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: format!("regen:iri/v{}", i) }); + if i <= 4 { + result.unwrap(); + } else { + let err = result.unwrap_err(); + assert!(matches!(err, ContractError::MaxRevisionsExceeded { .. })); + } + } + } + + #[test] + fn test_submit_wrong_milestone_index() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + let err = execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 1, deliverable_iri: "regen:iri/wrong".to_string() }).unwrap_err(); + assert!(matches!(err, ContractError::InvalidMilestoneIndex { .. })); + } + + #[test] + fn test_query_escrow_balance() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let balance: EscrowBalanceResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::EscrowBalance { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(balance.escrow_amount, Uint128::new(10000)); + assert_eq!(balance.provider_bond, Uint128::new(1000)); + assert_eq!(balance.total_released, Uint128::zero()); + assert_eq!(balance.remaining_escrow, Uint128::new(10000)); + assert_eq!(balance.denom, DENOM); + } + + #[test] + fn test_query_agreements_by_client() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let p1 = addr("provider1"); + let p2 = addr("provider2"); + propose_agreement(deps.as_mut(), &client, &p1); + propose_agreement(deps.as_mut(), &client, &p2); + let resp: AgreementsResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::AgreementsByClient { client: client.to_string(), start_after: None, limit: None }).unwrap()).unwrap(); + assert_eq!(resp.agreements.len(), 2); + } + + #[test] + fn test_query_agreements_by_provider() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let c1 = addr("client1"); + let c2 = addr("client2"); + let provider = addr("provider"); + propose_agreement(deps.as_mut(), &c1, &provider); + propose_agreement(deps.as_mut(), &c2, &provider); + let resp: AgreementsResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::AgreementsByProvider { provider: provider.to_string(), start_after: None, limit: None }).unwrap()).unwrap(); + assert_eq!(resp.agreements.len(), 2); + } + + #[test] + fn test_query_agreements_by_status() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let p1 = addr("provider1"); + let p2 = addr("provider2"); + let id1 = propose_agreement(deps.as_mut(), &client, &p1); + propose_agreement(deps.as_mut(), &client, &p2); + let cancel_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), cancel_info, ExecuteMsg::CancelAgreement { agreement_id: id1 }).unwrap(); + + let resp: AgreementsResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreements { status: Some(AgreementStatus::Proposed), start_after: None, limit: None }).unwrap()).unwrap(); + assert_eq!(resp.agreements.len(), 1); + let resp: AgreementsResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreements { status: Some(AgreementStatus::Cancelled), start_after: None, limit: None }).unwrap()).unwrap(); + assert_eq!(resp.agreements.len(), 1); + } + + #[test] + fn test_query_no_dispute() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let d: DisputeResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Dispute { agreement_id: id }).unwrap()).unwrap(); + assert!(d.dispute.is_none()); + } + + #[test] + fn test_query_agreement_not_found() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let result = query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: 99 }); + assert!(result.is_err()); + } + + #[test] + fn test_update_config() { + let mut deps = mock_dependencies(); + let admin_info = setup_contract(deps.as_mut()); + let msg = ExecuteMsg::UpdateConfig { + arbiter_dao: None, community_pool: None, provider_bond_ratio_bps: Some(1500), + platform_fee_rate_bps: Some(200), cancellation_fee_rate_bps: None, + arbiter_fee_rate_bps: None, review_period_seconds: Some(604800), + max_milestones: Some(15), max_revisions: Some(5), + }; + execute(deps.as_mut(), mock_env(), admin_info, msg).unwrap(); + let config: ConfigResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(config.provider_bond_ratio_bps, 1500); + assert_eq!(config.platform_fee_rate_bps, 200); + assert_eq!(config.review_period_seconds, 604800); + assert_eq!(config.max_milestones, 15); + assert_eq!(config.max_revisions, 5); + } + + #[test] + fn test_update_config_unauthorized() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let rando = addr("rando"); + let rando_info = message_info(&rando, &[]); + let msg = ExecuteMsg::UpdateConfig { + arbiter_dao: None, community_pool: None, provider_bond_ratio_bps: Some(1500), + platform_fee_rate_bps: None, cancellation_fee_rate_bps: None, + arbiter_fee_rate_bps: None, review_period_seconds: None, + max_milestones: None, max_revisions: None, + }; + let err = execute(deps.as_mut(), mock_env(), rando_info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + #[test] + fn test_double_dispute_fails() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/x".to_string() }).unwrap(); + let dispute_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), dispute_info, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Bad".to_string() }).unwrap(); + let dispute_info2 = message_info(&client, &[]); + let err = execute(deps.as_mut(), mock_env(), dispute_info2, ExecuteMsg::DisputeMilestone { agreement_id: id, milestone_index: 0, reason: "Really bad".to_string() }).unwrap_err(); + assert!(matches!(err, ContractError::InvalidStatus { .. })); + } + + #[test] + fn test_escrow_balance_after_approval() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + fund_and_accept(deps.as_mut(), id, &client, &provider); + fund_escrow(deps.as_mut(), id, &client); + start_agreement(deps.as_mut(), id, &client); + + let submit_info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), submit_info, ExecuteMsg::SubmitMilestone { agreement_id: id, milestone_index: 0, deliverable_iri: "regen:iri/m0".to_string() }).unwrap(); + let approve_info = message_info(&client, &[]); + execute(deps.as_mut(), mock_env(), approve_info, ExecuteMsg::ApproveMilestone { agreement_id: id, milestone_index: 0 }).unwrap(); + + let balance: EscrowBalanceResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::EscrowBalance { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(balance.total_released, Uint128::new(2970)); + assert_eq!(balance.total_fees, Uint128::new(30)); + assert_eq!(balance.remaining_escrow, Uint128::new(7000)); + } + + #[test] + fn test_provider_can_cancel_proposed() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let info = message_info(&provider, &[]); + execute(deps.as_mut(), mock_env(), info, ExecuteMsg::CancelAgreement { agreement_id: id }).unwrap(); + let a: AgreementResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreement { agreement_id: id }).unwrap()).unwrap(); + assert_eq!(a.agreement.status, AgreementStatus::Cancelled); + } + + #[test] + fn test_agreement_ids_increment() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let p1 = addr("p1"); + let p2 = addr("p2"); + let p3 = addr("p3"); + let id1 = propose_agreement(deps.as_mut(), &client, &p1); + let id2 = propose_agreement(deps.as_mut(), &client, &p2); + let id3 = propose_agreement(deps.as_mut(), &client, &p3); + assert_eq!(id1, 1); + assert_eq!(id2, 2); + assert_eq!(id3, 3); + } + + #[test] + fn test_query_agreements_pagination() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + for i in 0..5 { + let p = addr(&format!("provider{}", i)); + propose_agreement(deps.as_mut(), &client, &p); + } + let resp: AgreementsResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreements { status: None, start_after: None, limit: Some(2) }).unwrap()).unwrap(); + assert_eq!(resp.agreements.len(), 2); + assert_eq!(resp.agreements[0].id, 1); + assert_eq!(resp.agreements[1].id, 2); + let resp: AgreementsResponse = cosmwasm_std::from_json(query(deps.as_ref(), mock_env(), QueryMsg::Agreements { status: None, start_after: Some(2), limit: Some(2) }).unwrap()).unwrap(); + assert_eq!(resp.agreements.len(), 2); + assert_eq!(resp.agreements[0].id, 3); + assert_eq!(resp.agreements[1].id, 4); + } + + #[test] + fn test_fund_wrong_denom() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + let client = addr("client"); + let provider = addr("provider"); + let id = propose_agreement(deps.as_mut(), &client, &provider); + let fund_info = message_info(&client, &[Coin::new(10000u128, "uatom")]); + let err = execute(deps.as_mut(), mock_env(), fund_info, ExecuteMsg::FundAgreement { agreement_id: id }).unwrap_err(); + assert!(matches!(err, ContractError::WrongDenom { .. })); + } +} diff --git a/contracts/service-escrow/src/error.rs b/contracts/service-escrow/src/error.rs new file mode 100644 index 0000000..0b2f8da --- /dev/null +++ b/contracts/service-escrow/src/error.rs @@ -0,0 +1,62 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + #[error("Agreement {id} not found")] + AgreementNotFound { id: u64 }, + + #[error("Invalid agreement status: expected {expected}, got {actual}")] + InvalidStatus { expected: String, actual: String }, + + #[error("Milestone count must be between 1 and {max}, got {got}")] + InvalidMilestoneCount { max: u32, got: u32 }, + + #[error("Milestone payments must sum to escrow amount: payments={payments}, escrow={escrow}")] + MilestonePaymentMismatch { payments: String, escrow: String }, + + #[error("Invalid milestone index: expected {expected}, got {got}")] + InvalidMilestoneIndex { expected: u32, got: u32 }, + + #[error("Milestone {index} is not in {expected_status} status")] + InvalidMilestoneStatus { + index: u32, + expected_status: String, + }, + + #[error("Insufficient funds: required {required}, sent {sent}")] + InsufficientFunds { required: String, sent: String }, + + #[error("Wrong denomination: expected {expected}, got {got}")] + WrongDenom { expected: String, got: String }, + + #[error("Max revisions ({max}) exceeded for milestone {index}")] + MaxRevisionsExceeded { max: u32, index: u32 }, + + #[error("Review period has not expired yet")] + ReviewPeriodNotExpired, + + #[error("Dispute already exists for this agreement")] + DisputeAlreadyExists, + + #[error("No active dispute on this agreement")] + NoActiveDispute, + + #[error("Split percent must be between 1 and 99, got {got}")] + InvalidSplitPercent { got: u32 }, + + #[error("Provider cannot be the same as client")] + SelfAgreement, + + #[error("Bond ratio out of range: {value} bps (allowed {min}-{max})")] + BondRatioOutOfRange { value: u64, min: u64, max: u64 }, + + #[error("Fee rate out of range: {value} bps (allowed {min}-{max})")] + FeeRateOutOfRange { value: u64, min: u64, max: u64 }, +} diff --git a/contracts/service-escrow/src/lib.rs b/contracts/service-escrow/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/service-escrow/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/service-escrow/src/msg.rs b/contracts/service-escrow/src/msg.rs new file mode 100644 index 0000000..3b638ec --- /dev/null +++ b/contracts/service-escrow/src/msg.rs @@ -0,0 +1,215 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use crate::state::{AgreementStatus, DisputeResolution, Dispute, Milestone, ServiceAgreement}; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cw_serde] +pub struct InstantiateMsg { + /// Arbiter DAO address for dispute resolution + pub arbiter_dao: String, + /// Community pool address for fee collection + pub community_pool: String, + /// Provider bond ratio in basis points (default 1000 = 10%) + pub provider_bond_ratio_bps: Option, + /// Platform fee rate in basis points (default 100 = 1%) + pub platform_fee_rate_bps: Option, + /// Cancellation fee rate in basis points (default 200 = 2%) + pub cancellation_fee_rate_bps: Option, + /// Arbiter fee rate in basis points (default 500 = 5%) + pub arbiter_fee_rate_bps: Option, + /// Review period in seconds (default 14 days = 1_209_600) + pub review_period_seconds: Option, + /// Maximum milestones per agreement (default 20) + pub max_milestones: Option, + /// Maximum revisions per milestone (default 3) + pub max_revisions: Option, + /// Accepted payment denomination + pub denom: String, +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cw_serde] +pub enum ExecuteMsg { + /// Client proposes a new service agreement (sends no funds yet) + ProposeAgreement { + provider: String, + service_type: String, + description: String, + milestones: Vec, + }, + + /// Provider accepts and posts bond (must attach bond funds) + AcceptAgreement { + agreement_id: u64, + }, + + /// Client funds the escrow (must attach escrow amount) + FundAgreement { + agreement_id: u64, + }, + + /// Both parties confirm to start (or auto-starts when both accept+fund) + StartAgreement { + agreement_id: u64, + }, + + /// Provider submits a milestone deliverable + SubmitMilestone { + agreement_id: u64, + milestone_index: u32, + deliverable_iri: String, + }, + + /// Client approves a submitted milestone (releases payment) + ApproveMilestone { + agreement_id: u64, + milestone_index: u32, + }, + + /// Provider revises a milestone deliverable (before max_revisions) + ReviseMilestone { + agreement_id: u64, + milestone_index: u32, + deliverable_iri: String, + }, + + /// Client or timeout raises a dispute on a milestone + DisputeMilestone { + agreement_id: u64, + milestone_index: u32, + reason: String, + }, + + /// Arbiter DAO resolves a dispute + ResolveDispute { + agreement_id: u64, + resolution: DisputeResolution, + }, + + /// Cancel agreement (only from Proposed or Funded status) + CancelAgreement { + agreement_id: u64, + }, + + /// Admin updates governance parameters + UpdateConfig { + arbiter_dao: Option, + community_pool: Option, + provider_bond_ratio_bps: Option, + platform_fee_rate_bps: Option, + cancellation_fee_rate_bps: Option, + arbiter_fee_rate_bps: Option, + review_period_seconds: Option, + max_milestones: Option, + max_revisions: Option, + }, +} + +#[cw_serde] +pub struct MilestoneInput { + pub description: String, + pub payment_amount: Uint128, +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the contract configuration + #[returns(ConfigResponse)] + Config {}, + + /// Returns a single agreement by ID + #[returns(AgreementResponse)] + Agreement { agreement_id: u64 }, + + /// Returns agreements filtered by status (paginated) + #[returns(AgreementsResponse)] + Agreements { + status: Option, + start_after: Option, + limit: Option, + }, + + /// Returns agreements for a specific client + #[returns(AgreementsResponse)] + AgreementsByClient { + client: String, + start_after: Option, + limit: Option, + }, + + /// Returns agreements for a specific provider + #[returns(AgreementsResponse)] + AgreementsByProvider { + provider: String, + start_after: Option, + limit: Option, + }, + + /// Returns the escrow balance for an agreement + #[returns(EscrowBalanceResponse)] + EscrowBalance { agreement_id: u64 }, + + /// Returns milestones for an agreement + #[returns(MilestonesResponse)] + Milestones { agreement_id: u64 }, + + /// Returns the active dispute for an agreement, if any + #[returns(DisputeResponse)] + Dispute { agreement_id: u64 }, +} + +// ── Query responses ──────────────────────────────────────────────────── + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub arbiter_dao: String, + pub community_pool: String, + pub provider_bond_ratio_bps: u64, + pub platform_fee_rate_bps: u64, + pub cancellation_fee_rate_bps: u64, + pub arbiter_fee_rate_bps: u64, + pub review_period_seconds: u64, + pub max_milestones: u32, + pub max_revisions: u32, + pub denom: String, +} + +#[cw_serde] +pub struct AgreementResponse { + pub agreement: ServiceAgreement, +} + +#[cw_serde] +pub struct AgreementsResponse { + pub agreements: Vec, +} + +#[cw_serde] +pub struct EscrowBalanceResponse { + pub agreement_id: u64, + pub escrow_amount: Uint128, + pub provider_bond: Uint128, + pub total_released: Uint128, + pub total_fees: Uint128, + pub remaining_escrow: Uint128, + pub denom: String, +} + +#[cw_serde] +pub struct MilestonesResponse { + pub agreement_id: u64, + pub milestones: Vec, + pub current_milestone: u32, +} + +#[cw_serde] +pub struct DisputeResponse { + pub dispute: Option, +} diff --git a/contracts/service-escrow/src/state.rs b/contracts/service-escrow/src/state.rs new file mode 100644 index 0000000..53027bd --- /dev/null +++ b/contracts/service-escrow/src/state.rs @@ -0,0 +1,149 @@ +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ── Configuration ────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + /// Contract administrator (can update config) + pub admin: Addr, + /// Arbiter DAO address for dispute resolution + pub arbiter_dao: Addr, + /// Community pool address for fee collection + pub community_pool: Addr, + /// Provider bond ratio in basis points (default 1000 = 10%) + pub provider_bond_ratio_bps: u64, + /// Platform fee rate in basis points (default 100 = 1%) + pub platform_fee_rate_bps: u64, + /// Cancellation fee rate in basis points (default 200 = 2%) + pub cancellation_fee_rate_bps: u64, + /// Arbiter fee rate in basis points on disputed amount (default 500 = 5%) + pub arbiter_fee_rate_bps: u64, + /// Default review period in seconds + pub review_period_seconds: u64, + /// Maximum number of milestones per agreement + pub max_milestones: u32, + /// Maximum revision count per milestone + pub max_revisions: u32, + /// Accepted payment denomination + pub denom: String, +} + +// ── Agreement ────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum AgreementStatus { + Proposed, + Funded, + InProgress, + MilestoneReview, + Completed, + Disputed, + Cancelled, +} + +impl std::fmt::Display for AgreementStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AgreementStatus::Proposed => write!(f, "Proposed"), + AgreementStatus::Funded => write!(f, "Funded"), + AgreementStatus::InProgress => write!(f, "InProgress"), + AgreementStatus::MilestoneReview => write!(f, "MilestoneReview"), + AgreementStatus::Completed => write!(f, "Completed"), + AgreementStatus::Disputed => write!(f, "Disputed"), + AgreementStatus::Cancelled => write!(f, "Cancelled"), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ServiceAgreement { + pub id: u64, + pub client: Addr, + pub provider: Addr, + pub service_type: String, + pub description: String, + pub escrow_amount: Uint128, + pub provider_bond: Uint128, + pub milestones: Vec, + pub current_milestone: u32, + pub status: AgreementStatus, + pub created_at: Timestamp, + pub funded_at: Option, + pub started_at: Option, + pub completed_at: Option, + /// True once the provider has accepted + pub provider_accepted: bool, + /// True once the client has funded + pub client_funded: bool, + /// Total amount already released to provider + pub total_released: Uint128, + /// Total platform fees collected + pub total_fees: Uint128, +} + +// ── Milestone ────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum MilestoneStatus { + Pending, + InProgress, + Submitted, + Approved, + Disputed, + Revised, +} + +impl std::fmt::Display for MilestoneStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MilestoneStatus::Pending => write!(f, "Pending"), + MilestoneStatus::InProgress => write!(f, "InProgress"), + MilestoneStatus::Submitted => write!(f, "Submitted"), + MilestoneStatus::Approved => write!(f, "Approved"), + MilestoneStatus::Disputed => write!(f, "Disputed"), + MilestoneStatus::Revised => write!(f, "Revised"), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Milestone { + pub index: u32, + pub description: String, + pub payment: Uint128, + pub status: MilestoneStatus, + pub deliverable_iri: Option, + pub submitted_at: Option, + pub approved_at: Option, + pub revision_count: u32, +} + +// ── Dispute ──────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Dispute { + pub agreement_id: u64, + pub milestone_index: u32, + pub reason: String, + pub raised_by: Addr, + pub raised_at: Timestamp, + pub resolved_at: Option, + pub resolution: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum DisputeResolution { + ClientWins, + ProviderWins, + Split { client_percent: u32 }, +} + +// ── Storage keys ─────────────────────────────────────────────────────── + +pub const CONFIG: Item = Item::new("config"); +pub const NEXT_AGREEMENT_ID: Item = Item::new("next_agreement_id"); +pub const AGREEMENTS: Map = Map::new("agreements"); +pub const DISPUTES: Map = Map::new("disputes"); diff --git a/contracts/validator-governance/Cargo.toml b/contracts/validator-governance/Cargo.toml new file mode 100644 index 0000000..ea76394 --- /dev/null +++ b/contracts/validator-governance/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "validator-governance" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +library = [] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +schemars.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true diff --git a/contracts/validator-governance/src/contract.rs b/contracts/validator-governance/src/contract.rs new file mode 100644 index 0000000..f004cc2 --- /dev/null +++ b/contracts/validator-governance/src/contract.rs @@ -0,0 +1,1590 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, Addr, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, + Order, Response, StdResult, Uint128, +}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ + CompositionBreakdownResponse, ConfigResponse, ExecuteMsg, InstantiateMsg, + ModuleStateResponse, PerformanceRecordResponse, QueryMsg, ValidatorResponse, + ValidatorsResponse, +}; +use crate::state::{ + AuthorityValidator, Config, ModuleState, PerformanceRecord, ValidatorCategory, ValidatorStatus, + ACTIVE_VALIDATORS, CONFIG, MODULE_STATE, NEXT_PERIOD, PERFORMANCE_RECORDS, VALIDATORS, +}; + +const CONTRACT_NAME: &str = "crates.io:validator-governance"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let uptime_w = msg.uptime_weight_bps.unwrap_or(4000); + let gov_w = msg.governance_weight_bps.unwrap_or(3000); + let eco_w = msg.ecosystem_weight_bps.unwrap_or(3000); + + let weight_sum = uptime_w + gov_w + eco_w; + if weight_sum != 10_000 { + return Err(ContractError::InvalidWeightSum { total: weight_sum }); + } + + let base_share = msg.base_compensation_share_bps.unwrap_or(9000); + let bonus_share = msg.performance_bonus_share_bps.unwrap_or(1000); + let share_sum = base_share + bonus_share; + if share_sum != 10_000 { + return Err(ContractError::InvalidWeightSum { total: share_sum }); + } + + validate_bps(msg.min_uptime_bps.unwrap_or(9950))?; + validate_bps(msg.performance_threshold_bps.unwrap_or(7000))?; + validate_bps(uptime_w)?; + validate_bps(gov_w)?; + validate_bps(eco_w)?; + validate_bps(base_share)?; + validate_bps(bonus_share)?; + + let config = Config { + admin: info.sender.clone(), + min_validators: msg.min_validators.unwrap_or(15), + max_validators: msg.max_validators.unwrap_or(21), + term_length_seconds: msg.term_length_seconds.unwrap_or(31_536_000), // 12 months + probation_period_seconds: msg.probation_period_seconds.unwrap_or(2_592_000), // 30 days + min_uptime_bps: msg.min_uptime_bps.unwrap_or(9950), + performance_threshold_bps: msg.performance_threshold_bps.unwrap_or(7000), + uptime_weight_bps: uptime_w, + governance_weight_bps: gov_w, + ecosystem_weight_bps: eco_w, + base_compensation_share_bps: base_share, + performance_bonus_share_bps: bonus_share, + min_per_category: msg.min_per_category.unwrap_or(5), + denom: msg.denom, + }; + + let module_state = ModuleState { + validator_fund_balance: Uint128::zero(), + total_active: 0, + last_compensation_distribution: None, + last_performance_evaluation: None, + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &config)?; + MODULE_STATE.save(deps.storage, &module_state)?; + NEXT_PERIOD.save(deps.storage, &1u64)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::ApplyForValidator { + category, + application_data, + } => execute_apply(deps, env, info, category, application_data), + ExecuteMsg::ApproveValidator { applicant } => { + execute_approve(deps, env, info, applicant) + } + ExecuteMsg::ActivateValidator { validator } => { + execute_activate(deps, env, info, validator) + } + ExecuteMsg::SubmitPerformanceReport { + validator, + uptime_bps, + governance_participation_bps, + ecosystem_contribution_bps, + } => execute_submit_performance( + deps, + env, + info, + validator, + uptime_bps, + governance_participation_bps, + ecosystem_contribution_bps, + ), + ExecuteMsg::InitiateProbation { validator, reason } => { + execute_initiate_probation(deps, env, info, validator, reason) + } + ExecuteMsg::RestoreFromProbation { validator } => { + execute_restore_from_probation(deps, env, info, validator) + } + ExecuteMsg::ConfirmRemoval { validator } => { + execute_confirm_removal(deps, env, info, validator) + } + ExecuteMsg::EndValidatorTerm { validator } => { + execute_end_term(deps, env, info, validator) + } + ExecuteMsg::DistributeCompensation {} => execute_distribute_compensation(deps, env, info), + ExecuteMsg::ClaimCompensation {} => execute_claim_compensation(deps, env, info), + ExecuteMsg::UpdateValidatorFund {} => execute_update_fund(deps, env, info), + ExecuteMsg::UpdateConfig { + min_validators, + max_validators, + term_length_seconds, + probation_period_seconds, + min_uptime_bps, + performance_threshold_bps, + uptime_weight_bps, + governance_weight_bps, + ecosystem_weight_bps, + base_compensation_share_bps, + performance_bonus_share_bps, + min_per_category, + } => execute_update_config( + deps, + info, + min_validators, + max_validators, + term_length_seconds, + probation_period_seconds, + min_uptime_bps, + performance_threshold_bps, + uptime_weight_bps, + governance_weight_bps, + ecosystem_weight_bps, + base_compensation_share_bps, + performance_bonus_share_bps, + min_per_category, + ), + } +} + +// ── Execute handlers ─────────────────────────────────────────────────── + +fn execute_apply( + deps: DepsMut, + _env: Env, + info: MessageInfo, + category: ValidatorCategory, + application_data: String, +) -> Result { + let applicant = info.sender.clone(); + + if VALIDATORS.may_load(deps.storage, &applicant)?.is_some() { + return Err(ContractError::ValidatorAlreadyExists { + address: applicant.to_string(), + }); + } + + let validator = AuthorityValidator { + address: applicant.clone(), + category: category.clone(), + status: ValidatorStatus::Candidate, + term_start: None, + term_end: None, + probation_start: None, + performance_score_bps: 0, + compensation_due: Uint128::zero(), + }; + + VALIDATORS.save(deps.storage, &applicant, &validator)?; + + Ok(Response::new() + .add_attribute("action", "apply_for_validator") + .add_attribute("applicant", applicant) + .add_attribute("category", category.to_string()) + .add_attribute("application_data", application_data)) +} + +fn execute_approve( + deps: DepsMut, + _env: Env, + info: MessageInfo, + applicant: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let applicant_addr = deps.api.addr_validate(&applicant)?; + let mut validator = load_validator(deps.as_ref(), &applicant_addr)?; + + if validator.status != ValidatorStatus::Candidate { + return Err(ContractError::InvalidStatus { + expected: "Candidate".to_string(), + actual: validator.status.to_string(), + }); + } + + validator.status = ValidatorStatus::Approved; + VALIDATORS.save(deps.storage, &applicant_addr, &validator)?; + + Ok(Response::new() + .add_attribute("action", "approve_validator") + .add_attribute("applicant", applicant)) +} + +fn execute_activate( + deps: DepsMut, + env: Env, + info: MessageInfo, + validator_addr: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let addr = deps.api.addr_validate(&validator_addr)?; + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.status != ValidatorStatus::Approved { + return Err(ContractError::InvalidStatus { + expected: "Approved".to_string(), + actual: validator.status.to_string(), + }); + } + + // Check max validators + let mut state = MODULE_STATE.load(deps.storage)?; + if state.total_active >= config.max_validators { + return Err(ContractError::AboveMaxValidators { + max: config.max_validators, + }); + } + + // Set term + let term_start = env.block.time; + let term_end = term_start.plus_seconds(config.term_length_seconds); + + validator.status = ValidatorStatus::Active; + validator.term_start = Some(term_start); + validator.term_end = Some(term_end); + + state.total_active += 1; + + VALIDATORS.save(deps.storage, &addr, &validator)?; + ACTIVE_VALIDATORS.save(deps.storage, &addr, &true)?; + MODULE_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "activate_validator") + .add_attribute("validator", validator_addr) + .add_attribute("term_start", term_start.to_string()) + .add_attribute("term_end", term_end.to_string())) +} + +fn execute_submit_performance( + deps: DepsMut, + env: Env, + info: MessageInfo, + validator_addr: String, + uptime_bps: u64, + governance_participation_bps: u64, + ecosystem_contribution_bps: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + validate_bps(uptime_bps)?; + validate_bps(governance_participation_bps)?; + validate_bps(ecosystem_contribution_bps)?; + + let addr = deps.api.addr_validate(&validator_addr)?; + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.status != ValidatorStatus::Active && validator.status != ValidatorStatus::Probation + { + return Err(ContractError::InvalidStatus { + expected: "Active or Probation".to_string(), + actual: validator.status.to_string(), + }); + } + + // Calculate composite score: uptime*0.4 + gov*0.3 + ecosystem*0.3 + let composite = (uptime_bps * config.uptime_weight_bps + + governance_participation_bps * config.governance_weight_bps + + ecosystem_contribution_bps * config.ecosystem_weight_bps) + / 10_000; + + let period = NEXT_PERIOD.load(deps.storage)?; + + let record = PerformanceRecord { + validator_address: addr.clone(), + period, + uptime_bps, + governance_participation_bps, + ecosystem_contribution_bps, + composite_score_bps: composite, + recorded_at: env.block.time, + }; + + PERFORMANCE_RECORDS.save(deps.storage, (&addr, period), &record)?; + NEXT_PERIOD.save(deps.storage, &(period + 1))?; + + // Update cached score + validator.performance_score_bps = composite; + VALIDATORS.save(deps.storage, &addr, &validator)?; + + // Update module state + let mut state = MODULE_STATE.load(deps.storage)?; + state.last_performance_evaluation = Some(env.block.time); + MODULE_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "submit_performance_report") + .add_attribute("validator", validator_addr) + .add_attribute("composite_score", composite.to_string()) + .add_attribute("period", period.to_string())) +} + +fn execute_initiate_probation( + deps: DepsMut, + env: Env, + info: MessageInfo, + validator_addr: String, + reason: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let addr = deps.api.addr_validate(&validator_addr)?; + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.status != ValidatorStatus::Active { + return Err(ContractError::InvalidStatus { + expected: "Active".to_string(), + actual: validator.status.to_string(), + }); + } + + if validator.performance_score_bps >= config.performance_threshold_bps { + return Err(ContractError::ScoreAboveThreshold { + score: validator.performance_score_bps, + threshold: config.performance_threshold_bps, + }); + } + + validator.status = ValidatorStatus::Probation; + validator.probation_start = Some(env.block.time); + + // Decrement active count since probation is not Active + let mut state = MODULE_STATE.load(deps.storage)?; + state.total_active = state.total_active.saturating_sub(1); + MODULE_STATE.save(deps.storage, &state)?; + + VALIDATORS.save(deps.storage, &addr, &validator)?; + ACTIVE_VALIDATORS.remove(deps.storage, &addr); + + Ok(Response::new() + .add_attribute("action", "initiate_probation") + .add_attribute("validator", validator_addr) + .add_attribute("reason", reason)) +} + +fn execute_restore_from_probation( + deps: DepsMut, + _env: Env, + info: MessageInfo, + validator_addr: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let addr = deps.api.addr_validate(&validator_addr)?; + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.status != ValidatorStatus::Probation { + return Err(ContractError::InvalidStatus { + expected: "Probation".to_string(), + actual: validator.status.to_string(), + }); + } + + if validator.performance_score_bps < config.performance_threshold_bps { + return Err(ContractError::ScoreBelowThreshold { + score: validator.performance_score_bps, + threshold: config.performance_threshold_bps, + }); + } + + validator.status = ValidatorStatus::Active; + validator.probation_start = None; + + // Re-increment active count + let mut state = MODULE_STATE.load(deps.storage)?; + state.total_active += 1; + MODULE_STATE.save(deps.storage, &state)?; + + VALIDATORS.save(deps.storage, &addr, &validator)?; + ACTIVE_VALIDATORS.save(deps.storage, &addr, &true)?; + + Ok(Response::new() + .add_attribute("action", "restore_from_probation") + .add_attribute("validator", validator_addr)) +} + +fn execute_confirm_removal( + deps: DepsMut, + _env: Env, + info: MessageInfo, + validator_addr: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let addr = deps.api.addr_validate(&validator_addr)?; + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.status != ValidatorStatus::Probation { + return Err(ContractError::InvalidStatus { + expected: "Probation".to_string(), + actual: validator.status.to_string(), + }); + } + + // Check composition — count active validators in this category excluding this one + // (This validator is already not Active, so just check the remaining active set) + let category_active = count_active_in_category(deps.as_ref(), &validator.category)?; + // Since the validator is Probation (not Active), category_active doesn't include them. + // We only need to ensure the category still has enough Active validators. + if category_active < config.min_per_category { + return Err(ContractError::CompositionViolation { + category: validator.category.to_string(), + count: category_active, + min: config.min_per_category, + }); + } + + validator.status = ValidatorStatus::Removed; + VALIDATORS.save(deps.storage, &addr, &validator)?; + ACTIVE_VALIDATORS.remove(deps.storage, &addr); // defensive: already removed at probation + + Ok(Response::new() + .add_attribute("action", "confirm_removal") + .add_attribute("validator", validator_addr)) +} + +fn execute_end_term( + deps: DepsMut, + env: Env, + info: MessageInfo, + validator_addr: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let addr = deps.api.addr_validate(&validator_addr)?; + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.status != ValidatorStatus::Active { + return Err(ContractError::InvalidStatus { + expected: "Active".to_string(), + actual: validator.status.to_string(), + }); + } + + let term_end = validator.term_end.ok_or(ContractError::TermNotEnded)?; + if env.block.time < term_end { + return Err(ContractError::TermNotEnded); + } + + validator.status = ValidatorStatus::TermExpired; + + let mut state = MODULE_STATE.load(deps.storage)?; + state.total_active = state.total_active.saturating_sub(1); + MODULE_STATE.save(deps.storage, &state)?; + + VALIDATORS.save(deps.storage, &addr, &validator)?; + ACTIVE_VALIDATORS.remove(deps.storage, &addr); + + Ok(Response::new() + .add_attribute("action", "end_validator_term") + .add_attribute("validator", validator_addr)) +} + +fn execute_distribute_compensation( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let mut state = MODULE_STATE.load(deps.storage)?; + + if state.validator_fund_balance.is_zero() { + return Err(ContractError::InsufficientFund { + required: "non-zero".to_string(), + available: "0".to_string(), + }); + } + + // Collect active validators via the ACTIVE_VALIDATORS index (bounded) + let active_validators: Vec = ACTIVE_VALIDATORS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(addr, _)| VALIDATORS.load(deps.storage, &addr)) + .filter_map(|r| r.ok()) + .collect(); + + let active_count = active_validators.len() as u128; + if active_count == 0 { + return Err(ContractError::InsufficientFund { + required: "at least 1 active validator".to_string(), + available: "0 active validators".to_string(), + }); + } + + let fund = state.validator_fund_balance; + + // 90% base — equal split + let base_pool = fund.multiply_ratio(config.base_compensation_share_bps as u128, 10_000u128); + let base_per_validator = base_pool.multiply_ratio(1u128, active_count); + + // 10% bonus — pro-rata by performance score + let bonus_pool = fund.multiply_ratio(config.performance_bonus_share_bps as u128, 10_000u128); + let total_score: u128 = active_validators + .iter() + .map(|v| v.performance_score_bps as u128) + .sum(); + + let mut total_distributed = Uint128::zero(); + + for av in &active_validators { + let bonus = if total_score > 0 { + bonus_pool.multiply_ratio(av.performance_score_bps as u128, total_score) + } else { + // Equal split if no scores + bonus_pool.multiply_ratio(1u128, active_count) + }; + + let total_comp = base_per_validator + bonus; + total_distributed += total_comp; + + let mut v = VALIDATORS.load(deps.storage, &av.address)?; + v.compensation_due += total_comp; + VALIDATORS.save(deps.storage, &av.address, &v)?; + } + + state.validator_fund_balance = state + .validator_fund_balance + .saturating_sub(total_distributed); + state.last_compensation_distribution = Some(env.block.time); + MODULE_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "distribute_compensation") + .add_attribute("total_distributed", total_distributed) + .add_attribute("active_validators", active_count.to_string())) +} + +fn execute_claim_compensation( + deps: DepsMut, + _env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let addr = info.sender.clone(); + + let mut validator = load_validator(deps.as_ref(), &addr)?; + + if validator.compensation_due.is_zero() { + return Err(ContractError::NoCompensationDue); + } + + let amount = validator.compensation_due; + validator.compensation_due = Uint128::zero(); + VALIDATORS.save(deps.storage, &addr, &validator)?; + + let msg = BankMsg::Send { + to_address: addr.to_string(), + amount: vec![Coin { + denom: config.denom, + amount, + }], + }; + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "claim_compensation") + .add_attribute("validator", addr) + .add_attribute("amount", amount)) +} + +fn execute_update_fund( + deps: DepsMut, + _env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + let sent = info + .funds + .iter() + .find(|c| c.denom == config.denom) + .map(|c| c.amount) + .unwrap_or(Uint128::zero()); + + if sent.is_zero() { + return Err(ContractError::WrongDenom { + expected: config.denom, + got: "nothing or wrong denom".to_string(), + }); + } + + let mut state = MODULE_STATE.load(deps.storage)?; + state.validator_fund_balance += sent; + MODULE_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "update_validator_fund") + .add_attribute("deposited", sent) + .add_attribute("new_balance", state.validator_fund_balance)) +} + +#[allow(clippy::too_many_arguments)] +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + min_validators: Option, + max_validators: Option, + term_length_seconds: Option, + probation_period_seconds: Option, + min_uptime_bps: Option, + performance_threshold_bps: Option, + uptime_weight_bps: Option, + governance_weight_bps: Option, + ecosystem_weight_bps: Option, + base_compensation_share_bps: Option, + performance_bonus_share_bps: Option, + min_per_category: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + require_admin(&config, &info)?; + + if let Some(v) = min_validators { + config.min_validators = v; + } + if let Some(v) = max_validators { + config.max_validators = v; + } + if let Some(v) = term_length_seconds { + config.term_length_seconds = v; + } + if let Some(v) = probation_period_seconds { + config.probation_period_seconds = v; + } + if let Some(v) = min_uptime_bps { + validate_bps(v)?; + config.min_uptime_bps = v; + } + if let Some(v) = performance_threshold_bps { + validate_bps(v)?; + config.performance_threshold_bps = v; + } + + // If any weight is updated, validate the sum + let new_uptime_w = uptime_weight_bps.unwrap_or(config.uptime_weight_bps); + let new_gov_w = governance_weight_bps.unwrap_or(config.governance_weight_bps); + let new_eco_w = ecosystem_weight_bps.unwrap_or(config.ecosystem_weight_bps); + if uptime_weight_bps.is_some() || governance_weight_bps.is_some() || ecosystem_weight_bps.is_some() { + let sum = new_uptime_w + new_gov_w + new_eco_w; + if sum != 10_000 { + return Err(ContractError::InvalidWeightSum { total: sum }); + } + } + config.uptime_weight_bps = new_uptime_w; + config.governance_weight_bps = new_gov_w; + config.ecosystem_weight_bps = new_eco_w; + + // If any compensation share is updated, validate the sum + let new_base = base_compensation_share_bps.unwrap_or(config.base_compensation_share_bps); + let new_bonus = performance_bonus_share_bps.unwrap_or(config.performance_bonus_share_bps); + if base_compensation_share_bps.is_some() || performance_bonus_share_bps.is_some() { + let sum = new_base + new_bonus; + if sum != 10_000 { + return Err(ContractError::InvalidWeightSum { total: sum }); + } + } + config.base_compensation_share_bps = new_base; + config.performance_bonus_share_bps = new_bonus; + + if let Some(v) = min_per_category { + config.min_per_category = v; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Validator { address } => to_json_binary(&query_validator(deps, address)?), + QueryMsg::ActiveValidators {} => to_json_binary(&query_active_validators(deps)?), + QueryMsg::ValidatorsByCategory { category } => { + to_json_binary(&query_validators_by_category(deps, category)?) + } + QueryMsg::ValidatorsByStatus { status } => { + to_json_binary(&query_validators_by_status(deps, status)?) + } + QueryMsg::PerformanceRecord { validator, period } => { + to_json_binary(&query_performance_record(deps, validator, period)?) + } + QueryMsg::CompositionBreakdown {} => to_json_binary(&query_composition_breakdown(deps)?), + QueryMsg::ModuleState {} => to_json_binary(&query_module_state(deps)?), + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { config }) +} + +fn query_validator(deps: Deps, address: String) -> StdResult { + let addr = deps.api.addr_validate(&address)?; + let validator = VALIDATORS.load(deps.storage, &addr)?; + Ok(ValidatorResponse { validator }) +} + +fn query_active_validators(deps: Deps) -> StdResult { + let validators: Vec = ACTIVE_VALIDATORS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(addr, _)| VALIDATORS.load(deps.storage, &addr)) + .filter_map(|r| r.ok()) + .collect(); + Ok(ValidatorsResponse { validators }) +} + +fn query_validators_by_category( + deps: Deps, + category: ValidatorCategory, +) -> StdResult { + let validators: Vec = VALIDATORS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(_, v)| v) + .filter(|v| v.category == category) + .collect(); + Ok(ValidatorsResponse { validators }) +} + +fn query_validators_by_status( + deps: Deps, + status: ValidatorStatus, +) -> StdResult { + let validators: Vec = VALIDATORS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(_, v)| v) + .filter(|v| v.status == status) + .collect(); + Ok(ValidatorsResponse { validators }) +} + +fn query_performance_record( + deps: Deps, + validator: String, + period: u64, +) -> StdResult { + let addr = deps.api.addr_validate(&validator)?; + let record = PERFORMANCE_RECORDS.load(deps.storage, (&addr, period))?; + Ok(PerformanceRecordResponse { record }) +} + +fn query_composition_breakdown(deps: Deps) -> StdResult { + let mut infra = 0u32; + let mut refi = 0u32; + let mut eco = 0u32; + + let all: Vec = ACTIVE_VALIDATORS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(addr, _)| VALIDATORS.load(deps.storage, &addr)) + .filter_map(|r| r.ok()) + .collect(); + + for v in &all { + match v.category { + ValidatorCategory::InfrastructureBuilders => infra += 1, + ValidatorCategory::TrustedRefiPartners => refi += 1, + ValidatorCategory::EcologicalDataStewards => eco += 1, + } + } + + Ok(CompositionBreakdownResponse { + infrastructure_builders: infra, + trusted_refi_partners: refi, + ecological_data_stewards: eco, + total_active: infra + refi + eco, + }) +} + +fn query_module_state(deps: Deps) -> StdResult { + let state = MODULE_STATE.load(deps.storage)?; + Ok(ModuleStateResponse { state }) +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +fn require_admin(config: &Config, info: &MessageInfo) -> Result<(), ContractError> { + if info.sender != config.admin { + return Err(ContractError::Unauthorized { + reason: "Only admin can perform this action".to_string(), + }); + } + Ok(()) +} + +fn load_validator(deps: Deps, addr: &Addr) -> Result { + VALIDATORS + .load(deps.storage, addr) + .map_err(|_| ContractError::ValidatorNotFound { + address: addr.to_string(), + }) +} + +fn validate_bps(value: u64) -> Result<(), ContractError> { + if value > 10_000 { + return Err(ContractError::InvalidBasisPoints { value }); + } + Ok(()) +} + +fn count_active_in_category( + deps: Deps, + category: &ValidatorCategory, +) -> Result { + let count = ACTIVE_VALIDATORS + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|r| r.ok()) + .map(|(addr, _)| VALIDATORS.load(deps.storage, &addr)) + .filter_map(|r| r.ok()) + .filter(|v| v.category == *category) + .count() as u32; + Ok(count) +} + +// ── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; + use cosmwasm_std::{Addr, Coin, Uint128}; + + const DENOM: &str = "uregen"; + + fn addr(input: &str) -> Addr { + MockApi::default().addr_make(input) + } + + fn setup_contract(deps: DepsMut) -> MessageInfo { + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + min_validators: Some(2), // Low for testing + max_validators: Some(6), + term_length_seconds: None, + probation_period_seconds: None, + min_uptime_bps: None, + performance_threshold_bps: None, + uptime_weight_bps: None, + governance_weight_bps: None, + ecosystem_weight_bps: None, + base_compensation_share_bps: None, + performance_bonus_share_bps: None, + min_per_category: Some(1), // Low for testing + denom: DENOM.to_string(), + }; + instantiate(deps, mock_env(), info.clone(), msg).unwrap(); + info + } + + fn apply_validator( + deps: DepsMut, + sender: &Addr, + category: ValidatorCategory, + ) { + let info = message_info(sender, &[]); + let msg = ExecuteMsg::ApplyForValidator { + category, + application_data: "test application".to_string(), + }; + execute(deps, mock_env(), info, msg).unwrap(); + } + + fn approve_validator(deps: DepsMut, admin: &Addr, applicant: &Addr) { + let info = message_info(admin, &[]); + let msg = ExecuteMsg::ApproveValidator { + applicant: applicant.to_string(), + }; + execute(deps, mock_env(), info, msg).unwrap(); + } + + fn activate_validator(deps: DepsMut, admin: &Addr, validator: &Addr) { + let info = message_info(admin, &[]); + let msg = ExecuteMsg::ActivateValidator { + validator: validator.to_string(), + }; + execute(deps, mock_env(), info, msg).unwrap(); + } + + // ── Test 1: Instantiate ──────────────────────────────────────────── + + #[test] + fn test_instantiate() { + let mut deps = mock_dependencies(); + let info = setup_contract(deps.as_mut()); + + let res: ConfigResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap(), + ) + .unwrap(); + let c = res.config; + assert_eq!(c.admin, info.sender); + assert_eq!(c.min_validators, 2); + assert_eq!(c.max_validators, 6); + assert_eq!(c.term_length_seconds, 31_536_000); + assert_eq!(c.probation_period_seconds, 2_592_000); + assert_eq!(c.min_uptime_bps, 9950); + assert_eq!(c.performance_threshold_bps, 7000); + assert_eq!(c.uptime_weight_bps, 4000); + assert_eq!(c.governance_weight_bps, 3000); + assert_eq!(c.ecosystem_weight_bps, 3000); + assert_eq!(c.base_compensation_share_bps, 9000); + assert_eq!(c.performance_bonus_share_bps, 1000); + assert_eq!(c.min_per_category, 1); + assert_eq!(c.denom, DENOM); + + let state_res: ModuleStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ModuleState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state_res.state.total_active, 0); + assert_eq!(state_res.state.validator_fund_balance, Uint128::zero()); + } + + // ── Test 2: Apply + Approve + Activate lifecycle ─────────────────── + + #[test] + fn test_apply_approve_activate() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let val1 = addr("validator1"); + + // Apply + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::InfrastructureBuilders, + ); + + // Check candidate status + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.status, ValidatorStatus::Candidate); + assert_eq!( + vr.validator.category, + ValidatorCategory::InfrastructureBuilders + ); + + // Approve + approve_validator(deps.as_mut(), &admin, &val1); + + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.status, ValidatorStatus::Approved); + + // Activate + activate_validator(deps.as_mut(), &admin, &val1); + + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.status, ValidatorStatus::Active); + assert!(vr.validator.term_start.is_some()); + assert!(vr.validator.term_end.is_some()); + + // Module state should reflect 1 active + let state_res: ModuleStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ModuleState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state_res.state.total_active, 1); + + // Active validators query + let active_res: ValidatorsResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ActiveValidators {}).unwrap(), + ) + .unwrap(); + assert_eq!(active_res.validators.len(), 1); + assert_eq!(active_res.validators[0].address, val1); + } + + // ── Test 3: Performance report + score calculation ───────────────── + + #[test] + fn test_performance_report() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let val1 = addr("validator1"); + + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::TrustedRefiPartners, + ); + approve_validator(deps.as_mut(), &admin, &val1); + activate_validator(deps.as_mut(), &admin, &val1); + + // Submit performance: uptime=9800, gov=8000, eco=7500 + // Composite = (9800*4000 + 8000*3000 + 7500*3000) / 10000 + // = (39200000 + 24000000 + 22500000) / 10000 + // = 85700000 / 10000 = 8570 + let info = message_info(&admin, &[]); + let msg = ExecuteMsg::SubmitPerformanceReport { + validator: val1.to_string(), + uptime_bps: 9800, + governance_participation_bps: 8000, + ecosystem_contribution_bps: 7500, + }; + let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + let score_attr = res + .attributes + .iter() + .find(|a| a.key == "composite_score") + .unwrap(); + assert_eq!(score_attr.value, "8570"); + + // Verify cached score on validator + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.performance_score_bps, 8570); + + // Verify performance record + let pr: PerformanceRecordResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::PerformanceRecord { + validator: val1.to_string(), + period: 1, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(pr.record.uptime_bps, 9800); + assert_eq!(pr.record.governance_participation_bps, 8000); + assert_eq!(pr.record.ecosystem_contribution_bps, 7500); + assert_eq!(pr.record.composite_score_bps, 8570); + } + + // ── Test 4: Probation flow ───────────────────────────────────────── + + #[test] + fn test_probation_flow() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let val1 = addr("validator1"); + + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::EcologicalDataStewards, + ); + approve_validator(deps.as_mut(), &admin, &val1); + activate_validator(deps.as_mut(), &admin, &val1); + + // Submit low performance score to enable probation + // uptime=5000, gov=5000, eco=5000 + // Composite = (5000*4000 + 5000*3000 + 5000*3000) / 10000 = 5000 + let info = message_info(&admin, &[]); + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::SubmitPerformanceReport { + validator: val1.to_string(), + uptime_bps: 5000, + governance_participation_bps: 5000, + ecosystem_contribution_bps: 5000, + }, + ) + .unwrap(); + + // Initiate probation (score 5000 < threshold 7000) + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::InitiateProbation { + validator: val1.to_string(), + reason: "Low performance".to_string(), + }, + ) + .unwrap(); + + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.status, ValidatorStatus::Probation); + + // Active count decremented + let state_res: ModuleStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ModuleState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state_res.state.total_active, 0); + + // Submit improved performance + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::SubmitPerformanceReport { + validator: val1.to_string(), + uptime_bps: 9500, + governance_participation_bps: 8000, + ecosystem_contribution_bps: 8000, + }, + ) + .unwrap(); + + // Restore from probation (score should now be above threshold) + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RestoreFromProbation { + validator: val1.to_string(), + }, + ) + .unwrap(); + + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.status, ValidatorStatus::Active); + + // Active count restored + let state_res: ModuleStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ModuleState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state_res.state.total_active, 1); + } + + // ── Test 5: Compensation distribution ────────────────────────────── + + #[test] + fn test_compensation_distribution() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let val1 = addr("validator1"); + let val2 = addr("validator2"); + + // Set up two active validators + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::InfrastructureBuilders, + ); + approve_validator(deps.as_mut(), &admin, &val1); + activate_validator(deps.as_mut(), &admin, &val1); + + apply_validator( + deps.as_mut(), + &val2, + ValidatorCategory::TrustedRefiPartners, + ); + approve_validator(deps.as_mut(), &admin, &val2); + activate_validator(deps.as_mut(), &admin, &val2); + + // Submit performance: val1 gets 8000, val2 gets 6000 + let admin_info = message_info(&admin, &[]); + execute( + deps.as_mut(), + mock_env(), + admin_info.clone(), + ExecuteMsg::SubmitPerformanceReport { + validator: val1.to_string(), + uptime_bps: 8000, + governance_participation_bps: 8000, + ecosystem_contribution_bps: 8000, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + admin_info.clone(), + ExecuteMsg::SubmitPerformanceReport { + validator: val2.to_string(), + uptime_bps: 6000, + governance_participation_bps: 6000, + ecosystem_contribution_bps: 6000, + }, + ) + .unwrap(); + + // Fund the validator pool with 10000 uregen + let fund_info = message_info(&admin, &[Coin::new(10000u128, DENOM)]); + execute( + deps.as_mut(), + mock_env(), + fund_info, + ExecuteMsg::UpdateValidatorFund {}, + ) + .unwrap(); + + // Verify fund balance + let state_res: ModuleStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ModuleState {}).unwrap(), + ) + .unwrap(); + assert_eq!( + state_res.state.validator_fund_balance, + Uint128::new(10000) + ); + + // Distribute compensation + execute( + deps.as_mut(), + mock_env(), + admin_info, + ExecuteMsg::DistributeCompensation {}, + ) + .unwrap(); + + // Check compensation_due for each validator + // Base pool = 10000 * 9000 / 10000 = 9000 → 4500 each + // Bonus pool = 10000 * 1000 / 10000 = 1000 + // val1 score = 8000, val2 score = 6000, total = 14000 + // val1 bonus = 1000 * 8000 / 14000 = 571 + // val2 bonus = 1000 * 6000 / 14000 = 428 + // val1 total = 4500 + 571 = 5071 + // val2 total = 4500 + 428 = 4928 + + let vr1: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + let vr2: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val2.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + // Due to integer division: base_per = 9000/2 = 4500 + // val1 bonus = 1000 * 8000 / 14000 = 571 (integer) + // val2 bonus = 1000 * 6000 / 14000 = 428 (integer) + assert_eq!(vr1.validator.compensation_due, Uint128::new(5071)); + assert_eq!(vr2.validator.compensation_due, Uint128::new(4928)); + + // Total distributed = 5071 + 4928 = 9999 (1 lost to rounding) + let state_res: ModuleStateResponse = cosmwasm_std::from_json( + query(deps.as_ref(), mock_env(), QueryMsg::ModuleState {}).unwrap(), + ) + .unwrap(); + assert_eq!(state_res.state.validator_fund_balance, Uint128::new(1)); // rounding dust + + // val1 claims + let val1_info = message_info(&val1, &[]); + let claim_res = execute( + deps.as_mut(), + mock_env(), + val1_info, + ExecuteMsg::ClaimCompensation {}, + ) + .unwrap(); + + // Should have a BankMsg::Send + assert_eq!(claim_res.messages.len(), 1); + + // Compensation should be zero after claim + let vr1: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr1.validator.compensation_due, Uint128::zero()); + } + + // ── Test 6: Duplicate application rejected ───────────────────────── + + #[test] + fn test_duplicate_application_rejected() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let val1 = addr("validator1"); + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::InfrastructureBuilders, + ); + + // Second application should fail + let info = message_info(&val1, &[]); + let msg = ExecuteMsg::ApplyForValidator { + category: ValidatorCategory::TrustedRefiPartners, + application_data: "second attempt".to_string(), + }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::ValidatorAlreadyExists { .. })); + } + + // ── Test 7: Non-admin cannot approve ─────────────────────────────── + + #[test] + fn test_non_admin_cannot_approve() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let val1 = addr("validator1"); + let impostor = addr("impostor"); + + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::InfrastructureBuilders, + ); + + let info = message_info(&impostor, &[]); + let msg = ExecuteMsg::ApproveValidator { + applicant: val1.to_string(), + }; + let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + // ── Test 8: End term after expiry ────────────────────────────────── + + #[test] + fn test_end_term() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let val1 = addr("validator1"); + + apply_validator( + deps.as_mut(), + &val1, + ValidatorCategory::EcologicalDataStewards, + ); + approve_validator(deps.as_mut(), &admin, &val1); + activate_validator(deps.as_mut(), &admin, &val1); + + // Try ending term before expiry — should fail + let info = message_info(&admin, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::EndValidatorTerm { + validator: val1.to_string(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::TermNotEnded)); + + // Advance time past term end (12 months + 1 second) + let mut env = mock_env(); + env.block.time = env.block.time.plus_seconds(31_536_001); + + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::EndValidatorTerm { + validator: val1.to_string(), + }, + ) + .unwrap(); + + let vr: ValidatorResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + env, + QueryMsg::Validator { + address: val1.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(vr.validator.status, ValidatorStatus::TermExpired); + } + + // ── Test 9: Composition breakdown query ──────────────────────────── + + #[test] + fn test_composition_breakdown() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let admin = addr("admin"); + let v1 = addr("v1"); + let v2 = addr("v2"); + let v3 = addr("v3"); + + apply_validator( + deps.as_mut(), + &v1, + ValidatorCategory::InfrastructureBuilders, + ); + approve_validator(deps.as_mut(), &admin, &v1); + activate_validator(deps.as_mut(), &admin, &v1); + + apply_validator( + deps.as_mut(), + &v2, + ValidatorCategory::TrustedRefiPartners, + ); + approve_validator(deps.as_mut(), &admin, &v2); + activate_validator(deps.as_mut(), &admin, &v2); + + apply_validator( + deps.as_mut(), + &v3, + ValidatorCategory::EcologicalDataStewards, + ); + approve_validator(deps.as_mut(), &admin, &v3); + activate_validator(deps.as_mut(), &admin, &v3); + + let breakdown: CompositionBreakdownResponse = cosmwasm_std::from_json( + query( + deps.as_ref(), + mock_env(), + QueryMsg::CompositionBreakdown {}, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(breakdown.infrastructure_builders, 1); + assert_eq!(breakdown.trusted_refi_partners, 1); + assert_eq!(breakdown.ecological_data_stewards, 1); + assert_eq!(breakdown.total_active, 3); + } + + // ── Test 10: Weight validation on instantiate ────────────────────── + + #[test] + fn test_invalid_weight_sum() { + let mut deps = mock_dependencies(); + let admin = addr("admin"); + let info = message_info(&admin, &[]); + let msg = InstantiateMsg { + min_validators: None, + max_validators: None, + term_length_seconds: None, + probation_period_seconds: None, + min_uptime_bps: None, + performance_threshold_bps: None, + uptime_weight_bps: Some(5000), + governance_weight_bps: Some(3000), + ecosystem_weight_bps: Some(3000), // sum = 11000, invalid + base_compensation_share_bps: None, + performance_bonus_share_bps: None, + min_per_category: None, + denom: DENOM.to_string(), + }; + let err = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err(); + assert!(matches!(err, ContractError::InvalidWeightSum { total: 11000 })); + } +} diff --git a/contracts/validator-governance/src/error.rs b/contracts/validator-governance/src/error.rs new file mode 100644 index 0000000..10f39c2 --- /dev/null +++ b/contracts/validator-governance/src/error.rs @@ -0,0 +1,63 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + #[error("Composition violation: category {category} would have {count} validators, minimum is {min}")] + CompositionViolation { + category: String, + count: u32, + min: u32, + }, + + #[error("Cannot remove validator: active count {active} would fall below minimum {min}")] + BelowMinValidators { active: u32, min: u32 }, + + #[error("Maximum validators reached: {max} active validators already")] + AboveMaxValidators { max: u32 }, + + #[error("Validator term has expired")] + TermExpired, + + #[error("Validator is already in {status} status")] + AlreadyInStatus { status: String }, + + #[error("Invalid validator status: expected {expected}, got {actual}")] + InvalidStatus { expected: String, actual: String }, + + #[error("Validator {address} not found")] + ValidatorNotFound { address: String }, + + #[error("Validator already exists: {address}")] + ValidatorAlreadyExists { address: String }, + + #[error("Performance score {score} is above threshold {threshold}, probation not warranted")] + ScoreAboveThreshold { score: u64, threshold: u64 }, + + #[error("Performance score {score} is still below threshold {threshold}")] + ScoreBelowThreshold { score: u64, threshold: u64 }, + + #[error("Basis points value {value} out of range (0-10000)")] + InvalidBasisPoints { value: u64 }, + + #[error("No compensation due for this validator")] + NoCompensationDue, + + #[error("Insufficient fund balance: required {required}, available {available}")] + InsufficientFund { required: String, available: String }, + + #[error("Term has not ended yet")] + TermNotEnded, + + #[error("Weights must sum to 10000 bps, got {total}")] + InvalidWeightSum { total: u64 }, + + #[error("Wrong denomination: expected {expected}, got {got}")] + WrongDenom { expected: String, got: String }, +} diff --git a/contracts/validator-governance/src/lib.rs b/contracts/validator-governance/src/lib.rs new file mode 100644 index 0000000..a5abdbb --- /dev/null +++ b/contracts/validator-governance/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/contracts/validator-governance/src/msg.rs b/contracts/validator-governance/src/msg.rs new file mode 100644 index 0000000..a82e281 --- /dev/null +++ b/contracts/validator-governance/src/msg.rs @@ -0,0 +1,172 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +use crate::state::{ + AuthorityValidator, Config, ModuleState, PerformanceRecord, ValidatorCategory, ValidatorStatus, +}; + +// ── Instantiate ──────────────────────────────────────────────────────── + +#[cw_serde] +pub struct InstantiateMsg { + /// Minimum active validators (default 15) + pub min_validators: Option, + /// Maximum active validators (default 21) + pub max_validators: Option, + /// Validator term length in seconds (default 12 months) + pub term_length_seconds: Option, + /// Probation observation period in seconds (default 30 days) + pub probation_period_seconds: Option, + /// Minimum uptime in basis points (default 9950 = 99.5%) + pub min_uptime_bps: Option, + /// Performance threshold for probation (default 7000 = 70%) + pub performance_threshold_bps: Option, + /// Uptime weight in composite score (default 4000 = 40%) + pub uptime_weight_bps: Option, + /// Governance participation weight (default 3000 = 30%) + pub governance_weight_bps: Option, + /// Ecosystem contribution weight (default 3000 = 30%) + pub ecosystem_weight_bps: Option, + /// Base compensation share (default 9000 = 90%) + pub base_compensation_share_bps: Option, + /// Performance bonus share (default 1000 = 10%) + pub performance_bonus_share_bps: Option, + /// Minimum validators per category (default 5) + pub min_per_category: Option, + /// Accepted payment denomination + pub denom: String, +} + +// ── Execute ──────────────────────────────────────────────────────────── + +#[cw_serde] +pub enum ExecuteMsg { + /// Anyone applies to become a validator in a category + ApplyForValidator { + category: ValidatorCategory, + application_data: String, + }, + + /// Admin approves a candidate (checks composition validity) + ApproveValidator { applicant: String }, + + /// Admin activates an approved validator (sets term) + ActivateValidator { validator: String }, + + /// Admin/agent submits a performance report for a validator + SubmitPerformanceReport { + validator: String, + uptime_bps: u64, + governance_participation_bps: u64, + ecosystem_contribution_bps: u64, + }, + + /// Admin initiates probation for underperforming validator + InitiateProbation { validator: String, reason: String }, + + /// Admin restores a validator from probation if performance recovered + RestoreFromProbation { validator: String }, + + /// Admin confirms removal of a probationary validator + ConfirmRemoval { validator: String }, + + /// Admin ends a validator's term when it expires + EndValidatorTerm { validator: String }, + + /// Distribute compensation from fund to all active validators + DistributeCompensation {}, + + /// Validator claims accumulated compensation + ClaimCompensation {}, + + /// Admin deposits into validator fund (must attach funds) + UpdateValidatorFund {}, + + /// Admin updates governance parameters + UpdateConfig { + min_validators: Option, + max_validators: Option, + term_length_seconds: Option, + probation_period_seconds: Option, + min_uptime_bps: Option, + performance_threshold_bps: Option, + uptime_weight_bps: Option, + governance_weight_bps: Option, + ecosystem_weight_bps: Option, + base_compensation_share_bps: Option, + performance_bonus_share_bps: Option, + min_per_category: Option, + }, +} + +// ── Query ────────────────────────────────────────────────────────────── + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the contract configuration + #[returns(ConfigResponse)] + Config {}, + + /// Returns a single validator by address + #[returns(ValidatorResponse)] + Validator { address: String }, + + /// Returns all active validators + #[returns(ValidatorsResponse)] + ActiveValidators {}, + + /// Returns validators filtered by category + #[returns(ValidatorsResponse)] + ValidatorsByCategory { category: ValidatorCategory }, + + /// Returns validators filtered by status + #[returns(ValidatorsResponse)] + ValidatorsByStatus { status: ValidatorStatus }, + + /// Returns a performance record for a validator at a given period + #[returns(PerformanceRecordResponse)] + PerformanceRecord { validator: String, period: u64 }, + + /// Returns category composition breakdown (counts per category) + #[returns(CompositionBreakdownResponse)] + CompositionBreakdown {}, + + /// Returns module state + #[returns(ModuleStateResponse)] + ModuleState {}, +} + +// ── Query Responses ──────────────────────────────────────────────────── + +#[cw_serde] +pub struct ConfigResponse { + pub config: Config, +} + +#[cw_serde] +pub struct ValidatorResponse { + pub validator: AuthorityValidator, +} + +#[cw_serde] +pub struct ValidatorsResponse { + pub validators: Vec, +} + +#[cw_serde] +pub struct PerformanceRecordResponse { + pub record: PerformanceRecord, +} + +#[cw_serde] +pub struct CompositionBreakdownResponse { + pub infrastructure_builders: u32, + pub trusted_refi_partners: u32, + pub ecological_data_stewards: u32, + pub total_active: u32, +} + +#[cw_serde] +pub struct ModuleStateResponse { + pub state: ModuleState, +} diff --git a/contracts/validator-governance/src/state.rs b/contracts/validator-governance/src/state.rs new file mode 100644 index 0000000..d38c28d --- /dev/null +++ b/contracts/validator-governance/src/state.rs @@ -0,0 +1,140 @@ +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// ── Configuration ────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + /// Contract administrator + pub admin: Addr, + /// Minimum active validators (default 15) + pub min_validators: u32, + /// Maximum active validators (default 21) + pub max_validators: u32, + /// Validator term length in seconds (default 12 months = 31_536_000) + pub term_length_seconds: u64, + /// Probation observation period in seconds (default 30 days = 2_592_000) + pub probation_period_seconds: u64, + /// Minimum uptime in basis points (default 9950 = 99.5%) + pub min_uptime_bps: u64, + /// Performance threshold below which probation triggers (default 7000 = 70%) + pub performance_threshold_bps: u64, + /// Weight of uptime in composite score (default 4000 = 40%) + pub uptime_weight_bps: u64, + /// Weight of governance participation in composite score (default 3000 = 30%) + pub governance_weight_bps: u64, + /// Weight of ecosystem contribution in composite score (default 3000 = 30%) + pub ecosystem_weight_bps: u64, + /// Base compensation share for equal split (default 9000 = 90%) + pub base_compensation_share_bps: u64, + /// Performance bonus share for pro-rata (default 1000 = 10%) + pub performance_bonus_share_bps: u64, + /// Minimum validators per category (default 5) + pub min_per_category: u32, + /// Accepted payment denomination + pub denom: String, +} + +// ── Validator Category ───────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ValidatorCategory { + InfrastructureBuilders, + TrustedRefiPartners, + EcologicalDataStewards, +} + +impl std::fmt::Display for ValidatorCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidatorCategory::InfrastructureBuilders => write!(f, "InfrastructureBuilders"), + ValidatorCategory::TrustedRefiPartners => write!(f, "TrustedRefiPartners"), + ValidatorCategory::EcologicalDataStewards => write!(f, "EcologicalDataStewards"), + } + } +} + +// ── Validator Status ─────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ValidatorStatus { + Candidate, + Approved, + Active, + Probation, + Removed, + TermExpired, +} + +impl std::fmt::Display for ValidatorStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidatorStatus::Candidate => write!(f, "Candidate"), + ValidatorStatus::Approved => write!(f, "Approved"), + ValidatorStatus::Active => write!(f, "Active"), + ValidatorStatus::Probation => write!(f, "Probation"), + ValidatorStatus::Removed => write!(f, "Removed"), + ValidatorStatus::TermExpired => write!(f, "TermExpired"), + } + } +} + +// ── Authority Validator ──────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AuthorityValidator { + pub address: Addr, + pub category: ValidatorCategory, + pub status: ValidatorStatus, + pub term_start: Option, + pub term_end: Option, + pub probation_start: Option, + /// Cached composite performance score (0-10000 bps) + pub performance_score_bps: u64, + /// Accumulated compensation due (claimable) + pub compensation_due: Uint128, +} + +// ── Performance Record ───────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct PerformanceRecord { + pub validator_address: Addr, + pub period: u64, + pub uptime_bps: u64, + pub governance_participation_bps: u64, + pub ecosystem_contribution_bps: u64, + pub composite_score_bps: u64, + pub recorded_at: Timestamp, +} + +// ── Module State ─────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ModuleState { + /// Total fund balance available for compensation distribution + pub validator_fund_balance: Uint128, + /// Count of currently Active validators + pub total_active: u32, + /// Last time compensation was distributed + pub last_compensation_distribution: Option, + /// Last time performance evaluations were recorded + pub last_performance_evaluation: Option, +} + +// ── Storage keys ─────────────────────────────────────────────────────── + +pub const CONFIG: Item = Item::new("config"); +pub const MODULE_STATE: Item = Item::new("module_state"); +pub const VALIDATORS: Map<&Addr, AuthorityValidator> = Map::new("validators"); +pub const PERFORMANCE_RECORDS: Map<(&Addr, u64), PerformanceRecord> = + Map::new("performance_records"); +pub const NEXT_PERIOD: Item = Item::new("next_period"); + +/// Index of currently-active validator addresses. +/// Maintained by activate / probation / removal / term-end / restore handlers +/// so that `distribute_compensation` and similar hot paths never need to scan +/// the entire (unbounded) VALIDATORS map. +pub const ACTIVE_VALIDATORS: Map<&Addr, bool> = Map::new("active_validators");