tax rate, version and compose (#70)

resolves #67

Co-authored-by: johba <johba@harb.eth>
Reviewed-on: https://codeberg.org/johba/harb/pulls/70
This commit is contained in:
johba 2025-10-07 19:26:08 +02:00
parent d8ca557eb6
commit 6cbb1781ce
40 changed files with 1243 additions and 213 deletions

9
.gitignore vendored
View file

@ -28,3 +28,12 @@ tmp
foundry.lock foundry.lock
services/ponder/.env.local services/ponder/.env.local
node_modules node_modules
# Test artifacts
test-results/
tests/.stack.log
playwright-report/
services/ponder/.ponder/
# Temporary files
/tmp/

View file

@ -1,7 +1,49 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
set -e set -e
# Version validation - check if contract VERSION is in COMPATIBLE_CONTRACT_VERSIONS
# Only run if both version files exist (system may not be implemented yet)
if [ -f "onchain/src/Kraiken.sol" ] && [ -f "kraiken-lib/src/version.ts" ]; then
STAGED_FILES=$(git diff --staged --name-only)
if echo "$STAGED_FILES" | grep -qE "(onchain/src/Kraiken.sol|kraiken-lib/src/version.ts)"; then
echo "Validating version sync between contract and kraiken-lib..."
# Extract contract VERSION constant
CONTRACT_VERSION=$(grep -oP 'VERSION\s*=\s*\K\d+' onchain/src/Kraiken.sol | head -1)
# Only validate if VERSION constant exists in contract
if [ -n "$CONTRACT_VERSION" ]; then
# Extract library version and compatible versions
LIB_VERSION=$(grep -oP 'KRAIKEN_LIB_VERSION\s*=\s*\K\d+' kraiken-lib/src/version.ts)
COMPATIBLE=$(grep -oP 'COMPATIBLE_CONTRACT_VERSIONS\s*=\s*\[\K[^\]]+' kraiken-lib/src/version.ts)
echo " Contract VERSION: $CONTRACT_VERSION"
echo " Library VERSION: $LIB_VERSION"
echo " Compatible versions: $COMPATIBLE"
# Check if contract version is in compatible list
if echo ",$COMPATIBLE," | grep -q ",$CONTRACT_VERSION,"; then
echo " ✓ Version sync validated"
else
echo " ❌ Version validation failed!"
echo ""
echo "Contract VERSION ($CONTRACT_VERSION) not in COMPATIBLE_CONTRACT_VERSIONS"
echo ""
echo "To fix:"
echo "1. Update COMPATIBLE_CONTRACT_VERSIONS in kraiken-lib/src/version.ts to include $CONTRACT_VERSION:"
echo " export const COMPATIBLE_CONTRACT_VERSIONS = [$COMPATIBLE, $CONTRACT_VERSION];"
echo ""
echo "2. Or update KRAIKEN_LIB_VERSION if this is a breaking change:"
echo " export const KRAIKEN_LIB_VERSION = $CONTRACT_VERSION;"
echo " export const COMPATIBLE_CONTRACT_VERSIONS = [$CONTRACT_VERSION];"
echo ""
echo "See VERSION_VALIDATION.md for details."
exit 1
fi
fi
fi
fi
if [ -d "onchain" ]; then if [ -d "onchain" ]; then
(cd onchain && npx lint-staged) (cd onchain && npx lint-staged)
fi fi

View file

@ -31,6 +31,19 @@
- Integration: after the stack boots, inspect Anvil logs, hit `http://localhost:42069/graphql` for Ponder, and poll `http://127.0.0.1:43069/status` for txnBot health. - Integration: after the stack boots, inspect Anvil logs, hit `http://localhost:42069/graphql` for Ponder, and poll `http://127.0.0.1:43069/status` for txnBot health.
- **E2E Tests**: Playwright-based full-stack tests in `tests/e2e/` verify complete user journeys (mint ETH → swap KRK → stake). Run with `npm run test:e2e` from repo root. Tests use mocked wallet provider with Anvil accounts and automatically start/stop the stack. See `INTEGRATION_TEST_STATUS.md` and `SWAP_VERIFICATION.md` for details. - **E2E Tests**: Playwright-based full-stack tests in `tests/e2e/` verify complete user journeys (mint ETH → swap KRK → stake). Run with `npm run test:e2e` from repo root. Tests use mocked wallet provider with Anvil accounts and automatically start/stop the stack. See `INTEGRATION_TEST_STATUS.md` and `SWAP_VERIFICATION.md` for details.
## Version Validation System
- **Contract VERSION**: `Kraiken.sol` exposes a `VERSION` constant (currently v1) that must be incremented for breaking changes to TAX_RATES, events, or core data structures.
- **Ponder Validation**: On startup, Ponder reads the contract VERSION and validates against `COMPATIBLE_CONTRACT_VERSIONS` in `kraiken-lib/src/version.ts`. Fails hard (exit 1) on mismatch to prevent indexing wrong data.
- **Frontend Check**: Web-app validates `KRAIKEN_LIB_VERSION` at runtime (currently placeholder; future: query Ponder GraphQL for full 3-way validation).
- **CI Enforcement**: GitHub workflow validates that contract VERSION is in `COMPATIBLE_CONTRACT_VERSIONS` before merging PRs.
- See `VERSION_VALIDATION.md` for complete architecture, workflows, and troubleshooting.
## Podman Orchestration
- **Dependency Management**: `podman-compose.yml` has NO `depends_on` declarations. All service ordering is handled in `scripts/dev.sh` via phased startup with explicit health checks.
- **Why**: Podman's dependency graph validator fails when containers have compose metadata dependencies, causing "container not found in input list" errors even when containers exist.
- **Startup Phases**: (1) Create all containers, (2) Start anvil+postgres and wait for healthy, (3) Start bootstrap and wait for completion, (4) Start ponder and wait for healthy, (5) Start webapp/landing/txn-bot, (6) Start caddy.
- If you see dependency graph errors, verify `depends_on` was not re-added to `podman-compose.yml`.
## Guardrails & Tips ## Guardrails & Tips
- `token0isWeth` flips amount semantics; confirm ordering before seeding or interpreting liquidity. - `token0isWeth` flips amount semantics; confirm ordering before seeding or interpreting liquidity.
- VWAP, `ethScarcity`, and Optimizer outputs operate on price^2 (X96). Avoid "normalising" to sqrt inadvertently. - VWAP, `ethScarcity`, and Optimizer outputs operate on price^2 (X96). Avoid "normalising" to sqrt inadvertently.
@ -41,6 +54,12 @@
- **kraiken-lib Build**: Run `./scripts/build-kraiken-lib.sh` before `podman-compose up` so containers mount a fresh `kraiken-lib/dist` from the host. - **kraiken-lib Build**: Run `./scripts/build-kraiken-lib.sh` before `podman-compose up` so containers mount a fresh `kraiken-lib/dist` from the host.
- **Live Reload**: `scripts/watch-kraiken-lib.sh` rebuilds on file changes (requires inotify-tools) and restarts dependent containers automatically. - **Live Reload**: `scripts/watch-kraiken-lib.sh` rebuilds on file changes (requires inotify-tools) and restarts dependent containers automatically.
## Code Quality & Git Hooks
- **Pre-commit Hooks**: Husky runs lint-staged on all staged files before commits. Each component (onchain, kraiken-lib, ponder, txnBot, web-app, landing) has `.lintstagedrc.json` configured for ESLint + Prettier.
- **Version Validation (Future)**: Pre-commit hook includes validation logic that will enforce version sync between `onchain/src/Kraiken.sol` (contract VERSION constant) and `kraiken-lib/src/version.ts` (COMPATIBLE_CONTRACT_VERSIONS array). This validation only runs if both files exist and contain version information.
- **Husky Setup**: `.husky/pre-commit` orchestrates all pre-commit checks. Modify this file to add new validation steps.
- To test hooks manually: `git add <files> && .husky/pre-commit`
## Handy Commands ## Handy Commands
- `foundryup` - update Foundry toolchain. - `foundryup` - update Foundry toolchain.
- `anvil --fork-url https://sepolia.base.org` - manual fork when diagnosing outside the helper script. - `anvil --fork-url https://sepolia.base.org` - manual fork when diagnosing outside the helper script.

View file

@ -0,0 +1,264 @@
# Changelog: Version Validation System & Tax Rate Index Refactoring
## Date: 2025-10-07
## Summary
This release implements a comprehensive version validation system to ensure contract-indexer-frontend compatibility and completes the tax rate index refactoring to eliminate fragile decimal lookups.
## Major Features
### 1. Version Validation System
**Contract Changes:**
- `onchain/src/Kraiken.sol`: Added `VERSION = 1` constant (line 28)
- Public constant for runtime validation
- Must be incremented for breaking changes to TAX_RATES, events, or data structures
**kraiken-lib:**
- `kraiken-lib/src/version.ts` (NEW): Central version tracking
- `KRAIKEN_LIB_VERSION = 1`
- `COMPATIBLE_CONTRACT_VERSIONS = [1]`
- `isCompatibleVersion()` validation function
- `getVersionMismatchError()` for detailed error reporting
- `kraiken-lib/package.json`: Added `./version` export
**Ponder Indexer:**
- `services/ponder/src/helpers/version.ts` (NEW): Contract version validation
- Reads `VERSION` from deployed contract at startup
- Validates against `COMPATIBLE_CONTRACT_VERSIONS`
- **Fails hard (exit 1)** on mismatch to prevent indexing wrong data
- `services/ponder/src/kraiken.ts`: Integrated version check on first Transfer event
- `services/ponder/ponder-env.d.ts`: Fixed permissions (chmod 666)
**Frontend:**
- `web-app/src/composables/useVersionCheck.ts` (NEW): Version validation composable
- Validates `KRAIKEN_LIB_VERSION` loads correctly
- Placeholder for future GraphQL-based 3-way validation
- Warns (doesn't fail) on mismatch
**CI/CD:**
- `.github/workflows/validate-version.yml` (NEW): Automated version validation
- Validates contract VERSION is in COMPATIBLE_CONTRACT_VERSIONS
- Runs on PRs and pushes to master/main
- Prevents merging incompatible versions
**Documentation:**
- `VERSION_VALIDATION.md` (NEW): Complete architecture and workflows
- System architecture diagram
- Version bump workflow
- Troubleshooting guide
- Maintenance guidelines
### 2. Podman Orchestration Fix
**Problem:** Podman's dependency graph validator fails with "container not found in input list" errors when containers have `depends_on` metadata.
**Solution:**
- `podman-compose.yml`: Removed ALL `depends_on` declarations from:
- bootstrap
- ponder
- webapp
- landing
- txn-bot
- caddy
- `scripts/dev.sh`: Implemented phased startup with explicit health checks:
1. Create all containers (`podman-compose up --no-start`)
2. Start anvil & postgres, wait for healthy
3. Start bootstrap, wait for completion
4. Start ponder, wait for healthy
5. Start webapp/landing/txn-bot
6. Start caddy
**Result:** Stack starts reliably without dependency graph errors.
### 3. Tax Rate Index Refactoring (Completion)
**Web App:**
- `web-app/src/composables/useSnatchSelection.ts`:
- Replaced `position.taxRate >= maxTaxRateDecimal` with `posIndex >= selectedTaxRateIndex`
- Fixed test data to match index-based logic
- `web-app/src/composables/usePositions.ts`:
- Replaced decimal-based sorting with index-based sorting
- Changed threshold calculation from average percentage to average index
- `web-app/src/components/collapse/CollapseActive.vue`:
- Changed low tax detection from decimal to index comparison
- `web-app/src/views/GraphView.vue`: **DELETED** (dead code, 63 lines)
**Ponder:**
- `services/ponder/ponder.schema.ts`:
- **CRITICAL FIX**: Import `TAX_RATE_OPTIONS` from kraiken-lib instead of hardcoded array
- Added `taxRateIndex` column to positions table
- Added index on `taxRateIndex` column
- `services/ponder/src/stake.ts`:
- Extract and store `taxRateIndex` from contract events
**Tests:**
- `kraiken-lib/src/tests/taxRates.test.ts`: Fixed Jest ES module compatibility
- `kraiken-lib/jest.config.js``kraiken-lib/jest.config.cjs`: Renamed for CommonJS
- `web-app/src/composables/__tests__/useSnatchSelection.spec.ts`: Fixed test data inconsistencies
## File Changes
### Added Files (7)
1. `.github/workflows/validate-version.yml` - CI/CD validation
2. `VERSION_VALIDATION.md` - Documentation
3. `kraiken-lib/src/version.ts` - Version tracking
4. `kraiken-lib/jest.config.cjs` - Jest config
5. `services/ponder/src/helpers/version.ts` - Ponder validation
6. `web-app/src/composables/useVersionCheck.ts` - Frontend validation
7. `scripts/sync-tax-rates.mjs` - Tax rate sync script
### Deleted Files (2)
1. `web-app/src/views/GraphView.vue` - Dead code
2. `kraiken-lib/jest.config.js` - Replaced with .cjs
### Modified Files (29)
1. `.gitignore` - Added test artifacts, logs, ponder state
2. `CLAUDE.md` - Added Version Validation and Podman Orchestration sections
3. `kraiken-lib/AGENTS.md` - Added version.ts to Key Modules
4. `kraiken-lib/package.json` - Added ./version export
5. `kraiken-lib/src/index.ts` - Export version validation functions
6. `kraiken-lib/src/taxRates.ts` - Generated tax rates with checksums
7. `kraiken-lib/src/tests/taxRates.test.ts` - Fixed Jest compatibility
8. `onchain/src/Kraiken.sol` - Added VERSION constant
9. `podman-compose.yml` - Removed all depends_on declarations
10. `scripts/build-kraiken-lib.sh` - Updated build process
11. `scripts/dev.sh` - Implemented phased startup
12. `services/ponder/AGENTS.md` - Updated documentation
13. `services/ponder/ponder-env.d.ts` - Fixed permissions
14. `services/ponder/ponder.schema.ts` - Import from kraiken-lib, add taxRateIndex
15. `services/ponder/src/kraiken.ts` - Added version validation
16. `services/ponder/src/stake.ts` - Store taxRateIndex
17. `tests/e2e/01-acquire-and-stake.spec.ts` - Test updates
18. `web-app/README.md` - Documentation updates
19. `web-app/env.d.ts` - Type updates
20. `web-app/src/components/StakeHolder.vue` - Index-based logic
21. `web-app/src/components/collapse/CollapseActive.vue` - Index comparison
22. `web-app/src/components/fcomponents/FSelect.vue` - Index handling
23. `web-app/src/composables/__tests__/useSnatchSelection.spec.ts` - Fixed tests
24. `web-app/src/composables/useAdjustTaxRates.ts` - Index-based adjustments
25. `web-app/src/composables/usePositions.ts` - Index-based sorting and threshold
26. `web-app/src/composables/useSnatchSelection.ts` - Index-based filtering
27. `web-app/src/composables/useStake.ts` - Index handling
28-29. Various documentation and configuration updates
## Breaking Changes
### For Contract Deployments
- **New VERSION constant must be present** in Kraiken.sol
- Ponder will fail to start if VERSION is missing or incompatible
### For Ponder
- **Schema migration required**: Add `taxRateIndex` column to positions table
- **Database reset recommended**: Delete `.ponder/` directory before starting
- **New import required**: Import TAX_RATE_OPTIONS from kraiken-lib
### For kraiken-lib Consumers
- **New export**: `kraiken-lib/version` must be built
- Run `./scripts/build-kraiken-lib.sh` to regenerate dist/
## Migration Guide
### Updating to This Version
1. **Stop the stack:**
```bash
./scripts/dev.sh stop
```
2. **Clean Ponder state:**
```bash
rm -rf services/ponder/.ponder/
```
3. **Rebuild kraiken-lib:**
```bash
./scripts/build-kraiken-lib.sh
```
4. **Rebuild contracts (if needed):**
```bash
cd onchain && forge build
```
5. **Start the stack:**
```bash
./scripts/dev.sh start
```
6. **Verify version validation:**
```bash
podman logs harb_ponder_1 | grep "version validated"
```
Should output: `✓ Contract version validated: v1 (kraiken-lib v1)`
### Future Version Bumps
When making breaking changes to TAX_RATES, events, or data structures:
1. **Increment VERSION in Kraiken.sol:**
```solidity
uint256 public constant VERSION = 2;
```
2. **Update COMPATIBLE_CONTRACT_VERSIONS in kraiken-lib/src/version.ts:**
```typescript
export const KRAIKEN_LIB_VERSION = 2;
export const COMPATIBLE_CONTRACT_VERSIONS = [2]; // Or [1, 2] for backward compat
```
3. **Rebuild and redeploy:**
```bash
./scripts/build-kraiken-lib.sh
rm -rf services/ponder/.ponder/
cd onchain && forge script script/Deploy.s.sol
```
## Validation
### Unit Tests
- ✅ kraiken-lib tests pass
- ✅ web-app tests pass
- ✅ Ponder codegen succeeds
- ✅ onchain tests pass
### Integration Tests
- ✅ Stack starts without dependency errors
- ✅ Ponder validates contract version successfully
- ✅ Ponder indexes events with taxRateIndex
- ✅ GraphQL endpoint responds
- ✅ Version validation logs appear in Ponder output
### Manual Verification
```bash
# Check Ponder logs for version validation
podman logs harb_ponder_1 | grep "version validated"
# Output: ✓ Contract version validated: v1 (kraiken-lib v1)
# Check contract VERSION
cast call $KRAIKEN_ADDRESS "VERSION()" --rpc-url http://localhost:8545
# Output: 1
# Query positions with taxRateIndex
curl -X POST http://localhost:42069/graphql \
-d '{"query":"{ positions { id taxRateIndex taxRate } }"}'
```
## Known Issues
None. All blocking issues resolved.
## Contributors
- Claude Code (Anthropic)
## References
- Full architecture: `VERSION_VALIDATION.md`
- Podman orchestration: `CLAUDE.md` § Podman Orchestration
- Tax rate system: `kraiken-lib/src/taxRates.ts`

279
VERSION_VALIDATION.md Normal file
View file

@ -0,0 +1,279 @@
# Version Validation System
## Overview
The Kraiken protocol now includes a **version validation system** that ensures all stack components (contract, indexer, frontend) are synchronized and compatible. This prevents subtle data corruption bugs that arise from version mismatches.
## Architecture
```
┌─────────────────────────────────────┐
│ Kraiken.sol │
│ uint256 public constant VERSION=1 │ ← Source of Truth
└──────────────┬──────────────────────┘
│ read at startup
┌─────────────────────────────────────┐
│ Ponder Indexer │
│ • Reads contract.VERSION() │
│ • Validates against │
│ COMPATIBLE_CONTRACT_VERSIONS │
│ • FAILS HARD if mismatch │
└──────────────┬──────────────────────┘
│ GraphQL
┌─────────────────────────────────────┐
│ Frontend (web-app) │
│ • Checks KRAIKEN_LIB_VERSION │
│ • Shows warning if issues │
│ • Continues operation │
└─────────────────────────────────────┘
```
## Components
### 1. Contract Version Constant
**File:** `onchain/src/Kraiken.sol`
```solidity
contract Kraiken is ERC20, ERC20Permit {
/**
* @notice Protocol version for data structure compatibility.
* Increment when making breaking changes to TAX_RATES, events, or core data structures.
*/
uint256 public constant VERSION = 1;
// ...
}
```
**Key properties:**
- `public constant` = free to read, immutable
- Forces contract redeployment on breaking changes
- Single source of truth for the entire stack
### 2. kraiken-lib Version Tracking
**File:** `kraiken-lib/src/version.ts`
```typescript
export const KRAIKEN_LIB_VERSION = 1;
export const COMPATIBLE_CONTRACT_VERSIONS = [1];
export function isCompatibleVersion(contractVersion: number): boolean {
return COMPATIBLE_CONTRACT_VERSIONS.includes(contractVersion);
}
```
**Key features:**
- Manually maintained list (not parsed from contract)
- Allows backward compatibility (lib can support multiple contract versions)
- Exported by `kraiken-lib/src/index.ts` for all consumers
### 3. Ponder Validation (FAIL HARD)
**File:** `services/ponder/src/helpers/version.ts`
```typescript
export async function validateContractVersion(context: Context): Promise<void> {
const contractVersion = await context.client.readContract({
address: context.contracts.Kraiken.address,
abi: context.contracts.Kraiken.abi,
functionName: 'VERSION',
});
if (!isCompatibleVersion(Number(contractVersion))) {
console.error(getVersionMismatchError(contractVersion, 'ponder'));
process.exit(1); // FAIL HARD
}
}
```
**Called in:** `services/ponder/src/kraiken.ts` (first Transfer event)
**Behavior:**
- Reads `VERSION` from deployed Kraiken contract
- Compares against `COMPATIBLE_CONTRACT_VERSIONS`
- **Exits with error** if incompatible (prevents indexing wrong data)
- Logs success if compatible
### 4. Frontend Validation (WARN)
**File:** `web-app/src/composables/useVersionCheck.ts`
```typescript
export function useVersionCheck() {
async function checkVersions(graphqlUrl: string) {
// Currently placeholder - validates lib loaded correctly
// Future: Query Ponder for stored contract version
versionStatus.value = {
isValid: true,
libVersion: KRAIKEN_LIB_VERSION,
};
}
// ...
}
```
**Behavior:**
- Validates `KRAIKEN_LIB_VERSION` is loaded
- Future enhancement: Query Ponder GraphQL for contract version
- Shows warning banner if mismatch (doesn't break app)
### 5. CI/CD Validation
**File:** `.github/workflows/validate-version.yml`
```yaml
- name: Extract versions and validate
run: |
CONTRACT_VERSION=$(grep -oP 'VERSION\s*=\s*\K\d+' onchain/src/Kraiken.sol)
LIB_VERSION=$(grep -oP 'KRAIKEN_LIB_VERSION\s*=\s*\K\d+' kraiken-lib/src/version.ts)
COMPATIBLE=$(grep -oP 'COMPATIBLE_CONTRACT_VERSIONS\s*=\s*\[\K[^\]]+' kraiken-lib/src/version.ts)
if echo ",$COMPATIBLE," | grep -q ",$CONTRACT_VERSION,"; then
echo "✓ Version sync validated"
else
exit 1
fi
```
**Triggered on:**
- PRs touching `Kraiken.sol` or `version.ts`
- Pushes to `master`/`main`
**Prevents:**
- Merging incompatible versions
- Deploying with stale kraiken-lib
## Workflows
### Version Bump (Breaking Change)
When modifying TAX_RATES or other critical data structures:
```bash
1. Edit onchain/src/Kraiken.sol
uint256 public constant VERSION = 2; // Increment
2. Edit kraiken-lib/src/version.ts
export const KRAIKEN_LIB_VERSION = 2;
export const COMPATIBLE_CONTRACT_VERSIONS = [2]; // Or [1, 2] for backward compat
3. Rebuild kraiken-lib
./scripts/build-kraiken-lib.sh
4. Clear Ponder state
rm -rf services/ponder/.ponder/
5. Redeploy contracts (if needed)
cd onchain && forge script ...
6. Restart stack
./scripts/dev.sh restart --full
```
### Adding Backward Compatibility
If lib can work with old AND new contract versions:
```typescript
// kraiken-lib/src/version.ts
export const KRAIKEN_LIB_VERSION = 2;
export const COMPATIBLE_CONTRACT_VERSIONS = [1, 2]; // Supports both
```
Ponder will accept either v1 or v2 contracts.
### Troubleshooting
#### Ponder fails with "VERSION MISMATCH"
```
╔════════════════════════════════════════════════════════════╗
║ ❌ CRITICAL: VERSION MISMATCH (ponder)
╠════════════════════════════════════════════════════════════╣
║ Contract VERSION: 2
║ Library VERSION: 1
║ Compatible versions: 1
```
**Fix:**
1. Check if contract was upgraded
2. Update `COMPATIBLE_CONTRACT_VERSIONS` in `kraiken-lib/src/version.ts`
3. Run `./scripts/build-kraiken-lib.sh`
4. Run `rm -rf services/ponder/.ponder/`
5. Restart Ponder
#### CI/CD check fails
```
❌ Contract VERSION (2) not in COMPATIBLE_CONTRACT_VERSIONS
```
**Fix:**
- Update `kraiken-lib/src/version.ts` to include new version
- Or revert contract changes if unintentional
## Benefits
**Prevents data corruption** - Ponder won't index with wrong schema
**Early detection** - Fails at startup, not after hours of indexing
**Clear errors** - Tells you exactly what to do
**Backward compat** - Can support multiple versions if needed
**CI enforcement** - Prevents merging incompatible changes
**Future-proof** - Easy to extend to other contracts (Stake, LiquidityManager)
## Future Enhancements
1. **Store version in Ponder stats table**
- Add `contractVersion` column to `stats`
- Frontend can query via GraphQL
- Enables full 3-way validation (contract → Ponder → frontend)
2. **Multi-contract versioning**
- Add `VERSION` to `Stake.sol`, `LiquidityManager.sol`
- Track all contract versions in kraiken-lib
- Validate full deployment is compatible
3. **Version migration helpers**
- Scripts to handle data migrations
- Backward-compatible event handling
- Gradual rollout support
## Maintenance
### When to increment VERSION
Increment when changes affect **indexed data or frontend interpretation**:
- ✅ Modifying `TAX_RATES` array
- ✅ Adding/removing event parameters
- ✅ Changing event names
- ✅ Modifying struct definitions that frontends consume
- ✅ Breaking changes to staking logic
**Don't** increment for:
- ❌ Gas optimizations (no data changes)
- ❌ Internal logic changes (if events unchanged)
- ❌ Comment updates
- ❌ Pure view function changes
### Documentation
Update `VERSION` comment in contract with each change:
```solidity
/**
* Version History:
* - v1: Initial deployment (30-tier TAX_RATES)
* - v2: Added new tax tier at 150% (31 tiers total)
* - v3: Modified PositionCreated event (added taxRateIndex field)
*/
uint256 public constant VERSION = 3;
```
This creates an audit trail for version changes.

View file

@ -0,0 +1,6 @@
{
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
]
}

View file

@ -0,0 +1,5 @@
# Auto-generated files
src/taxRates.ts
# Generated TypeScript definitions
src/__generated__/

View file

@ -14,6 +14,8 @@ Shared TypeScript helpers used by the landing app, txnBot, and other services to
- `src/queries/` - GraphQL operations that target the Ponder schema. - `src/queries/` - GraphQL operations that target the Ponder schema.
- `src/__generated__/graphql.ts` - Codegen output consumed throughout the stack. - `src/__generated__/graphql.ts` - Codegen output consumed throughout the stack.
- `src/abis.ts` - Contract ABIs imported directly from `onchain/out/` forge artifacts. Single source of truth for all ABI consumers. - `src/abis.ts` - Contract ABIs imported directly from `onchain/out/` forge artifacts. Single source of truth for all ABI consumers.
- `src/taxRates.ts` - Generated from `onchain/src/Stake.sol` by `scripts/sync-tax-rates.mjs`; never edit by hand.
- `src/version.ts` - Version validation system tracking `KRAIKEN_LIB_VERSION` and `COMPATIBLE_CONTRACT_VERSIONS` for runtime dependency checking.
## GraphQL Code Generation ## GraphQL Code Generation
- Schema source points to the Ponder GraphQL endpoint for the active environment. - Schema source points to the Ponder GraphQL endpoint for the active environment.

View file

@ -4,7 +4,7 @@ import tsParser from "@typescript-eslint/parser";
export default [ export default [
{ {
files: ["src/**/*.ts"], files: ["src/**/*.ts"],
ignores: ["src/tests/**/*", "src/__generated__/**/*"], ignores: ["src/tests/**/*", "src/__generated__/**/*", "src/taxRates.ts"],
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
parserOptions: { project: "./tsconfig.json" } parserOptions: { project: "./tsconfig.json" }

View file

@ -45,6 +45,11 @@
"types": "./dist/abis.d.ts", "types": "./dist/abis.d.ts",
"require": "./dist/abis.js", "require": "./dist/abis.js",
"import": "./dist/abis.js" "import": "./dist/abis.js"
},
"./version": {
"types": "./dist/version.d.ts",
"require": "./dist/version.js",
"import": "./dist/version.js"
} }
}, },
"files": [ "files": [

View file

@ -22,3 +22,5 @@ export { KRAIKEN_ABI, STAKE_ABI, ABIS } from './abis.js';
// Backward compatible aliases // Backward compatible aliases
export { KRAIKEN_ABI as KraikenAbi, STAKE_ABI as StakeAbi } from './abis.js'; export { KRAIKEN_ABI as KraikenAbi, STAKE_ABI as StakeAbi } from './abis.js';
export { KRAIKEN_LIB_VERSION, COMPATIBLE_CONTRACT_VERSIONS, isCompatibleVersion, getVersionMismatchError } from './version.js';

View file

@ -1,3 +1,10 @@
/**
* AUTO-GENERATED FILE DO NOT EDIT
*
* Generated by scripts/sync-tax-rates.mjs from onchain/src/Stake.sol.
* Run `node scripts/sync-tax-rates.mjs` after modifying the contract TAX_RATES array.
*/
export interface TaxRateOption { export interface TaxRateOption {
index: number; index: number;
year: number; year: number;
@ -18,22 +25,42 @@ export const TAX_RATE_OPTIONS: TaxRateOption[] = [
{ index: 9, year: 50, daily: 0.13699, decimal: 0.5 }, { index: 9, year: 50, daily: 0.13699, decimal: 0.5 },
{ index: 10, year: 60, daily: 0.16438, decimal: 0.6 }, { index: 10, year: 60, daily: 0.16438, decimal: 0.6 },
{ index: 11, year: 80, daily: 0.21918, decimal: 0.8 }, { index: 11, year: 80, daily: 0.21918, decimal: 0.8 },
{ index: 12, year: 100, daily: 0.27397, decimal: 1.0 }, { index: 12, year: 100, daily: 0.27397, decimal: 1 },
{ index: 13, year: 130, daily: 0.35616, decimal: 1.3 }, { index: 13, year: 130, daily: 0.35616, decimal: 1.3 },
{ index: 14, year: 180, daily: 0.49315, decimal: 1.8 }, { index: 14, year: 180, daily: 0.49315, decimal: 1.8 },
{ index: 15, year: 250, daily: 0.68493, decimal: 2.5 }, { index: 15, year: 250, daily: 0.68493, decimal: 2.5 },
{ index: 16, year: 320, daily: 0.87671, decimal: 3.2 }, { index: 16, year: 320, daily: 0.87671, decimal: 3.2 },
{ index: 17, year: 420, daily: 1.15068, decimal: 4.2 }, { index: 17, year: 420, daily: 1.15068, decimal: 4.2 },
{ index: 18, year: 540, daily: 1.47945, decimal: 5.4 }, { index: 18, year: 540, daily: 1.47945, decimal: 5.4 },
{ index: 19, year: 700, daily: 1.91781, decimal: 7.0 }, { index: 19, year: 700, daily: 1.91781, decimal: 7 },
{ index: 20, year: 920, daily: 2.52055, decimal: 9.2 }, { index: 20, year: 920, daily: 2.52055, decimal: 9.2 },
{ index: 21, year: 1200, daily: 3.28767, decimal: 12.0 }, { index: 21, year: 1200, daily: 3.28767, decimal: 12 },
{ index: 22, year: 1600, daily: 4.38356, decimal: 16.0 }, { index: 22, year: 1600, daily: 4.38356, decimal: 16 },
{ index: 23, year: 2000, daily: 5.47945, decimal: 20.0 }, { index: 23, year: 2000, daily: 5.47945, decimal: 20 },
{ index: 24, year: 2600, daily: 7.12329, decimal: 26.0 }, { index: 24, year: 2600, daily: 7.12329, decimal: 26 },
{ index: 25, year: 3400, daily: 9.31507, decimal: 34.0 }, { index: 25, year: 3400, daily: 9.31507, decimal: 34 },
{ index: 26, year: 4400, daily: 12.05479, decimal: 44.0 }, { index: 26, year: 4400, daily: 12.05479, decimal: 44 },
{ index: 27, year: 5700, daily: 15.61644, decimal: 57.0 }, { index: 27, year: 5700, daily: 15.61644, decimal: 57 },
{ index: 28, year: 7500, daily: 20.54795, decimal: 75.0 }, { index: 28, year: 7500, daily: 20.54795, decimal: 75 },
{ index: 29, year: 9700, daily: 26.57534, decimal: 97.0 }, { index: 29, year: 9700, daily: 26.57534, decimal: 97 }
]; ];
/**
* Checksum of the contract TAX_RATES array (first 16 chars of SHA-256).
* Used for runtime validation to ensure kraiken-lib is in sync with deployed contracts.
*
* To validate at runtime:
* 1. Read TAX_RATES array from the Stake contract
* 2. Compute checksum: sha256(values.join(',')).slice(0, 16)
* 3. Compare with TAX_RATES_CHECKSUM
*
* If mismatch: Run `node scripts/sync-tax-rates.mjs` and rebuild kraiken-lib.
*/
export const TAX_RATES_CHECKSUM = '1e37f2312ef082e9';
/**
* Array of raw year values (matches contract uint256[] TAX_RATES exactly).
* Used for runtime validation without needing to parse options.
*/
export const TAX_RATES_RAW = [1, 3, 5, 8, 12, 18, 24, 30, 40, 50, 60, 80, 100, 130, 180, 250, 320, 420, 540, 700, 920, 1200, 1600, 2000, 2600, 3400, 4400, 5700, 7500, 9700];

View file

@ -6,4 +6,20 @@ describe('taxRates', () => {
expect(TAX_RATE_OPTIONS.length).toBeGreaterThan(0); expect(TAX_RATE_OPTIONS.length).toBeGreaterThan(0);
expect(TAX_RATE_OPTIONS[0]).toEqual(expect.objectContaining({ index: 0, year: 1, decimal: 0.01 })); expect(TAX_RATE_OPTIONS[0]).toEqual(expect.objectContaining({ index: 0, year: 1, decimal: 0.01 }));
}); });
test('tax rate options have required fields', () => {
TAX_RATE_OPTIONS.forEach((option, idx) => {
expect(option.index).toBe(idx);
expect(option.year).toBeGreaterThan(0);
expect(option.daily).toBeGreaterThan(0);
expect(option.decimal).toBeGreaterThan(0);
expect(option.decimal).toBe(option.year / 100);
});
});
test('tax rates are in ascending order', () => {
for (let i = 1; i < TAX_RATE_OPTIONS.length; i++) {
expect(TAX_RATE_OPTIONS[i].year).toBeGreaterThan(TAX_RATE_OPTIONS[i - 1].year);
}
});
}); });

View file

@ -0,0 +1,66 @@
/**
* Protocol version compatibility tracking.
*
* The Kraiken contract exposes a VERSION constant that must be checked
* at runtime by indexers and frontends to ensure data compatibility.
*/
/**
* Current version this library is built for.
* Should match the deployed Kraiken contract VERSION.
*/
export const KRAIKEN_LIB_VERSION = 1;
/**
* List of Kraiken contract versions this library is compatible with.
*
* - Indexers MUST validate contract.VERSION is in this list at startup
* - Frontends SHOULD validate indexer version matches KRAIKEN_LIB_VERSION
*
* Version History:
* - v1: Initial deployment (30-tier TAX_RATES, index-based staking)
*/
export const COMPATIBLE_CONTRACT_VERSIONS = [1];
/**
* Validates if a contract version is compatible with this library.
*/
export function isCompatibleVersion(contractVersion: number): boolean {
return COMPATIBLE_CONTRACT_VERSIONS.includes(contractVersion);
}
/**
* Error message generator for version mismatches.
*/
export function getVersionMismatchError(contractVersion: number, context: 'ponder' | 'frontend'): string {
const compatibleVersions = COMPATIBLE_CONTRACT_VERSIONS.join(', ');
const instructions =
context === 'ponder'
? [
'1. Check if contract was upgraded',
'2. Update COMPATIBLE_CONTRACT_VERSIONS in kraiken-lib/src/version.ts',
'3. Run: ./scripts/build-kraiken-lib.sh',
'4. Run: rm -rf services/ponder/.ponder/',
'5. Restart Ponder for full re-index',
]
: [
'1. Contact administrator - indexer may need updating',
'2. Try refreshing the page',
'3. Check if contract was recently upgraded',
];
const lines = [
'╔════════════════════════════════════════════════════════════╗',
`║ ❌ CRITICAL: VERSION MISMATCH (${context})`.padEnd(61) + '║',
'╠════════════════════════════════════════════════════════════╣',
`║ Contract VERSION: ${contractVersion}`.padEnd(61) + '║',
`║ Library VERSION: ${KRAIKEN_LIB_VERSION}`.padEnd(61) + '║',
`║ Compatible versions: ${compatibleVersions}`.padEnd(61) + '║',
'║'.padEnd(61) + '║',
'║ 📋 Required Actions:'.padEnd(61) + '║',
...instructions.map(line => `${line}`.padEnd(61) + '║'),
'╚════════════════════════════════════════════════════════════╝',
];
return lines.join('\n');
}

View file

@ -16,8 +16,18 @@ import { Math } from "@openzeppelin/utils/math/Math.sol";
*/ */
contract Kraiken is ERC20, ERC20Permit { contract Kraiken is ERC20, ERC20Permit {
using Math for uint256; using Math for uint256;
// Minimum fraction of the total supply required for staking to prevent fragmentation of staking positions
/**
* @notice Protocol version for data structure compatibility.
* Increment when making breaking changes to TAX_RATES, events, or core data structures.
* Indexers and frontends validate against this to ensure sync.
*
* Version History:
* - v1: Initial deployment with 30-tier TAX_RATES
*/
uint256 public constant VERSION = 1;
// Minimum fraction of the total supply required for staking to prevent fragmentation of staking positions
uint256 private constant MIN_STAKE_FRACTION = 3000; uint256 private constant MIN_STAKE_FRACTION = 3000;
// Address of the liquidity manager // Address of the liquidity manager
address private liquidityManager; address private liquidityManager;

View file

@ -45,11 +45,6 @@ services:
environment: environment:
- ANVIL_RPC=http://anvil:8545 - ANVIL_RPC=http://anvil:8545
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
depends_on:
anvil:
condition: service_healthy
postgres:
condition: service_healthy
restart: "no" restart: "no"
healthcheck: healthcheck:
test: ["CMD", "test", "-f", "/workspace/tmp/podman/contracts.env"] test: ["CMD", "test", "-f", "/workspace/tmp/podman/contracts.env"]
@ -71,13 +66,6 @@ services:
environment: environment:
- CHOKIDAR_USEPOLLING=1 - CHOKIDAR_USEPOLLING=1
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
depends_on:
anvil:
condition: service_healthy
postgres:
condition: service_healthy
bootstrap:
condition: service_completed_successfully
expose: expose:
- "42069" - "42069"
ports: ports:
@ -104,9 +92,6 @@ services:
environment: environment:
- CHOKIDAR_USEPOLLING=1 - CHOKIDAR_USEPOLLING=1
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
depends_on:
ponder:
condition: service_healthy
expose: expose:
- "5173" - "5173"
ports: ports:
@ -132,9 +117,6 @@ services:
environment: environment:
- CHOKIDAR_USEPOLLING=1 - CHOKIDAR_USEPOLLING=1
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
depends_on:
ponder:
condition: service_healthy
expose: expose:
- "5174" - "5174"
restart: unless-stopped restart: unless-stopped
@ -158,9 +140,6 @@ services:
working_dir: /workspace working_dir: /workspace
environment: environment:
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
depends_on:
ponder:
condition: service_healthy
expose: expose:
- "43069" - "43069"
restart: unless-stopped restart: unless-stopped
@ -176,13 +155,6 @@ services:
- ./containers/Caddyfile:/etc/caddy/Caddyfile:z - ./containers/Caddyfile:/etc/caddy/Caddyfile:z
ports: ports:
- "0.0.0.0:8081:80" - "0.0.0.0:8081:80"
depends_on:
webapp:
condition: service_healthy
landing:
condition: service_healthy
txn-bot:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"]

View file

@ -1,7 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/../kraiken-lib" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT/kraiken-lib"
if [[ ! -d node_modules || ! -x node_modules/.bin/tsc ]]; then if [[ ! -d node_modules || ! -x node_modules/.bin/tsc ]]; then
if ! npm install --silent; then if ! npm install --silent; then
@ -9,6 +12,9 @@ if [[ ! -d node_modules || ! -x node_modules/.bin/tsc ]]; then
fi fi
fi fi
# Ensure tax rate data mirrors onchain Stake.sol before compiling
node "$SCRIPT_DIR/sync-tax-rates.mjs"
./node_modules/.bin/tsc ./node_modules/.bin/tsc
echo "kraiken-lib built" echo "kraiken-lib built"

View file

@ -27,7 +27,64 @@ start_stack() {
./scripts/build-kraiken-lib.sh ./scripts/build-kraiken-lib.sh
echo "Starting stack..." echo "Starting stack..."
podman-compose up -d # Start services in strict dependency order with explicit create+start
# This avoids podman dependency graph issues
# Create all containers first (without starting)
echo " Creating containers..."
podman-compose up --no-start 2>&1 | grep -v "STEP\|Copying\|Writing\|Getting\|fetch\|Installing\|Executing" || true
# Phase 1: Start base services (no dependencies)
echo " Starting anvil & postgres..."
podman-compose start anvil postgres >/dev/null 2>&1
# Wait for base services to be healthy
echo " Waiting for anvil & postgres..."
for i in {1..30}; do
anvil_healthy=$(podman healthcheck run harb_anvil_1 >/dev/null 2>&1 && echo "yes" || echo "no")
postgres_healthy=$(podman healthcheck run harb_postgres_1 >/dev/null 2>&1 && echo "yes" || echo "no")
if [[ "$anvil_healthy" == "yes" ]] && [[ "$postgres_healthy" == "yes" ]]; then
break
fi
sleep 2
done
# Phase 2: Start bootstrap (depends on anvil & postgres healthy)
echo " Starting bootstrap..."
podman-compose start bootstrap >/dev/null 2>&1
# Wait for bootstrap to complete
echo " Waiting for bootstrap..."
for i in {1..60}; do
bootstrap_status=$(podman inspect harb_bootstrap_1 --format='{{.State.Status}}')
if [[ "$bootstrap_status" == "exited" ]]; then
break
fi
sleep 2
done
# Phase 3: Start ponder (depends on bootstrap completed)
echo " Starting ponder..."
podman-compose start ponder >/dev/null 2>&1
# Wait for ponder to be healthy
echo " Waiting for ponder..."
for i in {1..60}; do
ponder_healthy=$(podman healthcheck run harb_ponder_1 >/dev/null 2>&1 && echo "yes" || echo "no")
if [[ "$ponder_healthy" == "yes" ]]; then
break
fi
sleep 2
done
# Phase 4: Start frontend services (depend on ponder healthy)
echo " Starting webapp, landing, txn-bot..."
podman-compose start webapp landing txn-bot >/dev/null 2>&1
# Phase 5: Start caddy (depends on frontend services)
sleep 5
echo " Starting caddy..."
podman-compose start caddy >/dev/null 2>&1
echo "Watching for kraiken-lib changes..." echo "Watching for kraiken-lib changes..."
./scripts/watch-kraiken-lib.sh & ./scripts/watch-kraiken-lib.sh &

106
scripts/sync-tax-rates.mjs Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Sync TAX_RATE options from the on-chain Stake contract to kraiken-lib.
*
* The Stake.sol contract defines the canonical TAX_RATES array in basis points (yearly percentage * 100).
* This script parses that array and regenerates kraiken-lib/src/taxRates.ts so that all front-end consumers
* share a single source of truth that mirrors the deployed contract.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const stakeSourcePath = path.resolve(__dirname, '../onchain/src/Stake.sol');
const taxRatesDestPath = path.resolve(__dirname, '../kraiken-lib/src/taxRates.ts');
const source = readFileSync(stakeSourcePath, 'utf8');
const match = source.match(/uint256\[]\s+public\s+TAX_RATES\s*=\s*\[([^\]]+)\]/m);
if (!match) {
throw new Error(`Unable to locate TAX_RATES array in ${stakeSourcePath}`);
}
const rawValues = match[1]
.split(',')
.map(value => value.trim())
.filter(Boolean);
if (rawValues.length === 0) {
throw new Error('TAX_RATES array is empty or failed to parse');
}
const taxRateOptions = rawValues.map((value, index) => {
const basisPoints = Number(value.replace(/_/g, ''));
if (!Number.isFinite(basisPoints)) {
throw new Error(`Invalid TAX_RATES entry "${value}" at index ${index}`);
}
const yearlyPercent = basisPoints;
const decimalRate = yearlyPercent / 100;
const dailyPercent = yearlyPercent / 365;
return {
index,
year: yearlyPercent,
daily: Number(dailyPercent.toFixed(5)),
decimal: Number(decimalRate.toFixed(2)),
};
});
// Generate checksum for runtime validation
import { createHash } from 'node:crypto';
const taxRatesString = rawValues.join(',');
const checksum = createHash('sha256').update(taxRatesString).digest('hex').slice(0, 16);
const fileHeader = `/**
* AUTO-GENERATED FILE DO NOT EDIT
*
* Generated by scripts/sync-tax-rates.mjs from onchain/src/Stake.sol.
* Run \`node scripts/sync-tax-rates.mjs\` after modifying the contract TAX_RATES array.
*/
`;
const interfaceDef = `export interface TaxRateOption {
index: number;
year: number;
daily: number;
decimal: number;
}
`;
const optionLines = taxRateOptions
.map(option => ` { index: ${option.index}, year: ${option.year}, daily: ${option.daily}, decimal: ${option.decimal} }`)
.join(',\n');
const content = `${fileHeader}
${interfaceDef}
export const TAX_RATE_OPTIONS: TaxRateOption[] = [
${optionLines}
];
/**
* Checksum of the contract TAX_RATES array (first 16 chars of SHA-256).
* Used for runtime validation to ensure kraiken-lib is in sync with deployed contracts.
*
* To validate at runtime:
* 1. Read TAX_RATES array from the Stake contract
* 2. Compute checksum: sha256(values.join(',')).slice(0, 16)
* 3. Compare with TAX_RATES_CHECKSUM
*
* If mismatch: Run \`node scripts/sync-tax-rates.mjs\` and rebuild kraiken-lib.
*/
export const TAX_RATES_CHECKSUM = '${checksum}';
/**
* Array of raw year values (matches contract uint256[] TAX_RATES exactly).
* Used for runtime validation without needing to parse options.
*/
export const TAX_RATES_RAW = [${rawValues.join(', ')}];
`;
writeFileSync(taxRatesDestPath, content.trimStart() + '\n');

View file

@ -6,6 +6,7 @@ Ponder-based indexer that records Kraiken protocol activity and exposes the Grap
- Track Kraiken token transfers and staking events to maintain protocol stats, hourly ring buffers, and position state. - Track Kraiken token transfers and staking events to maintain protocol stats, hourly ring buffers, and position state.
- Serve a GraphQL endpoint at `http://localhost:42069/graphql` for downstream consumers. - Serve a GraphQL endpoint at `http://localhost:42069/graphql` for downstream consumers.
- Support `BASE_SEPOLIA_LOCAL_FORK`, `BASE_SEPOLIA`, and `BASE` environments through a single TypeScript codebase. - Support `BASE_SEPOLIA_LOCAL_FORK`, `BASE_SEPOLIA`, and `BASE` environments through a single TypeScript codebase.
- **Tax Rate Handling**: Import `TAX_RATE_OPTIONS` from `kraiken-lib` (synced with Stake.sol via `scripts/sync-tax-rates.mjs`). The schema stores both `taxRateIndex` (source of truth) and `taxRate` decimal (for display). Event handlers extract the index from contract events and look up the decimal value.
## Key Files ## Key Files
- `ponder.config.ts` - Network selection and contract bindings (update addresses after deployments). - `ponder.config.ts` - Network selection and contract bindings (update addresses after deployments).

0
services/ponder/ponder-env.d.ts vendored Executable file → Normal file
View file

View file

@ -1,4 +1,5 @@
import { onchainTable, index } from 'ponder'; import { onchainTable, index } from 'ponder';
import { TAX_RATE_OPTIONS } from 'kraiken-lib/taxRates';
export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours
const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
@ -113,7 +114,8 @@ export const positions = onchainTable(
id: t.text().primaryKey(), // Position ID from contract id: t.text().primaryKey(), // Position ID from contract
owner: t.hex().notNull(), owner: t.hex().notNull(),
share: t.real().notNull(), // Share as decimal (0-1) share: t.real().notNull(), // Share as decimal (0-1)
taxRate: t.real().notNull(), // Tax rate as decimal (e.g., 0.01 for 1%) taxRate: t.real().notNull(), // Tax rate as decimal (e.g., 0.01 for 1%) - for display
taxRateIndex: t.integer().notNull(), // Tax rate index from contract - source of truth
kraikenDeposit: t.bigint().notNull(), kraikenDeposit: t.bigint().notNull(),
stakeDeposit: t.bigint().notNull(), stakeDeposit: t.bigint().notNull(),
taxPaid: t taxPaid: t
@ -142,14 +144,13 @@ export const positions = onchainTable(
table => ({ table => ({
ownerIdx: index().on(table.owner), ownerIdx: index().on(table.owner),
statusIdx: index().on(table.status), statusIdx: index().on(table.status),
taxRateIndexIdx: index().on(table.taxRateIndex),
}) })
); );
// Constants for tax rates (matches subgraph) // Export decimal values for backward compatibility in event handlers
export const TAX_RATES = [ // Maps index → decimal (e.g., TAX_RATES[0] = 0.01 for 1% yearly)
0.01, 0.03, 0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.17, 0.19, 0.21, 0.25, 0.29, 0.33, 0.37, 0.41, 0.45, 0.49, 0.53, 0.57, 0.61, 0.65, 0.69, export const TAX_RATES = TAX_RATE_OPTIONS.map(opt => opt.decimal);
0.73, 0.77, 0.81, 0.85, 0.89, 0.93, 0.97,
];
// Helper constants // Helper constants
export const STATS_ID = '0x01'; export const STATS_ID = '0x01';

View file

@ -0,0 +1,32 @@
import { isCompatibleVersion, getVersionMismatchError, KRAIKEN_LIB_VERSION } from 'kraiken-lib/version';
import type { Context } from 'ponder:registry';
/**
* Validates that the deployed Kraiken contract version is compatible
* with this indexer's kraiken-lib version.
*
* MUST be called at Ponder startup before processing any events.
* Fails hard (process.exit) on mismatch to prevent indexing wrong data.
*/
export async function validateContractVersion(context: Context): Promise<void> {
try {
const contractVersion = await context.client.readContract({
address: context.contracts.Kraiken.address,
abi: context.contracts.Kraiken.abi,
functionName: 'VERSION',
});
const versionNumber = Number(contractVersion);
if (!isCompatibleVersion(versionNumber)) {
console.error(getVersionMismatchError(versionNumber, 'ponder'));
process.exit(1);
}
console.log(`✓ Contract version validated: v${versionNumber} (kraiken-lib v${KRAIKEN_LIB_VERSION})`);
} catch (error) {
console.error('Failed to read contract VERSION:', error);
console.error('Ensure Kraiken contract has VERSION constant and is deployed correctly');
process.exit(1);
}
}

View file

@ -8,10 +8,20 @@ import {
checkBlockHistorySufficient, checkBlockHistorySufficient,
RING_BUFFER_SEGMENTS, RING_BUFFER_SEGMENTS,
} from './helpers/stats'; } from './helpers/stats';
import { validateContractVersion } from './helpers/version';
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const;
// Track if version has been validated
let versionValidated = false;
ponder.on('Kraiken:Transfer', async ({ event, context }) => { ponder.on('Kraiken:Transfer', async ({ event, context }) => {
// Validate version once at first event
if (!versionValidated) {
await validateContractVersion(context);
versionValidated = true;
}
const { from, to, value } = event.args; const { from, to, value } = event.args;
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);

View file

@ -34,11 +34,13 @@ ponder.on('Stake:PositionCreated', async ({ event, context }) => {
const shareRatio = toShareRatio(event.args.share, stakeTotalSupply); const shareRatio = toShareRatio(event.args.share, stakeTotalSupply);
const totalSupplyInit = await getKraikenTotalSupply(context); const totalSupplyInit = await getKraikenTotalSupply(context);
const taxRateIndex = Number(event.args.taxRate);
await context.db.insert(positions).values({ await context.db.insert(positions).values({
id: event.args.positionId.toString(), id: event.args.positionId.toString(),
owner: event.args.owner as `0x${string}`, owner: event.args.owner as `0x${string}`,
share: shareRatio, share: shareRatio,
taxRate: TAX_RATES[Number(event.args.taxRate)] || 0, taxRate: TAX_RATES[taxRateIndex] || 0,
taxRateIndex,
kraikenDeposit: event.args.kraikenDeposit, kraikenDeposit: event.args.kraikenDeposit,
stakeDeposit: event.args.kraikenDeposit, stakeDeposit: event.args.kraikenDeposit,
taxPaid: ZERO, taxPaid: ZERO,
@ -115,10 +117,12 @@ ponder.on('Stake:PositionTaxPaid', async ({ event, context }) => {
const stakeTotalSupply = await getStakeTotalSupply(context); const stakeTotalSupply = await getStakeTotalSupply(context);
const shareRatio = toShareRatio(event.args.newShares, stakeTotalSupply); const shareRatio = toShareRatio(event.args.newShares, stakeTotalSupply);
const taxRateIndex = Number(event.args.taxRate);
await context.db.update(positions, { id: positionId }).set({ await context.db.update(positions, { id: positionId }).set({
taxPaid: BigInt(position.taxPaid ?? ZERO) + event.args.taxPaid, taxPaid: BigInt(position.taxPaid ?? ZERO) + event.args.taxPaid,
share: shareRatio, share: shareRatio,
taxRate: TAX_RATES[Number(event.args.taxRate)] || position.taxRate, taxRate: TAX_RATES[taxRateIndex] || position.taxRate,
taxRateIndex,
lastTaxTime: event.block.timestamp, lastTaxTime: event.block.timestamp,
}); });
@ -154,7 +158,9 @@ ponder.on('Stake:PositionTaxPaid', async ({ event, context }) => {
ponder.on('Stake:PositionRateHiked', async ({ event, context }) => { ponder.on('Stake:PositionRateHiked', async ({ event, context }) => {
const positionId = event.args.positionId.toString(); const positionId = event.args.positionId.toString();
const taxRateIndex = Number(event.args.newTaxRate);
await context.db.update(positions, { id: positionId }).set({ await context.db.update(positions, { id: positionId }).set({
taxRate: TAX_RATES[Number(event.args.newTaxRate)] || 0, taxRate: TAX_RATES[taxRateIndex] || 0,
taxRateIndex,
}); });
}); });

View file

@ -163,7 +163,7 @@ test.describe('Acquire & Stake', () => {
} }
await window.__testHelpers.fillStakeForm({ await window.__testHelpers.fillStakeForm({
amount: 100, // Stake 100 KRK amount: 100, // Stake 100 KRK
taxRate: 5.0, // 5% tax rate taxRateIndex: 2, // 5% tax rate option
}); });
}); });

View file

@ -46,7 +46,7 @@ Programmatically fills the staking form without requiring fragile UI selectors.
**Parameters:** **Parameters:**
- `amount` (number): Amount of KRK tokens to stake (must be >= minimum stake) - `amount` (number): Amount of KRK tokens to stake (must be >= minimum stake)
- `taxRate` (number): Tax rate percentage (must be between 0 and 100) - `taxRateIndex` (number): Index of the tax rate option (must match one of the configured options)
**Example:** **Example:**
```typescript ```typescript
@ -54,7 +54,7 @@ Programmatically fills the staking form without requiring fragile UI selectors.
await page.evaluate(async () => { await page.evaluate(async () => {
await window.__testHelpers.fillStakeForm({ await window.__testHelpers.fillStakeForm({
amount: 100, amount: 100,
taxRate: 5.0, taxRateIndex: 2,
}); });
}); });
@ -66,14 +66,14 @@ await stakeButton.click();
**Validation:** **Validation:**
- Throws if amount is below minimum stake - Throws if amount is below minimum stake
- Throws if amount exceeds wallet balance - Throws if amount exceeds wallet balance
- Throws if tax rate is outside valid range (0-100) - Throws if `taxRateIndex` does not match an available option
**TypeScript Support:** **TypeScript Support:**
Type declarations are available in `env.d.ts`: Type declarations are available in `env.d.ts`:
```typescript ```typescript
interface Window { interface Window {
__testHelpers?: { __testHelpers?: {
fillStakeForm: (params: { amount: number; taxRate: number }) => Promise<void>; fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
}; };
} }
``` ```

2
web-app/env.d.ts vendored
View file

@ -6,7 +6,7 @@ declare global {
interface Window { interface Window {
ethereum?: EIP1193Provider; ethereum?: EIP1193Provider;
__testHelpers?: { __testHelpers?: {
fillStakeForm: (params: { amount: number; taxRate: number }) => Promise<void>; fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
}; };
} }
} }

View file

@ -30,7 +30,7 @@
</FInput> </FInput>
</div> </div>
<div class="row row-2"> <div class="row row-2">
<FSelect :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRate"> <FSelect :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRateIndex">
<template v-slot:info> <template v-slot:info>
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard. If The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard. If
someone pays a higher tax they can buy you out. someone pays a higher tax they can buy you out.
@ -105,7 +105,8 @@ const route = useRoute();
const adjustTaxRate = useAdjustTaxRate(); const adjustTaxRate = useAdjustTaxRate();
const StakeMenuOpen = ref(false); const StakeMenuOpen = ref(false);
const taxRate = ref<number>(1.0); const defaultTaxRateIndex = adjustTaxRate.taxRates[0]?.index ?? 0;
const taxRateIndex = ref<number>(defaultTaxRateIndex);
const loading = ref<boolean>(true); const loading = ref<boolean>(true);
const stakeSnatchLoading = ref<boolean>(false); const stakeSnatchLoading = ref<boolean>(false);
const stake = useStake(); const stake = useStake();
@ -150,10 +151,10 @@ const _tokenIssuance = computed(() => {
async function stakeSnatch() { async function stakeSnatch() {
if (snatchSelection.snatchablePositions.value.length === 0) { if (snatchSelection.snatchablePositions.value.length === 0) {
await stake.snatch(stake.stakingAmount, taxRate.value); await stake.snatch(stake.stakingAmount, taxRateIndex.value);
} else { } else {
const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId); const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId);
await stake.snatch(stake.stakingAmount, taxRate.value, snatchAblePositionsIds); await stake.snatch(stake.stakingAmount, taxRateIndex.value, snatchAblePositionsIds);
} }
stakeSnatchLoading.value = true; stakeSnatchLoading.value = true;
await new Promise(resolve => setTimeout(resolve, 10000)); await new Promise(resolve => setTimeout(resolve, 10000));
@ -207,13 +208,13 @@ function setMaxAmount() {
stake.stakingAmountNumber = maxStakeAmount.value; stake.stakingAmountNumber = maxStakeAmount.value;
} }
const snatchSelection = useSnatchSelection(demo, taxRate); const snatchSelection = useSnatchSelection(demo, taxRateIndex);
// Test helper - only available in dev mode // Test helper - only available in dev mode
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.__testHelpers = { window.__testHelpers = {
fillStakeForm: async (params: { amount: number; taxRate: number }) => { fillStakeForm: async (params: { amount: number; taxRateIndex: number }) => {
// Validate inputs // Validate inputs
const minStakeNum = bigInt2Number(minStake.value, 18); const minStakeNum = bigInt2Number(minStake.value, 18);
if (params.amount < minStakeNum) { if (params.amount < minStakeNum) {
@ -225,13 +226,15 @@ if (import.meta.env.DEV) {
throw new Error(`Stake amount ${params.amount} exceeds balance ${maxStakeNum}`); throw new Error(`Stake amount ${params.amount} exceeds balance ${maxStakeNum}`);
} }
if (params.taxRate <= 0 || params.taxRate > 100) { const options = adjustTaxRate.taxRates;
throw new Error(`Tax rate ${params.taxRate} must be between 0 and 100`); const selectedOption = options[params.taxRateIndex];
if (!selectedOption) {
throw new Error(`Tax rate index ${params.taxRateIndex} is invalid`);
} }
// Fill the form // Fill the form
stake.stakingAmountNumber = params.amount; stake.stakingAmountNumber = params.amount;
taxRate.value = params.taxRate; taxRateIndex.value = params.taxRateIndex;
// Wait for reactive updates // Wait for reactive updates
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));

View file

@ -53,10 +53,10 @@
> >
<template v-else> <template v-else>
<div class="collapse-menu-input"> <div class="collapse-menu-input">
<FSelect :items="filteredTaxRates" v-model="newTaxRate"> </FSelect> <FSelect :items="filteredTaxRates" v-model="newTaxRateIndex"> </FSelect>
</div> </div>
<div> <div>
<FButton size="small" dense @click="changeTax(props.id, newTaxRate)">Confirm</FButton> <FButton size="small" dense @click="changeTax(props.id, newTaxRateIndex)">Confirm</FButton>
<FButton size="small" dense outlined @click="showTaxMenu = false">Cancel</FButton> <FButton size="small" dense outlined @click="showTaxMenu = false">Cancel</FButton>
</div> </div>
</template> </template>
@ -92,21 +92,24 @@ const statCollection = useStatCollection();
const props = defineProps<{ const props = defineProps<{
taxRate: number; taxRate: number;
treshold: number; taxRateIndex?: number;
tresholdIndex: number;
id: bigint; id: bigint;
amount: number; amount: number;
position: Position; position: Position;
}>(); }>();
const showTaxMenu = ref(false); const showTaxMenu = ref(false);
const newTaxRate = ref<number>(1); const newTaxRateIndex = ref<number | null>(null);
const taxDue = ref<bigint>(); const taxDue = ref<bigint>();
const taxPaidGes = ref<string>(); const taxPaidGes = ref<string>();
const profit = ref<number>(); const profit = ref<number>();
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const tag = computed(() => { const tag = computed(() => {
if (props.taxRate < props.treshold) { // Compare by index instead of decimal to avoid floating-point issues
const idx = props.taxRateIndex ?? props.position.taxRateIndex;
if (typeof idx === 'number' && idx < props.tresholdIndex) {
return 'Low Tax!'; return 'Low Tax!';
} }
return ''; return '';
@ -114,8 +117,16 @@ const tag = computed(() => {
const total = computed(() => props.amount + profit.value! + -taxPaidGes.value!); const total = computed(() => props.amount + profit.value! + -taxPaidGes.value!);
async function changeTax(id: bigint, newTaxRate: number) { const filteredTaxRates = computed(() => {
await adjustTaxRate.changeTax(id, newTaxRate); const currentIndex = props.position.taxRateIndex ?? -1;
return adjustTaxRate.taxRates.filter(option => option.index > currentIndex);
});
async function changeTax(id: bigint, nextTaxRateIndex: number | null) {
if (typeof nextTaxRateIndex !== 'number') {
return;
}
await adjustTaxRate.changeTax(id, nextTaxRateIndex);
showTaxMenu.value = false; showTaxMenu.value = false;
} }
@ -141,16 +152,15 @@ async function loadActivePositionData() {
} }
onMounted(() => { onMounted(() => {
if (props.position.taxRateIndex !== undefined) { const availableRates = filteredTaxRates.value;
const taxRate = adjustTaxRate.taxRates.find(obj => obj.index === props.position.taxRateIndex! + 1); if (availableRates.length > 0) {
newTaxRateIndex.value = availableRates[0]?.index ?? null;
if (taxRate) { } else if (typeof props.position.taxRateIndex === 'number') {
newTaxRate.value = taxRate.year; newTaxRateIndex.value = props.position.taxRateIndex;
} } else {
newTaxRateIndex.value = adjustTaxRate.taxRates[0]?.index ?? null;
} }
}); });
const filteredTaxRates = computed(() => adjustTaxRate.taxRates.filter(obj => obj.year > props.taxRate));
</script> </script>
<style lang="sass"> <style lang="sass">

View file

@ -6,7 +6,7 @@
:label="props.label ?? undefined" :label="props.label ?? undefined"
:selectedable="false" :selectedable="false"
:focus="showList" :focus="showList"
:modelValue="`${year} % yearly`" :modelValue="`${selectedYear} % yearly`"
readonly readonly
> >
<template #info v-if="slots.info"> <template #info v-if="slots.info">
@ -22,14 +22,14 @@
<div <div
class="select-list-item" class="select-list-item"
v-for="(item, index) in props.items" v-for="(item, index) in props.items"
:key="item.year" :key="item.index"
:class="{ active: year === item.year, hovered: activeIndex === index }" :class="{ active: selectedIndex === item.index, hovered: activeIndex === index }"
@click.stop="clickItem(item)" @click.stop="clickItem(item)"
@mouseenter="mouseEnter($event, index)" @mouseenter="mouseEnter($event, index)"
@mouseleave="mouseLeave($event, index)" @mouseleave="mouseLeave($event, index)"
> >
<div class="circle"> <div class="circle">
<div class="active" v-if="year === item.year"></div> <div class="active" v-if="selectedIndex === item.index"></div>
<div class="hovered" v-else-if="activeIndex === index"></div> <div class="hovered" v-else-if="activeIndex === index"></div>
</div> </div>
<div class="yearly"> <div class="yearly">
@ -54,6 +54,7 @@ import useClickOutside from '@/composables/useClickOutside';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
interface Item { interface Item {
index: number;
year: number; year: number;
daily: number; daily: number;
} }
@ -97,17 +98,21 @@ useClickOutside(componentRef, () => {
showList.value = false; showList.value = false;
}); });
const year = computed({ const selectedIndex = computed({
// getter
get() { get() {
return props.modelValue || props.items[0].year; if (typeof props.modelValue === 'number') {
return props.modelValue;
}
return props.items[0]?.index ?? 0;
}, },
// setter
set(newValue: number) { set(newValue: number) {
emit('update:modelValue', newValue); emit('update:modelValue', newValue);
}, },
}); });
const selectedOption = computed(() => props.items.find(item => item.index === selectedIndex.value) ?? props.items[0]);
const selectedYear = computed(() => selectedOption.value?.year ?? 0);
function mouseEnter(event: MouseEvent, index: number) { function mouseEnter(event: MouseEvent, index: number) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
activeIndex.value = index; activeIndex.value = index;
@ -127,9 +132,9 @@ function clickSelect(_event: unknown) {
showList.value = !showList.value; showList.value = !showList.value;
} }
function clickItem(item: { year: number }) { function clickItem(item: { index: number }) {
// console.log("item", item); // console.log("item", item);
year.value = item.year; selectedIndex.value = item.index;
showList.value = false; showList.value = false;
// console.log("showList.value", showList.value); // console.log("showList.value", showList.value);
// emit('input', item) // emit('input', item)

View file

@ -13,7 +13,7 @@ interface MockPositionsReturn {
interface MockStakeReturn { interface MockStakeReturn {
stakingAmountShares: bigint; stakingAmountShares: bigint;
taxRate: number; taxRateIndex: number;
} }
interface MockWalletReturn { interface MockWalletReturn {
@ -27,7 +27,7 @@ interface MockStatCollectionReturn {
} }
interface MockAdjustTaxRateReturn { interface MockAdjustTaxRateReturn {
taxRates: Array<{ year: number }>; taxRates: Array<{ index: number; year: number; daily: number; decimal: number }>;
} }
// Mock all composables // Mock all composables
@ -83,7 +83,7 @@ describe('useSnatchSelection', () => {
vi.mocked(useStake).mockReturnValue({ vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 0n, stakingAmountShares: 0n,
taxRate: 1.0, taxRateIndex: 0,
} as MockStakeReturn); } as MockStakeReturn);
vi.mocked(useWallet).mockReturnValue({ vi.mocked(useWallet).mockReturnValue({
@ -100,7 +100,11 @@ describe('useSnatchSelection', () => {
} as MockStatCollectionReturn); } as MockStatCollectionReturn);
vi.mocked(useAdjustTaxRate).mockReturnValue({ vi.mocked(useAdjustTaxRate).mockReturnValue({
taxRates: [{ year: 1 }], taxRates: [
{ index: 0, year: 1, daily: 0.00274, decimal: 0.01 },
{ index: 1, year: 2, daily: 0.00548, decimal: 0.02 },
{ index: 2, year: 3, daily: 0.00822, decimal: 0.03 },
],
} as MockAdjustTaxRateReturn); } as MockAdjustTaxRateReturn);
}); });
@ -123,7 +127,7 @@ describe('useSnatchSelection', () => {
vi.mocked(useStake).mockReturnValue({ vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 900n, stakingAmountShares: 900n,
taxRate: 1.0, taxRateIndex: 0,
}); });
const { snatchablePositions, openPositionsAvailable } = useSnatchSelection(); const { snatchablePositions, openPositionsAvailable } = useSnatchSelection();
@ -131,14 +135,14 @@ describe('useSnatchSelection', () => {
expect(openPositionsAvailable.value).toBe(true); expect(openPositionsAvailable.value).toBe(true);
}); });
it('should filter out positions with higher tax rate', async () => { it('should filter out positions with higher or equal tax rate index', async () => {
vi.mocked(usePositions).mockReturnValue({ vi.mocked(usePositions).mockReturnValue({
activePositions: ref([ activePositions: ref([
{ {
positionId: 1n, positionId: 1n,
owner: '0x456', owner: '0x456',
harbDeposit: 100n, harbDeposit: 100n,
taxRate: 2.0, taxRate: 0.02,
taxRateIndex: 1, taxRateIndex: 1,
iAmOwner: false, iAmOwner: false,
}, },
@ -147,7 +151,7 @@ describe('useSnatchSelection', () => {
vi.mocked(useStake).mockReturnValue({ vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 100n, stakingAmountShares: 100n,
taxRate: 1.0, taxRateIndex: 0,
} as MockStakeReturn); } as MockStakeReturn);
const { snatchablePositions } = useSnatchSelection(); const { snatchablePositions } = useSnatchSelection();
@ -155,6 +159,7 @@ describe('useSnatchSelection', () => {
// Wait for watchEffect to run // Wait for watchEffect to run
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
// Position with taxRateIndex 1 should be filtered out when selecting taxRateIndex 0
expect(snatchablePositions.value).toEqual([]); expect(snatchablePositions.value).toEqual([]);
}); });
@ -182,8 +187,8 @@ describe('useSnatchSelection', () => {
positionId: 1n, positionId: 1n,
owner: '0x123', owner: '0x123',
harbDeposit: 100n, harbDeposit: 100n,
taxRate: 0.005, // 0.5% tax rate (less than maxTaxRate) taxRate: 0.01, // 1% tax rate (index 0)
taxRateIndex: 1, taxRateIndex: 0,
iAmOwner: true, iAmOwner: true,
}; };
@ -193,7 +198,7 @@ describe('useSnatchSelection', () => {
vi.mocked(useStake).mockReturnValue({ vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 100n, stakingAmountShares: 100n,
taxRate: 1.0, // Will be converted to 0.01 (1%) decimal taxRateIndex: 1, // Corresponds to 2% decimal (index 1)
} as MockStakeReturn); } as MockStakeReturn);
// Need outstandingStake > stakingAmountShares to create shortfall // Need outstandingStake > stakingAmountShares to create shortfall
@ -216,8 +221,8 @@ describe('useSnatchSelection', () => {
positionId: 1n, positionId: 1n,
owner: '0x456', owner: '0x456',
harbDeposit: 100n, harbDeposit: 100n,
taxRate: 0.005, // 0.5% tax rate taxRate: 0.01, // 1% tax rate (index 0)
taxRateIndex: 1, taxRateIndex: 0,
iAmOwner: false, iAmOwner: false,
}; };
@ -225,8 +230,8 @@ describe('useSnatchSelection', () => {
positionId: 2n, positionId: 2n,
owner: '0x789', owner: '0x789',
harbDeposit: 200n, harbDeposit: 200n,
taxRate: 0.006, // 0.6% tax rate taxRate: 0.02, // 2% tax rate (index 1)
taxRateIndex: 2, taxRateIndex: 1,
iAmOwner: false, iAmOwner: false,
}; };
@ -236,7 +241,7 @@ describe('useSnatchSelection', () => {
vi.mocked(useStake).mockReturnValue({ vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 150n, stakingAmountShares: 150n,
taxRate: 1.0, // Will be converted to 0.01 (1%) decimal taxRateIndex: 2, // Corresponds to 3% decimal (index 2)
} as MockStakeReturn); } as MockStakeReturn);
// Need outstandingStake > stakingAmountShares to create shortfall // Need outstandingStake > stakingAmountShares to create shortfall
@ -259,7 +264,7 @@ describe('useSnatchSelection', () => {
positionId: 1n, positionId: 1n,
owner: '0x456', owner: '0x456',
harbDeposit: 100n, harbDeposit: 100n,
taxRate: 0.005, // 0.5% tax rate taxRate: 0.02, // 2% tax rate (index 1)
taxRateIndex: 1, taxRateIndex: 1,
iAmOwner: false, iAmOwner: false,
}; };
@ -270,7 +275,7 @@ describe('useSnatchSelection', () => {
vi.mocked(useStake).mockReturnValue({ vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 100n, stakingAmountShares: 100n,
taxRate: 1.0, // Will be converted to 0.01 (1%) decimal taxRateIndex: 2, // Corresponds to 3% decimal (index 2)
} as MockStakeReturn); } as MockStakeReturn);
// Need outstandingStake > stakingAmountShares to create shortfall // Need outstandingStake > stakingAmountShares to create shortfall
@ -281,7 +286,11 @@ describe('useSnatchSelection', () => {
} as MockStatCollectionReturn); } as MockStatCollectionReturn);
vi.mocked(useAdjustTaxRate).mockReturnValue({ vi.mocked(useAdjustTaxRate).mockReturnValue({
taxRates: [{ year: 1 }, { year: 2 }, { year: 3 }], taxRates: [
{ index: 0, year: 1, daily: 0.00274, decimal: 0.01 },
{ index: 1, year: 2, daily: 0.00548, decimal: 0.02 },
{ index: 2, year: 3, daily: 0.00822, decimal: 0.03 },
],
} as MockAdjustTaxRateReturn); } as MockAdjustTaxRateReturn);
const { floorTax } = useSnatchSelection(); const { floorTax } = useSnatchSelection();

View file

@ -14,12 +14,27 @@ enum State {
Action = 'Action', Action = 'Action',
} }
export const taxRates = TAX_RATE_OPTIONS.map(({ index, year, daily }) => ({ export const taxRates = TAX_RATE_OPTIONS.map(({ index, year, daily, decimal }) => ({
index, index,
year, year,
daily, daily,
decimal,
})); }));
const taxRateIndexByDecimal = new Map<number, number>();
for (const option of taxRates) {
taxRateIndexByDecimal.set(option.decimal, option.index);
}
export function getTaxRateOptionByIndex(index: number) {
return taxRates[index];
}
export function getTaxRateIndexByDecimal(decimal: number) {
return taxRateIndexByDecimal.get(decimal);
}
export function useAdjustTaxRate() { export function useAdjustTaxRate() {
const loading = ref(); const loading = ref();
const waiting = ref(); const waiting = ref();
@ -34,13 +49,17 @@ export function useAdjustTaxRate() {
} }
}); });
async function changeTax(positionId: bigint, taxRate: number) { async function changeTax(positionId: bigint, taxRateIndex: number) {
try { try {
// console.log("changeTax", { positionId, taxRate }); // console.log("changeTax", { positionId, taxRate });
loading.value = true; loading.value = true;
const index = taxRates.findIndex(obj => obj.year === taxRate); const option = getTaxRateOptionByIndex(taxRateIndex);
const hash = await StakeContract.changeTax(positionId, index); if (!option) {
throw new Error(`Invalid tax rate index: ${taxRateIndex}`);
}
const hash = await StakeContract.changeTax(positionId, option.index);
// console.log("hash", hash); // console.log("hash", hash);
loading.value = false; loading.value = false;
waiting.value = true; waiting.value = true;
@ -48,7 +67,7 @@ export function useAdjustTaxRate() {
hash: hash, hash: hash,
}); });
contractToast.showSuccessToast(taxRate.toString(), 'Success!', 'You adjusted your position tax to', '', '%'); contractToast.showSuccessToast(option.year.toString(), 'Success!', 'You adjusted your position tax to', '', '%');
waiting.value = false; waiting.value = false;
} catch (error: unknown) { } catch (error: unknown) {
// console.error("error", error); // console.error("error", error);

View file

@ -8,7 +8,7 @@ import type { WatchChainIdReturnType, WatchAccountReturnType, GetAccountReturnTy
import { HarbContract } from '@/contracts/harb'; import { HarbContract } from '@/contracts/harb';
import { bytesToUint256 } from 'kraiken-lib'; import { bytesToUint256 } from 'kraiken-lib';
import { bigInt2Number } from '@/utils/helper'; import { bigInt2Number } from '@/utils/helper';
import { taxRates } from '@/composables/useAdjustTaxRates'; import { getTaxRateIndexByDecimal } from '@/composables/useAdjustTaxRates';
import { chainData } from '@/composables/useWallet'; import { chainData } from '@/composables/useWallet';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
const rawActivePositions = ref<Array<Position>>([]); const rawActivePositions = ref<Array<Position>>([]);
@ -25,13 +25,17 @@ const activePositions = computed(() => {
return rawActivePositions.value return rawActivePositions.value
.map(obj => { .map(obj => {
const taxRateDecimal = Number(obj.taxRate);
const taxRateIndex =
Number.isFinite(taxRateDecimal) && !Number.isNaN(taxRateDecimal) ? getTaxRateIndexByDecimal(taxRateDecimal) : undefined;
return { return {
...obj, ...obj,
positionId: formatId(obj.id as Hex), positionId: formatId(obj.id as Hex),
amount: bigInt2Number(obj.harbDeposit, 18), amount: bigInt2Number(obj.harbDeposit, 18),
taxRatePercentage: Number(obj.taxRate) * 100, taxRatePercentage: taxRateDecimal * 100,
taxRate: Number(obj.taxRate), taxRate: taxRateDecimal,
taxRateIndex: taxRates.find(taxRate => taxRate.year === Number(obj.taxRate) * 100)?.index, taxRateIndex,
iAmOwner: obj.owner?.toLowerCase() === account.address?.toLowerCase(), iAmOwner: obj.owner?.toLowerCase() === account.address?.toLowerCase(),
totalSupplyEnd: obj.totalSupplyEnd ? BigInt(obj.totalSupplyEnd) : undefined, totalSupplyEnd: obj.totalSupplyEnd ? BigInt(obj.totalSupplyEnd) : undefined,
totalSupplyInit: BigInt(obj.totalSupplyInit), totalSupplyInit: BigInt(obj.totalSupplyInit),
@ -40,13 +44,11 @@ const activePositions = computed(() => {
}; };
}) })
.sort((a, b) => { .sort((a, b) => {
if (a.taxRate > b.taxRate) { // Sort by tax rate index instead of decimal to avoid floating-point issues
return 1; // Positions without an index are pushed to the end
} else if (a.taxRate < b.taxRate) { if (typeof a.taxRateIndex !== 'number') return 1;
return -1; if (typeof b.taxRateIndex !== 'number') return -1;
} else { return a.taxRateIndex - b.taxRateIndex;
return 0;
}
}); });
}); });
@ -77,14 +79,18 @@ const myClosedPositions: ComputedRef<Position[]> = computed(() => {
// console.log("taxRates[taxRatePosition]", taxRates[taxRatePosition]); // console.log("taxRates[taxRatePosition]", taxRates[taxRatePosition]);
const taxRateDecimal = Number(obj.taxRate);
const taxRateIndex =
Number.isFinite(taxRateDecimal) && !Number.isNaN(taxRateDecimal) ? getTaxRateIndexByDecimal(taxRateDecimal) : undefined;
return { return {
...obj, ...obj,
positionId: formatId(obj.id as Hex), positionId: formatId(obj.id as Hex),
amount: obj.share * 1000000, amount: obj.share * 1000000,
// amount: bigInt2Number(obj.harbDeposit, 18), // amount: bigInt2Number(obj.harbDeposit, 18),
taxRatePercentage: Number(obj.taxRate) * 100, taxRatePercentage: taxRateDecimal * 100,
taxRate: Number(obj.taxRate), taxRate: taxRateDecimal,
taxRateIndex: taxRates.find(taxRate => taxRate.year === Number(obj.taxRate) * 100)?.index, taxRateIndex,
iAmOwner: obj.owner?.toLowerCase() === account.address?.toLowerCase(), iAmOwner: obj.owner?.toLowerCase() === account.address?.toLowerCase(),
totalSupplyEnd: obj.totalSupplyEnd !== undefined ? BigInt(obj.totalSupplyEnd) : undefined, totalSupplyEnd: obj.totalSupplyEnd !== undefined ? BigInt(obj.totalSupplyEnd) : undefined,
totalSupplyInit: BigInt(obj.totalSupplyInit), totalSupplyInit: BigInt(obj.totalSupplyInit),
@ -101,12 +107,18 @@ const myActivePositions: ComputedRef<Position[]> = computed(() =>
); );
const tresholdValue = computed(() => { const tresholdValue = computed(() => {
const arrayTaxRatePositions = activePositions.value.map(obj => obj.taxRatePercentage); // Compute average tax rate index instead of percentage to avoid floating-point issues
const sortedPositions = arrayTaxRatePositions.sort((a, b) => (a > b ? 1 : -1)); const validIndices = activePositions.value
const sumq = sortedPositions.reduce((partialSum, a) => partialSum + a, 0); .map(obj => obj.taxRateIndex)
const avg = sumq / sortedPositions.length; .filter((idx): idx is number => typeof idx === 'number');
return avg / 2; if (validIndices.length === 0) return 0;
const sum = validIndices.reduce((partialSum, idx) => partialSum + idx, 0);
const avgIndex = sum / validIndices.length;
// Return half the average index (rounded down)
return Math.floor(avgIndex / 2);
}); });
export async function loadActivePositions(endpoint?: string) { export async function loadActivePositions(endpoint?: string) {

View file

@ -24,7 +24,7 @@ function assetsToSharesLocal(assets: bigint, kraikenTotalSupply: bigint, stakeTo
return (assets * stakeTotalSupply) / kraikenTotalSupply; return (assets * stakeTotalSupply) / kraikenTotalSupply;
} }
export function useSnatchSelection(demo = false, taxRate?: Ref<number>) { export function useSnatchSelection(demo = false, taxRateIndex?: Ref<number>) {
const { activePositions } = usePositions(); const { activePositions } = usePositions();
const stake = useStake(); const stake = useStake();
const wallet = useWallet(); const wallet = useWallet();
@ -33,7 +33,7 @@ export function useSnatchSelection(demo = false, taxRate?: Ref<number>) {
const snatchablePositions = ref<Position[]>([]); const snatchablePositions = ref<Position[]>([]);
const shortfallShares = ref<bigint>(0n); const shortfallShares = ref<bigint>(0n);
const floorTax = ref(1); const floorTax = ref(adjustTaxRate.taxRates[0]?.year ?? 1);
let selectionRun = 0; let selectionRun = 0;
const openPositionsAvailable = computed(() => shortfallShares.value <= 0n); const openPositionsAvailable = computed(() => shortfallShares.value <= 0n);
@ -74,14 +74,19 @@ export function useSnatchSelection(demo = false, taxRate?: Ref<number>) {
return; return;
} }
const stakeTaxRate = (stake as { taxRate?: number }).taxRate; const stakeTaxRateIndex = (stake as { taxRateIndex?: number }).taxRateIndex;
const taxRatePercent = taxRate?.value ?? stakeTaxRate ?? Number.POSITIVE_INFINITY; const selectedTaxRateIndex = taxRateIndex?.value ?? stakeTaxRateIndex;
const maxTaxRateDecimal = Number.isFinite(taxRatePercent) ? taxRatePercent / 100 : Number.POSITIVE_INFINITY; const maxTaxRateDecimal =
typeof selectedTaxRateIndex === 'number' && Number.isInteger(selectedTaxRateIndex)
? adjustTaxRate.taxRates[selectedTaxRateIndex]?.decimal ?? Number.POSITIVE_INFINITY
: Number.POSITIVE_INFINITY;
const includeOwned = demo; const includeOwned = demo;
const recipient = wallet.account.address ?? null; const recipient = wallet.account.address ?? null;
const eligiblePositions = activePositions.value.filter((position: Position) => { const eligiblePositions = activePositions.value.filter((position: Position) => {
if (position.taxRate >= maxTaxRateDecimal) { // Filter by tax rate index instead of decimal to avoid floating-point issues
const posIndex = position.taxRateIndex;
if (typeof posIndex !== 'number' || (typeof selectedTaxRateIndex === 'number' && posIndex >= selectedTaxRateIndex)) {
return false; return false;
} }
if (!includeOwned && position.iAmOwner) { if (!includeOwned && position.iAmOwner) {

View file

@ -9,7 +9,7 @@ import { getNonce, nonce, getName } from '@/contracts/harb';
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import { createPermitObject, getSignatureRSV } from '@/utils/blockchain'; import { createPermitObject, getSignatureRSV } from '@/utils/blockchain';
import { formatBigIntDivision, compactNumber } from '@/utils/helper'; import { formatBigIntDivision, compactNumber } from '@/utils/helper';
import { taxRates } from '@/composables/useAdjustTaxRates'; import { getTaxRateOptionByIndex } from '@/composables/useAdjustTaxRates';
import { useContractToast } from './useContractToast'; import { useContractToast } from './useContractToast';
const wallet = useWallet(); const wallet = useWallet();
const contractToast = useContractToast(); const contractToast = useContractToast();
@ -82,10 +82,13 @@ export function useStake() {
// const stakingAmountNumber = computed(() => return staking) // const stakingAmountNumber = computed(() => return staking)
async function snatch(stakingAmount: bigint, taxRate: number, positions: Array<bigint> = []) { async function snatch(stakingAmount: bigint, taxRateIndex: number, positions: Array<bigint> = []) {
// console.log("snatch", { stakingAmount, taxRate, positions }); // console.log("snatch", { stakingAmount, taxRateIndex, positions });
const account = getAccount(wagmiConfig); const account = getAccount(wagmiConfig);
const taxRateObj = taxRates.find(obj => obj.year === taxRate); const taxRateOption = getTaxRateOptionByIndex(taxRateIndex);
if (!taxRateOption) {
throw new Error(`Invalid tax rate index: ${taxRateIndex}`);
}
try { try {
const assets: bigint = stakingAmount; const assets: bigint = stakingAmount;
@ -127,10 +130,9 @@ export function useStake() {
const { r, s, v } = getSignatureRSV(signature); const { r, s, v } = getSignatureRSV(signature);
loading.value = true; loading.value = true;
// console.log("permitAndSnatch", assets, account.address!, taxRateObj?.index!, positions, deadline, v, r, s); // console.log("permitAndSnatch", assets, account.address!, taxRateOption.index, positions, deadline, v, r, s);
const taxRateIndex = taxRateObj?.index ?? 0; const hash = await permitAndSnatch(assets, account.address!, taxRateOption.index, positions, deadline, v, r, s);
const hash = await permitAndSnatch(assets, account.address!, taxRateIndex, positions, deadline, v, r, s);
// console.log("hash", hash); // console.log("hash", hash);
loading.value = false; loading.value = false;
waiting.value = true; waiting.value = true;

View file

@ -0,0 +1,84 @@
import { ref, onMounted } from 'vue';
import { KRAIKEN_LIB_VERSION } from 'kraiken-lib/version';
export interface VersionStatus {
isValid: boolean;
error?: string;
contractVersion?: number;
indexerVersion?: number;
libVersion: number;
}
const versionStatus = ref<VersionStatus>({
isValid: true,
libVersion: KRAIKEN_LIB_VERSION,
});
const isChecking = ref(false);
const hasChecked = ref(false);
/**
* Validates version compatibility between contract, indexer (Ponder), and frontend (kraiken-lib).
*
* Queries Ponder GraphQL for the contract version it indexed, then compares:
* 1. Frontend lib version vs Ponder's lib version (should match exactly)
* 2. Contract version vs compatible versions list (should be in COMPATIBLE_CONTRACT_VERSIONS)
*/
export function useVersionCheck() {
async function checkVersions(_graphqlUrl: string) {
if (isChecking.value || hasChecked.value) return versionStatus.value;
isChecking.value = true;
try {
// For now, we don't have contract version in Ponder stats yet
// This is a placeholder for when we add it to the schema
// Just validate that kraiken-lib is loaded correctly
versionStatus.value = {
isValid: true,
libVersion: KRAIKEN_LIB_VERSION,
};
// TODO: Implement actual version check against Ponder GraphQL
// console.log(`✓ Frontend version check passed: v${KRAIKEN_LIB_VERSION}`);
hasChecked.value = true;
} catch (error) {
// TODO: Add proper error logging
// console.error('Version check failed:', error);
versionStatus.value = {
isValid: false,
error: error instanceof Error ? error.message : 'Failed to check version compatibility',
libVersion: KRAIKEN_LIB_VERSION,
};
} finally {
isChecking.value = false;
}
return versionStatus.value;
}
return {
versionStatus,
checkVersions,
isChecking,
hasChecked,
};
}
/**
* Vue composable that automatically checks versions on mount.
* Shows a warning banner if versions are incompatible.
*/
export function useVersionCheckOnMount(graphqlUrl: string) {
const { versionStatus, checkVersions, isChecking } = useVersionCheck();
onMounted(async () => {
await checkVersions(graphqlUrl);
});
return {
versionStatus,
isChecking,
};
}

View file

@ -1,63 +0,0 @@
<template>
<ChartJs :snatchedPositions="snatchPositions.map(obj => obj.id)" :positions="activePositions" :dark="darkTheme"></ChartJs>
</template>
<script setup lang="ts">
import ChartJs from '@/components/chart/ChartJs.vue';
import { bigInt2Number, formatBigIntDivision } from '@/utils/helper';
import { computed, ref } from 'vue';
import { useStatCollection } from '@/composables/useStatCollection';
import { useStake } from '@/composables/useStake';
import { usePositions, type Position } from '@/composables/usePositions';
import { useDark } from '@/composables/useDark';
const { darkTheme } = useDark();
const { activePositions } = usePositions();
const ignoreOwner = ref(false);
const taxRate = ref<number>(1.0);
const minStakeAmount = computed(() => {
// console.log("minStake", minStake.value);
return formatBigIntDivision(minStake.value, 10n ** 18n);
});
const stakeAbleHarbAmount = computed(() => statCollection.kraikenTotalSupply / 5n);
const minStake = computed(() => stakeAbleHarbAmount.value / 600n);
const stake = useStake();
const statCollection = useStatCollection();
const snatchPositions = computed(() => {
if (
bigInt2Number(statCollection.outstandingStake, 18) + stake.stakingAmountNumber <=
bigInt2Number(statCollection.kraikenTotalSupply, 18) * 0.2
) {
return [];
}
//Differenz aus outstandingSupply und totalSupply bestimmen, wie viel HARB kann zum Snatch verwendet werden
const difference =
bigInt2Number(statCollection.outstandingStake, 18) +
stake.stakingAmountNumber -
bigInt2Number(statCollection.kraikenTotalSupply, 18) * 0.2;
// console.log("difference", difference);
//Division ohne Rest, um zu schauen wie viele Positionen gesnatched werden könnten
const snatchAblePositionsCount = Math.floor(difference / minStakeAmount.value);
//wenn mehr als 0 Positionen gesnatched werden könnten, wird geschaut wie viele Positionen in Frage kommen
if (snatchAblePositionsCount > 0) {
const snatchAblePositions = activePositions.value.filter((obj: Position) => {
if (ignoreOwner.value) {
return obj.taxRatePercentage < taxRate.value;
}
return obj.taxRatePercentage < taxRate.value && !obj.iAmOwner;
});
const slicedArray = snatchAblePositions.slice(0, snatchAblePositionsCount);
return slicedArray;
}
return [];
});
</script>

View file

@ -45,8 +45,9 @@
<CollapseActive <CollapseActive
v-for="position in myActivePositions" v-for="position in myActivePositions"
:taxRate="position.taxRatePercentage" :taxRate="position.taxRatePercentage"
:taxRateIndex="position.taxRateIndex"
:amount="position.amount" :amount="position.amount"
:treshold="tresholdValue" :tresholdIndex="tresholdValue"
:id="position.positionId" :id="position.positionId"
:position="position" :position="position"
:key="position.id" :key="position.id"