diff --git a/AGENTS.md b/AGENTS.md index 19daf88a..757bb746 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,7 @@ This app uses Vercel Web Analytics. Two things must stay in place: | `trackExplorerActiveBlockJump(chain, jump)` | `app/internal-explorer/components/ActiveBlockButton.tsx` — zeronet latest/previous active block | | `trackValidityOrder(side, status)` | `app/vibenet/demos/validity/ValidityDemo.tsx` — conditional swap submit / include / expiry / replace | | `trackValidityRace(attempt, status)` | `app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx` — validity/manual comparison attempts | + | `trackValidityLiquidation(attempt, status)` | `app/vibenet/demos/validity/liquidations/LiquidationsDemo.tsx` — validity/manual liquidation attempts | Add a helper (and a row here) for a new key journey; remove the helper if you remove its surface. Confirm the wiring with `grep -rn "analytics/events" app`. diff --git a/app/analytics/events.ts b/app/analytics/events.ts index b65d81da..431c1421 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -91,3 +91,10 @@ export function trackValidityRace( ): void { track('validity_race', { attempt, status }); } + +export function trackValidityLiquidation( + attempt: 'validity' | 'manual', + status: 'submitted' | 'success' | 'reverted' | 'expired' | 'beaten' | 'error', +): void { + track('validity_liquidation', { attempt, status }); +} diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index a0ac9bae..f58e43fe 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -3,11 +3,12 @@ import { describe, expect, it } from 'vitest'; import sitemap from './sitemap'; describe('sitemap', () => { - it('indexes the Validity Transactions group and both nested demos', () => { + it('indexes the Validity Transactions group and every nested demo', () => { const urls = sitemap().map((entry) => entry.url); expect(urls).toContain('https://chain.base.org/vibenet/demos/validity'); expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/conditional-swaps'); expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/race-the-agent'); + expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/liquidations'); }); }); diff --git a/app/sitemap.ts b/app/sitemap.ts index 090ae7db..a7a18794 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -26,6 +26,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: '/vibenet/demos/validity', priority: 0.5, changeFrequency: 'weekly' }, { path: '/vibenet/demos/validity/conditional-swaps', priority: 0.5, changeFrequency: 'weekly' }, { path: '/vibenet/demos/validity/race-the-agent', priority: 0.5, changeFrequency: 'weekly' }, + { path: '/vibenet/demos/validity/liquidations', priority: 0.5, changeFrequency: 'weekly' }, ]; return routes.map(({ path, priority, changeFrequency }) => ({ diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 7559df68..f4f79b97 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -98,6 +98,18 @@ export const DEMOS: DemoEntry[] = [ ], available: true, }, + { + href: '/vibenet/demos/validity/liquidations', + title: 'Liquidations', + summary: + 'Liquidate loans on a demo lending market before a rival keeper, with a validity transaction that waits for the price to cross.', + points: [ + 'Bound a pool reserve so the transaction lands only once a loan is under water', + 'Pin the position in storage so a lost race is dropped, not reverted', + 'Compare inclusion blocks against a conventional keeper bot', + ], + available: true, + }, ], }, { diff --git a/app/vibenet/demos/validity/components/CodeSnippet.test.ts b/app/vibenet/demos/validity/components/CodeSnippet.test.ts new file mode 100644 index 00000000..b9502ee1 --- /dev/null +++ b/app/vibenet/demos/validity/components/CodeSnippet.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { tokenizeCode } from './CodeSnippet'; + +const kinds = (source: string, types?: string[]) => + tokenizeCode(source, 'solidity', types).filter((t) => t.kind !== 'plain').map((t) => [t.text, t.kind]); + +describe('tokenizeCode', () => { + it('round-trips the source text', () => { + const source = 'function liquidate(address borrower) external { // go\n delete positions[borrower];\n}'; + expect(tokenizeCode(source, 'solidity').map((t) => t.text).join('')).toBe(source); + }); + + it('colours caller-supplied types but not identifiers that merely contain them', () => { + expect(kinds('Position memory position = Positions[x];', ['Position'])).toEqual([ + ['Position', 'type'], + ['memory', 'keyword'], + ]); + }); + + it('keeps keywords inside comments and strings as part of them', () => { + expect(kinds('require(x, "no position"); // return 10_000')).toEqual([ + ['require', 'keyword'], + ['"no position"', 'string'], + ['// return 10_000', 'comment'], + ]); + }); +}); diff --git a/app/vibenet/demos/validity/components/CodeSnippet.tsx b/app/vibenet/demos/validity/components/CodeSnippet.tsx new file mode 100644 index 00000000..1b20dfa4 --- /dev/null +++ b/app/vibenet/demos/validity/components/CodeSnippet.tsx @@ -0,0 +1,93 @@ +import { Card } from '../../../../components/ui/Card'; +import { cn } from '../../../../components/ui/cn'; +import { Text } from '../../../../components/ui/Text'; + +export type CodeLanguage = 'json' | 'solidity'; +type CodeToken = { text: string; kind: 'comment' | 'key' | 'keyword' | 'literal' | 'number' | 'plain' | 'string' | 'type' }; + +const CODE_TOKEN_CLASS: Record = { + comment: 'text-bds-gray-50 dark:text-[#7f8c98]', + key: 'text-base-blue dark:text-[#7eb8ff]', + keyword: 'text-purple-700 dark:text-[#c792ea]', + literal: 'text-bds-orange-70 dark:text-[#ff9d76]', + number: 'text-bds-orange-70 dark:text-[#f5c542]', + plain: 'text-bds-gray-80 dark:text-[#d6deeb]', + string: 'text-bds-green-70 dark:text-[#7ee0a8]', + type: 'text-base-blue dark:text-[#82aaff]', +}; + +const JSON_PATTERN = /("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?|[{}[\]:,])/g; + +const SOLIDITY_KEYWORDS = [ + 'address', 'bool', 'constant', 'contract', 'delete', 'emit', 'event', 'external', 'function', 'if', + 'immutable', 'interface', 'internal', 'mapping', 'memory', 'private', 'public', 'require', 'return', + 'returns', 'revert', 'storage', 'struct', 'uint112', 'uint128', 'uint256', 'view', +]; + +/** + * Tokenize a JSON or Solidity snippet for display. `types` lists identifiers + * (contracts, structs, interfaces) to colour as types in Solidity. + */ +export function tokenizeCode(source: string, language: CodeLanguage, types: readonly string[] = []): CodeToken[] { + const typeSet = new Set(types); + const pattern = language === 'json' + ? JSON_PATTERN + : new RegExp( + [ + String.raw`\/\/[^\n]*`, + String.raw`"(?:\\.|[^"\\])*"`, + String.raw`\b(?:${SOLIDITY_KEYWORDS.join('|')})\b`, + ...(types.length > 0 ? [String.raw`\b(?:${types.join('|')})\b`] : []), + String.raw`\b\d[\d_]*(?:\s+ether)?\b`, + ].join('|'), + 'g', + ); + const tokens: CodeToken[] = []; + let last = 0; + for (const hit of source.matchAll(pattern)) { + const text = hit[0]; + const index = hit.index ?? 0; + if (index > last) tokens.push({ text: source.slice(last, index), kind: 'plain' }); + let kind: CodeToken['kind'] = 'plain'; + if (language === 'json') { + if (text.startsWith('"')) kind = text.endsWith(':') ? 'key' : 'string'; + else if (text === 'true' || text === 'false' || text === 'null') kind = 'literal'; + else if (/^-?\d/.test(text)) kind = 'number'; + } else if (text.startsWith('//')) kind = 'comment'; + else if (text.startsWith('"')) kind = 'string'; + else if (/^\d/.test(text)) kind = 'number'; + else if (typeSet.has(text)) kind = 'type'; + else kind = 'keyword'; + tokens.push({ text, kind }); + last = index + text.length; + } + if (last < source.length) tokens.push({ text: source.slice(last), kind: 'plain' }); + return tokens; +} + +export function CodeSnippet({ + label, + code, + language, + types, + className, +}: { + label: string; + code: string; + language: CodeLanguage; + types?: readonly string[]; + className?: string; +}) { + return ( + + {label} +
+        
+          {tokenizeCode(code, language, types).map((token, index) => (
+            {token.text}
+          ))}
+        
+      
+
+ ); +} diff --git a/app/vibenet/demos/validity/lib/artifacts/VibeLend.json b/app/vibenet/demos/validity/lib/artifacts/VibeLend.json new file mode 100644 index 00000000..fa270d33 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/VibeLend.json @@ -0,0 +1,35 @@ +{ + "compiler": { + "version": "0.8.24+commit.e11b9ed9" + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + } + }, + "command": "npx --yes solc@0.8.24 --optimize --optimize-runs 200 --bin --abi -o app/vibenet/demos/validity/lib/contracts/VibeLend.sol", + "source": "../contracts/VibeLend.sol", + "abi": [ + {"inputs":[{"internalType":"contract IERC20","name":"vibe","type":"address"},{"internalType":"contract IMintableERC20","name":"usdv","type":"address"},{"internalType":"contract IUniswapV2Pair","name":"pair","type":"address"}],"stateMutability":"nonpayable","type":"constructor"}, + {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"borrower","type":"address"},{"indexed":false,"internalType":"uint256","name":"collateral","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"debt","type":"uint256"}],"name":"Closed","type":"event"}, + {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"borrower","type":"address"},{"indexed":true,"internalType":"address","name":"liquidator","type":"address"},{"indexed":false,"internalType":"uint256","name":"collateral","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"debt","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"reward","type":"uint256"}],"name":"Liquidated","type":"event"}, + {"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"borrower","type":"address"},{"indexed":false,"internalType":"uint256","name":"collateral","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"debt","type":"uint256"}],"name":"Opened","type":"event"}, + {"inputs":[],"name":"LIQUIDATION_REWARD_BPS","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"LIQUIDATION_THRESHOLD_BPS","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"PAIR","outputs":[{"internalType":"contract IUniswapV2Pair","name":"","type":"address"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"USDV","outputs":[{"internalType":"contract IMintableERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"VIBE","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"VIBE_IS_TOKEN0","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"book","outputs":[{"internalType":"address[]","name":"borrowers","type":"address[]"},{"components":[{"internalType":"uint128","name":"collateral","type":"uint128"},{"internalType":"uint128","name":"debt","type":"uint128"}],"internalType":"struct VibeLend.Position[]","name":"open_","type":"tuple[]"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"close","outputs":[],"stateMutability":"nonpayable","type":"function"}, + {"inputs":[{"internalType":"address","name":"borrower","type":"address"}],"name":"healthFactor","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, + {"inputs":[{"internalType":"address","name":"borrower","type":"address"}],"name":"isLiquidatable","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}, + {"inputs":[{"internalType":"address","name":"borrower","type":"address"}],"name":"liquidate","outputs":[{"internalType":"uint256","name":"reward","type":"uint256"}],"stateMutability":"nonpayable","type":"function"}, + {"inputs":[{"internalType":"uint128","name":"collateral","type":"uint128"},{"internalType":"uint128","name":"debt","type":"uint128"}],"name":"open","outputs":[],"stateMutability":"nonpayable","type":"function"}, + {"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"positions","outputs":[{"internalType":"uint128","name":"collateral","type":"uint128"},{"internalType":"uint128","name":"debt","type":"uint128"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"price","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"reserves","outputs":[{"internalType":"uint256","name":"vibeReserve","type":"uint256"},{"internalType":"uint256","name":"usdvReserve","type":"uint256"}],"stateMutability":"view","type":"function"} + ], + "bytecode": "0x61010060405234801562000011575f80fd5b50604051620014bb380380620014bb8339810160408190526200003491620000e1565b6001600160a01b03808416608081905283821660a05290821660c081905260408051630dfe168160e01b81529051630dfe1681916004808201926020929091908290030181865afa1580156200008c573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190620000b2919062000132565b6001600160a01b03161460e0525062000157915050565b6001600160a01b0381168114620000de575f80fd5b50565b5f805f60608486031215620000f4575f80fd5b83516200010181620000c9565b60208501519093506200011481620000c9565b60408501519092506200012781620000c9565b809150509250925092565b5f6020828403121562000143575f80fd5b81516200015081620000c9565b9392505050565b60805160a05160c05160e0516112f1620001ca5f395f818161022d0152610af601525f81816102780152610a7101525f818161029f015281816107c001528181610e0401528181610e9b0152610f1f01525f8181610121015281816105e5015281816108950152610d2601526112f15ff3fe608060405234801561000f575f80fd5b50600436106100f0575f3560e01c80636ad9f9df11610093578063852b672f11610063578063852b672f14610262578063a035b1fe1461026b578063ace3a8a714610273578063e2c3b0461461029a575f80fd5b80636ad9f9df146101f857806375172a8b1461020b57806377be47741461022857806384c5f0261461024f575f80fd5b80630c6d5a58116100ce5780630c6d5a58146101715780632f8655681461018857806343d726d61461019b57806355f57510146101a5575f80fd5b8063042e02cf146100f457806304a3b7cd1461011c57806305a8da721461015b575b5f80fd5b6101076101023660046110a0565b6102c1565b60405190151581526020015b60405180910390f35b6101437f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610113565b61016361034b565b6040516101139291906110cd565b61017a611f4081565b604051908152602001610113565b61017a6101963660046110a0565b6104a0565b6101a3610707565b005b6101d86101b33660046110a0565b5f602081905290815260409020546001600160801b0380821691600160801b90041682565b604080516001600160801b03938416815292909116602083015201610113565b61017a6102063660046110a0565b6109aa565b610213610a6b565b60408051928352602083019190915201610113565b6101077f000000000000000000000000000000000000000000000000000000000000000081565b6101a361025d366004611182565b610b39565b61017a6101f481565b61017a611021565b6101437f000000000000000000000000000000000000000000000000000000000000000081565b6101437f000000000000000000000000000000000000000000000000000000000000000081565b6001600160a01b0381165f908152602081815260408083208151808301909252546001600160801b03808216808452600160801b909204169282019290925290820361030f57505f92915050565b5f80610319610a6b565b91509150610342835f01516001600160801b031684602001516001600160801b03168484611067565b95945050505050565b60608060018054806020026020016040519081016040528092919081815260200182805480156103a257602002820191905f5260205f20905b81546001600160a01b03168152600190910190602001808311610384575b50505050509150815167ffffffffffffffff8111156103c3576103c36111b3565b60405190808252806020026020018201604052801561040757816020015b604080518082019091525f80825260208201528152602001906001900390816103e15790505b5090505f5b825181101561049b575f80848381518110610429576104296111c7565b6020908102919091018101516001600160a01b031682528181019290925260409081015f208151808301909252546001600160801b038082168352600160801b90910416918101919091528251839083908110610488576104886111c7565b602090810291909101015260010161040c565b509091565b6001600160a01b0381165f908152602081815260408083208151808301909252546001600160801b03808216808452600160801b90920416928201929092529061051f5760405162461bcd60e51b815260206004820152600b60248201526a3737903837b9b4ba34b7b760a91b60448201526064015b60405180910390fd5b5f80610529610a6b565b91509150610552835f01516001600160801b031684602001516001600160801b03168484611067565b6105885760405162461bcd60e51b81526020600482015260076024820152666865616c74687960c81b6044820152606401610516565b6001600160a01b0385165f908152602081905260408120558251612710906105bc906101f4906001600160801b03166111db565b6105c69190611204565b60405163a9059cbb60e01b8152336004820152602481018290529094507f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af1158015610633573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906106579190611223565b61069c5760405162461bcd60e51b81526020600482015260166024820152751c995dd85c99081d1c985b9cd9995c8819985a5b195960521b6044820152606401610516565b8251602080850151604080516001600160801b0394851681529390911691830191909152810185905233906001600160a01b038716907ffcbc974bf3a532baf2bb229db3c37fd58299b62d2d1db6a855dac5b693bb6ff39060600160405180910390a3505050919050565b335f90815260208181526040918290208251808401909352546001600160801b03808216808552600160801b90920416918301919091526107785760405162461bcd60e51b815260206004820152600b60248201526a3737903837b9b4ba34b7b760a91b6044820152606401610516565b335f818152602081815260408083209290925583015190516323b872dd60e01b815260048101929092523060248301526001600160801b031660448201526001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016906323b872dd906064016020604051808303815f875af1158015610806573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061082a9190611223565b61086e5760405162461bcd60e51b81526020600482015260156024820152741c995c185e481d1c985b9cd9995c8819985a5b1959605a1b6044820152606401610516565b805160405163a9059cbb60e01b81523360048201526001600160801b0390911660248201527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af11580156108e3573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906109079190611223565b6109535760405162461bcd60e51b815260206004820152601a60248201527f636f6c6c61746572616c207472616e73666572206661696c65640000000000006044820152606401610516565b8051602082015160405133927f792b1058d55c02122048f16ecbfdaab6be257e8c903a60a01f220960238aefc79261099f926001600160801b0392831681529116602082015260400190565b60405180910390a250565b6001600160a01b0381165f908152602081815260408083208151808301909252546001600160801b038082168352600160801b909104169181018290529082036109f757505f1992915050565b5f80610a01610a6b565b915091506127108284602001516001600160801b0316610a2191906111db565b610a2b91906111db565b8351611f4090610a459084906001600160801b03166111db565b610a4f91906111db565b610a6190670de0b6b3a76400006111db565b6103429190611204565b5f805f807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316630902f1ac6040518163ffffffff1660e01b8152600401606060405180830381865afa158015610acb573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610aef9190611258565b50915091507f0000000000000000000000000000000000000000000000000000000000000000610b20578082610b23565b81815b6001600160701b03918216969116945092505050565b5f826001600160801b0316118015610b5957505f816001600160801b0316115b610b965760405162461bcd60e51b815260206004820152600e60248201526d32b6b83a3c903837b9b4ba34b7b760911b6044820152606401610516565b335f908152602081905260409020546001600160801b031615610bed5760405162461bcd60e51b815260206004820152600f60248201526e706f736974696f6e2065786973747360881b6044820152606401610516565b5f80610bf7610a6b565b91509150610c19846001600160801b0316846001600160801b03168484611067565b15610c525760405162461bcd60e51b8152602060048201526009602482015268756e6865616c74687960b81b6044820152606401610516565b6040805180820182526001600160801b0380871682528581166020808401918252335f908152808252858120945192518416600160801b0292909316919091179092556002909152205460ff16610cfc57335f818152600260205260408120805460ff191660019081179091558054808201825591527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf60180546001600160a01b03191690911790555b6040516323b872dd60e01b81523360048201523060248201526001600160801b03851660448201527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906323b872dd906064016020604051808303815f875af1158015610d74573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610d989190611223565b610de45760405162461bcd60e51b815260206004820152601a60248201527f636f6c6c61746572616c207472616e73666572206661696c65640000000000006044820152606401610516565b6040516370a0823160e01b81523060048201526001600160801b038416907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906370a0823190602401602060405180830381865afa158015610e51573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610e7591906112a4565b1015610efb576040516340c10f1960e01b815230600482015264e8d4a5100060248201527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906340c10f19906044015f604051808303815f87803b158015610ee4575f80fd5b505af1158015610ef6573d5f803e3d5ffd5b505050505b60405163a9059cbb60e01b81523360048201526001600160801b03841660248201527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af1158015610f6d573d5f803e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610f919190611223565b610fd65760405162461bcd60e51b8152602060048201526016602482015275189bdc9c9bddc81d1c985b9cd9995c8819985a5b195960521b6044820152606401610516565b604080516001600160801b0380871682528516602082015233917f0e00eb3434eeed137478dadba5581f87fdda6f16b438d9d1b616b24093bfbb42910160405180910390a250505050565b5f805f61102c610a6b565b91509150815f1461105e578161104f6c0c9f2c9cd04674edea40000000836111db565b6110599190611204565b611060565b5f5b9250505090565b5f61271061107584866111db565b61107f91906111db565b611f4061108c84886111db565b61109691906111db565b1095945050505050565b5f602082840312156110b0575f80fd5b81356001600160a01b03811681146110c6575f80fd5b9392505050565b604080825283518282018190525f91906020906060850190828801855b8281101561110f5781516001600160a01b0316845292840192908401906001016110ea565b505050848103828601528551808252868301918301905f5b8181101561115957835180516001600160801b0390811685529086015116858401529284019291850191600101611127565b509098975050505050505050565b80356001600160801b038116811461117d575f80fd5b919050565b5f8060408385031215611193575f80fd5b61119c83611167565b91506111aa60208401611167565b90509250929050565b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52603260045260245ffd5b80820281158282048414176111fe57634e487b7160e01b5f52601160045260245ffd5b92915050565b5f8261121e57634e487b7160e01b5f52601260045260245ffd5b500490565b5f60208284031215611233575f80fd5b815180151581146110c6575f80fd5b80516001600160701b038116811461117d575f80fd5b5f805f6060848603121561126a575f80fd5b61127384611242565b925061128160208501611242565b9150604084015163ffffffff81168114611299575f80fd5b809150509250925092565b5f602082840312156112b4575f80fd5b505191905056fea26469706673582212203bf7d230796af18528c5d02527bee58cfe3b4896ee73a60ac203dcc0471d34bc64736f6c63430008180033" +} diff --git a/app/vibenet/demos/validity/lib/contracts/VibeLend.sol b/app/vibenet/demos/validity/lib/contracts/VibeLend.sol new file mode 100644 index 00000000..90ef5b30 --- /dev/null +++ b/app/vibenet/demos/validity/lib/contracts/VibeLend.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +interface IMintableERC20 is IERC20 { + function mint(address to, uint256 amount) external; +} + +interface IUniswapV2Pair { + function token0() external view returns (address); + function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); +} + +/// @notice Demo lending market for the Vibenet validity-transactions demos. +/// Borrowers post VIBE as collateral and borrow USDV. The price oracle is the +/// spot price of the VIBE/USDV Uniswap v2 pair, which anyone can move, so this +/// is not a safe design for real funds. +/// +/// A position is liquidatable once `collateral * price * 80% < debt`. Anyone +/// can then call `liquidate(borrower)`: the position is closed, its debt is +/// written off, and the caller receives 5% of the seized collateral. +contract VibeLend { + /// @dev Packed into one storage word: collateral in the low 128 bits, debt + /// in the high 128 bits. A validity predicate can pin a position with a + /// single `=` comparison on `keccak256(abi.encode(borrower, 0))`. + struct Position { + uint128 collateral; + uint128 debt; + } + + uint256 public constant LIQUIDATION_THRESHOLD_BPS = 8_000; + uint256 public constant LIQUIDATION_REWARD_BPS = 500; + uint256 private constant BPS = 10_000; + /// @dev USDV has 6 decimals and VIBE 18, so price(1e18) = usdv * 1e30 / vibe. + uint256 private constant PRICE_SCALE = 1e30; + uint256 private constant USDV_TOP_UP = 1_000_000e6; + + /// @dev Slot 0. Keep it first: the UI derives position slots from it. + mapping(address => Position) public positions; + address[] private borrowerList; + mapping(address => bool) private listed; + + IERC20 public immutable VIBE; + IMintableERC20 public immutable USDV; + IUniswapV2Pair public immutable PAIR; + bool public immutable VIBE_IS_TOKEN0; + + event Opened(address indexed borrower, uint256 collateral, uint256 debt); + event Closed(address indexed borrower, uint256 collateral, uint256 debt); + event Liquidated( + address indexed borrower, address indexed liquidator, uint256 collateral, uint256 debt, uint256 reward + ); + + constructor(IERC20 vibe, IMintableERC20 usdv, IUniswapV2Pair pair) { + VIBE = vibe; + USDV = usdv; + PAIR = pair; + VIBE_IS_TOKEN0 = pair.token0() == address(vibe); + } + + /// @notice Deposit `collateral` VIBE and borrow `debt` USDV. The position + /// must be healthy at the current price. + function open(uint128 collateral, uint128 debt) external { + require(collateral > 0 && debt > 0, "empty position"); + require(positions[msg.sender].collateral == 0, "position exists"); + (uint256 vibeReserve, uint256 usdvReserve) = reserves(); + require(!_liquidatable(collateral, debt, vibeReserve, usdvReserve), "unhealthy"); + + positions[msg.sender] = Position(collateral, debt); + if (!listed[msg.sender]) { + listed[msg.sender] = true; + borrowerList.push(msg.sender); + } + + require(VIBE.transferFrom(msg.sender, address(this), collateral), "collateral transfer failed"); + // USDV is Vibenet's public-mint test dollar, so the market refills + // itself instead of relying on a funded treasury. + if (USDV.balanceOf(address(this)) < debt) USDV.mint(address(this), USDV_TOP_UP); + require(USDV.transfer(msg.sender, debt), "borrow transfer failed"); + emit Opened(msg.sender, collateral, debt); + } + + /// @notice Repay the full debt and withdraw the collateral. + function close() external { + Position memory position = positions[msg.sender]; + require(position.collateral > 0, "no position"); + delete positions[msg.sender]; + + require(USDV.transferFrom(msg.sender, address(this), position.debt), "repay transfer failed"); + require(VIBE.transfer(msg.sender, position.collateral), "collateral transfer failed"); + emit Closed(msg.sender, position.collateral, position.debt); + } + + /// @notice Close an under-collateralized position and pay the caller 5% of + /// its collateral. + function liquidate(address borrower) external returns (uint256 reward) { + Position memory position = positions[borrower]; + require(position.collateral > 0, "no position"); + (uint256 vibeReserve, uint256 usdvReserve) = reserves(); + require(_liquidatable(position.collateral, position.debt, vibeReserve, usdvReserve), "healthy"); + delete positions[borrower]; + + reward = uint256(position.collateral) * LIQUIDATION_REWARD_BPS / BPS; + require(VIBE.transfer(msg.sender, reward), "reward transfer failed"); + emit Liquidated(borrower, msg.sender, position.collateral, position.debt, reward); + } + + /// @notice Pair reserves ordered as (VIBE, USDV). + function reserves() public view returns (uint256 vibeReserve, uint256 usdvReserve) { + (uint112 reserve0, uint112 reserve1,) = PAIR.getReserves(); + return VIBE_IS_TOKEN0 ? (reserve0, reserve1) : (reserve1, reserve0); + } + + /// @notice Oracle price in USDV per VIBE, scaled by 1e18. + function price() external view returns (uint256) { + (uint256 vibeReserve, uint256 usdvReserve) = reserves(); + return vibeReserve == 0 ? 0 : usdvReserve * PRICE_SCALE / vibeReserve; + } + + /// @notice `collateral * price * threshold / debt`, scaled by 1e18. Below + /// 1e18 the position is liquidatable. + function healthFactor(address borrower) external view returns (uint256) { + Position memory position = positions[borrower]; + if (position.debt == 0) return type(uint256).max; + (uint256 vibeReserve, uint256 usdvReserve) = reserves(); + return uint256(position.collateral) * usdvReserve * LIQUIDATION_THRESHOLD_BPS * 1e18 + / (uint256(position.debt) * vibeReserve * BPS); + } + + function isLiquidatable(address borrower) external view returns (bool) { + Position memory position = positions[borrower]; + if (position.collateral == 0) return false; + (uint256 vibeReserve, uint256 usdvReserve) = reserves(); + return _liquidatable(position.collateral, position.debt, vibeReserve, usdvReserve); + } + + /// @notice Every address that has ever opened a position, with its current + /// position (zero once closed or liquidated). + function book() external view returns (address[] memory borrowers, Position[] memory open_) { + borrowers = borrowerList; + open_ = new Position[](borrowers.length); + for (uint256 i = 0; i < borrowers.length; i++) { + open_[i] = positions[borrowers[i]]; + } + } + + /// @dev collateral * (usdv / vibe) * threshold < debt, cross-multiplied so + /// there is no rounding. + function _liquidatable(uint256 collateral, uint256 debt, uint256 vibeReserve, uint256 usdvReserve) + private + pure + returns (bool) + { + return collateral * usdvReserve * LIQUIDATION_THRESHOLD_BPS < debt * vibeReserve * BPS; + } +} diff --git a/app/vibenet/demos/validity/lib/lending.test.ts b/app/vibenet/demos/validity/lib/lending.test.ts new file mode 100644 index 00000000..7d1a7fec --- /dev/null +++ b/app/vibenet/demos/validity/lib/lending.test.ts @@ -0,0 +1,186 @@ +import type { Address } from 'viem'; +import { describe, expect, it, vi } from 'vitest'; + +import artifact from './artifacts/VibeLend.json'; +import { PAIR_RESERVES_SLOT, RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; +import { + healthFactorWad, + isLiquidatable, + LENDING_SALT, + liquidationPriceWad, + liquidationValidity, + packPosition, + positionSlot, + predictLending, + probeLending, + spotPriceWad, + usdvReserveCeiling, + type LendingReserves, + type Position, +} from './lending'; + +// Live vibenet fixtures (api.vibes.base.org/api/vibenet/contracts). +const VIBE = '0xB200000000000000000000ef8aE5Df466876133d' as Address; +const USDV = '0x56370eA3085612e42a4d89E001fb7833b62617CF' as Address; +const PAIR = '0x247C33a9C3C5D19ac0B0114422D23807177E573A' as Address; +const LENDING = '0x1534d0D4d4161AF5988e81b282741BE87E001dA1' as Address; +const BORROWER = '0x032FE6D95Ecd2146f9505864D5fa9e974208fF1d' as Address; + +const USDV_UNIT = 10n ** 6n; +const reserves = (vibe: bigint, usdv: bigint): LendingReserves => ({ vibe, usdv }); + +describe('VibeLend singleton', () => { + it('commits reproducible compiler metadata', () => { + expect(artifact.compiler.version).toBe('0.8.24+commit.e11b9ed9'); + expect(artifact.settings.optimizer).toEqual({ enabled: true, runs: 200 }); + expect(artifact.command).toContain('solc@0.8.24 --optimize --optimize-runs 200'); + }); + + it('predicts the address vibenet-setup deploys for the live pool', () => { + // Must match the salt in base/vibenet deploy_validity_lending. + expect(LENDING_SALT).toBe('0xdf20a36f2c138fe0bca1d03a0d4ae3c2c3dce743a489873b8820cb3f19bae770'); + expect(predictLending(VIBE, USDV, PAIR)).toBe(LENDING); + }); + + it('discovers only a deployment that prices off the given pair', async () => { + const client = { + getCode: vi.fn().mockResolvedValue('0x1234'), + readContract: vi.fn().mockResolvedValue(PAIR), + }; + await expect(probeLending(client as never, VIBE, USDV, PAIR)).resolves.toBe(LENDING); + client.readContract.mockResolvedValue(USDV); + await expect(probeLending(client as never, VIBE, USDV, PAIR)).resolves.toBeNull(); + client.getCode.mockResolvedValue('0x'); + await expect(probeLending(client as never, VIBE, USDV, PAIR)).resolves.toBeNull(); + }); +}); + +describe('position storage', () => { + // Read from live vibenet with `cast index address 0` and + // `cast storage ` for a 48,000 VIBE / 2,880.498651 USDV position. + it('matches the slot and packed word the contract writes', () => { + expect(positionSlot(BORROWER)).toBe( + 0x4456099b6ee08ace15af7a59931cff59ebfc3289069d8fd2b1b59665422ff0e4n, + ); + expect(packPosition({ collateral: 48_000n * WAD, debt: 2_880_498_651n })).toBe( + 0x000000000000000000000000abb0ebdb0000000000000a2a15d09519be000000n, + ); + }); + + it('rejects fields wider than uint128', () => { + expect(() => packPosition({ collateral: 1n << 128n, debt: 1n })).toThrow(); + }); +}); + +describe('health', () => { + const pool = reserves(10_000_000n * WAD, 700_000n * USDV_UNIT); // $0.07 + // Liquidation price $0.0625: 0.0625 * 0.8 * 100k = 5,000 USDV. + const position: Position = { collateral: 100_000n * WAD, debt: 5_000n * USDV_UNIT }; + + it('reports prices and health on the contract scale', () => { + expect(spotPriceWad(pool)).toBe(70_000_000_000_000_000n); + expect(liquidationPriceWad(position)).toBe(62_500_000_000_000_000n); + // 100k * 0.07 * 0.8 / 5k = 1.12 + expect(healthFactorWad(position, pool)).toBe(1_120_000_000_000_000_000n); + expect(isLiquidatable(position, pool)).toBe(false); + }); + + it('flips to liquidatable just below the liquidation price', () => { + const vibe = 10_000_000n * WAD; + const at = reserves(vibe, 625_000n * USDV_UNIT); // exactly $0.0625 + const below = reserves(vibe, 625_000n * USDV_UNIT - 1n); + expect(isLiquidatable(position, at)).toBe(false); + expect(isLiquidatable(position, below)).toBe(true); + }); +}); + +describe('usdvReserveCeiling', () => { + // Deterministic LCG so failures reproduce. + let seed = 0x5eedn; + const rand = (max: bigint) => { + seed = (seed * 6364136223846793005n + 1442695040888963407n) & ((1n << 64n) - 1n); + return (seed % max) + 1n; + }; + + it('never admits a reserve state the contract would call healthy', () => { + for (let i = 0; i < 500; i++) { + const vibe = rand(50_000_000n) * WAD + rand(WAD); + const usdv = rand(5_000_000n) * USDV_UNIT + rand(USDV_UNIT); + const k = vibe * usdv; + const position = { collateral: rand(200_000n) * WAD, debt: rand(20_000n) * USDV_UNIT }; + const ceiling = usdvReserveCeiling(position, k); + if (ceiling === 0n) continue; + // Worst case for the liquidator: USDV at the ceiling and VIBE at the + // smallest reserve k allows, plus a pool whose k grew since signing. + for (const u of [ceiling, ceiling / 2n + 1n]) { + for (const grownK of [k, k + k / 10n]) { + const v = (grownK + u - 1n) / u; + expect(isLiquidatable(position, reserves(v, u))).toBe(true); + } + } + } + }); + + it('is tight: two units above the ceiling, the curve point is healthy', () => { + for (let i = 0; i < 200; i++) { + const vibe = rand(50_000_000n) * WAD; + const usdv = rand(5_000_000n) * USDV_UNIT; + const k = vibe * usdv; + const position = { collateral: rand(200_000n) * WAD, debt: rand(20_000n) * USDV_UNIT }; + const ceiling = usdvReserveCeiling(position, k); + const u = ceiling + 2n; + expect(isLiquidatable(position, reserves(k / u, u))).toBe(false); + } + }); + + it('is zero for an empty position or pool', () => { + expect(usdvReserveCeiling({ collateral: 0n, debt: 1n }, 1n)).toBe(0n); + expect(usdvReserveCeiling({ collateral: 1n, debt: 1n }, 0n)).toBe(0n); + }); +}); + +describe('liquidationValidity', () => { + const pool = reserves(10_000_000n * WAD, 700_000n * USDV_UNIT); + const position: Position = { collateral: 100_000n * WAD, debt: 5_000n * USDV_UNIT }; + const base = { + lending: LENDING, + pair: PAIR, + borrower: BORROWER, + position, + reserves: pool, + maxBlock: 1_000n, + }; + + it('bounds the USDV reserve in the low half when USDV is token0', () => { + const { predicates, ceiling, ceilingPriceWad } = liquidationValidity({ ...base, vibeIsToken0: false }); + expect(predicates).toHaveLength(3); + const [reserve, pinned, expiry] = predicates; + expect(reserve).toMatchObject({ type: 'storage', params: { address: PAIR, op: '<=' } }); + if (reserve?.type !== 'storage') throw new Error('expected storage'); + expect(BigInt(reserve.params.slot)).toBe(PAIR_RESERVES_SLOT); + expect(BigInt(reserve.params.mask!)).toBe(RESERVE0_MASK); + expect(BigInt(reserve.params.value)).toBe(ceiling); + // On the current curve the ceiling sits at the liquidation price. + const liq = liquidationPriceWad(position); + expect(ceilingPriceWad <= liq).toBe(true); + expect(liq - ceilingPriceWad < liq / 1_000_000n).toBe(true); + // Below the current USDV reserve, since the position is healthy now. + expect(ceiling < pool.usdv).toBe(true); + + expect(pinned).toMatchObject({ type: 'storage', params: { address: LENDING, op: '=' } }); + if (pinned?.type !== 'storage') throw new Error('expected storage'); + expect(BigInt(pinned.params.slot)).toBe(positionSlot(BORROWER)); + expect(BigInt(pinned.params.value)).toBe(packPosition(position)); + + expect(expiry).toEqual({ type: 'block_number', params: { op: '<=', value: expect.any(String) } }); + }); + + it('shifts the bound into the high half when VIBE is token0', () => { + const low = liquidationValidity({ ...base, vibeIsToken0: false }); + const high = liquidationValidity({ ...base, vibeIsToken0: true }); + const reserve = high.predicates[0]; + if (reserve?.type !== 'storage') throw new Error('expected storage'); + expect(BigInt(reserve.params.mask!)).toBe(RESERVE1_MASK); + expect(BigInt(reserve.params.value)).toBe(low.ceiling << RESERVE_BITS); + }); +}); diff --git a/app/vibenet/demos/validity/lib/lending.ts b/app/vibenet/demos/validity/lib/lending.ts new file mode 100644 index 00000000..04625b04 --- /dev/null +++ b/app/vibenet/demos/validity/lib/lending.ts @@ -0,0 +1,210 @@ +import { + encodeAbiParameters, + encodeDeployData, + encodeFunctionData, + keccak256, + type Abi, + type Address, + type Hex, + type PublicClient, +} from 'viem'; + +import artifact from './artifacts/VibeLend.json'; +import { PAIR_RESERVES_SLOT, RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; +import { blockExpiryPredicate, sqrt, storagePredicate } from './predicates'; +import { create2Address, hasCode, singletonSalt } from './singleton'; +import type { ValidityPredicate } from './types'; + +export const vibeLendAbi = artifact.abi as Abi; +export const vibeLendBytecode = artifact.bytecode as Hex; + +export const LENDING_SALT = singletonSalt('lending'); +export const LIQUIDATION_THRESHOLD_BPS = 8_000n; +export const LIQUIDATION_REWARD_BPS = 500n; +const BPS = 10_000n; +/** USDV has 6 decimals and VIBE 18: price(1e18) = usdv * 1e30 / vibe. */ +const PRICE_SCALE = 10n ** 30n; +const WORD_MASK = (1n << 256n) - 1n; +const UINT128_MAX = (1n << 128n) - 1n; + +export type Position = { collateral: bigint; debt: bigint }; +export type BookEntry = Position & { borrower: Address }; +export type LendingReserves = { vibe: bigint; usdv: bigint }; + +export function lendingInitCode(vibe: Address, usdv: Address, pair: Address): Hex { + return encodeDeployData({ + abi: vibeLendAbi, + bytecode: vibeLendBytecode, + args: [vibe, usdv, pair], + }); +} + +export function predictLending(vibe: Address, usdv: Address, pair: Address): Address { + return create2Address(LENDING_SALT, lendingInitCode(vibe, usdv, pair)); +} + +/** The shared VibeLend singleton for this pool, or null until vibenet-setup deploys it. */ +export async function probeLending( + client: PublicClient, + vibe: Address, + usdv: Address, + pair: Address, +): Promise
{ + const address = predictLending(vibe, usdv, pair); + if (!(await hasCode(client, address))) return null; + const configuredPair = await client + .readContract({ address, abi: vibeLendAbi, functionName: 'PAIR' }) + .catch(() => null); + return typeof configuredPair === 'string' && configuredPair.toLowerCase() === pair.toLowerCase() + ? address + : null; +} + +/** Every open position plus the (VIBE, USDV) reserves the contract prices with. */ +export async function readBook( + client: PublicClient, + lending: Address, +): Promise<{ entries: BookEntry[]; reserves: LendingReserves }> { + const [book, reserves] = await Promise.all([ + client.readContract({ address: lending, abi: vibeLendAbi, functionName: 'book' }) as Promise< + readonly [readonly Address[], readonly Position[]] + >, + client.readContract({ address: lending, abi: vibeLendAbi, functionName: 'reserves' }) as Promise< + readonly [bigint, bigint] + >, + ]); + const [borrowers, positions] = book; + const entries = borrowers + .map((borrower, i) => ({ borrower, ...(positions[i] ?? { collateral: 0n, debt: 0n }) })) + .filter((entry) => entry.collateral > 0n); + return { entries, reserves: { vibe: reserves[0], usdv: reserves[1] } }; +} + +export function isLiquidatable(position: Position, reserves: LendingReserves): boolean { + if (position.collateral === 0n) return false; + return ( + position.collateral * reserves.usdv * LIQUIDATION_THRESHOLD_BPS < + position.debt * reserves.vibe * BPS + ); +} + +/** Same scale as VibeLend.healthFactor: below 1e18 the position is liquidatable. */ +export function healthFactorWad(position: Position, reserves: LendingReserves): bigint { + if (position.debt === 0n || reserves.vibe === 0n) return WORD_MASK; + return ( + (position.collateral * reserves.usdv * LIQUIDATION_THRESHOLD_BPS * WAD) / + (position.debt * reserves.vibe * BPS) + ); +} + +/** USDV per VIBE (1e18-scaled) below which the position is liquidatable. */ +export function liquidationPriceWad(position: Position): bigint { + if (position.collateral === 0n) return 0n; + return (position.debt * BPS * PRICE_SCALE) / (position.collateral * LIQUIDATION_THRESHOLD_BPS); +} + +export function spotPriceWad(reserves: LendingReserves): bigint { + if (reserves.vibe === 0n) return 0n; + return (reserves.usdv * PRICE_SCALE) / reserves.vibe; +} + +export function liquidationReward(position: Position): bigint { + return (position.collateral * LIQUIDATION_REWARD_BPS) / BPS; +} + +/** `positions[borrower]` lives at keccak256(abi.encode(borrower, 0)). */ +export function positionSlot(borrower: Address): bigint { + return BigInt( + keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [borrower, 0n])), + ); +} + +/** The packed storage word: collateral in the low 128 bits, debt in the high 128. */ +export function packPosition(position: Position): bigint { + if (position.collateral > UINT128_MAX || position.debt > UINT128_MAX) { + throw new Error('Position field exceeds uint128.'); + } + return position.collateral | (position.debt << 128n); +} + +/** + * Largest USDV reserve `u` such that `u ≤ ceiling` guarantees the position is + * liquidatable, given the pool's current constant product `k`. + * + * The sequencer can only compare one reserve against a constant, but + * liquidatability depends on the ratio of both. The pool charges no fee and no + * one removes liquidity, so k never decreases: at inclusion `vibe ≥ k / u`. If + * `collateral · 8000 · u² < debt · 10000 · k`, multiplying through by + * `vibe · u ≥ k` gives `collateral · u · 8000 < debt · vibe · 10000`, which is + * exactly the contract's liquidation test. So the predicate never admits a + * transaction that would revert with "healthy". + */ +export function usdvReserveCeiling(position: Position, k: bigint): bigint { + if (position.collateral === 0n || position.debt === 0n || k === 0n) return 0n; + const numerator = position.debt * BPS * k; + const denominator = position.collateral * LIQUIDATION_THRESHOLD_BPS; + if (numerator === 0n) return 0n; + return sqrt((numerator - 1n) / denominator); +} + +/** USDV-per-VIBE price (1e18) at the ceiling on the current curve, for display. */ +export function ceilingPriceWad(ceiling: bigint, k: bigint): bigint { + if (k === 0n) return 0n; + return (ceiling * ceiling * PRICE_SCALE) / k; +} + +export function encodeLiquidate(lending: Address, borrower: Address): { to: Address; data: Hex } { + return { + to: lending, + data: encodeFunctionData({ abi: vibeLendAbi, functionName: 'liquidate', args: [borrower] }), + }; +} + +export type LiquidationValidity = { + predicates: ValidityPredicate[]; + ceiling: bigint; + ceilingPriceWad: bigint; +}; + +/** + * Validity predicates for liquidating `borrower`: + * + * 1. the pair's USDV reserve is at or below the ceiling, so the position is + * under water when the transaction lands; + * 2. the position word still equals what we read, so the transaction is + * dropped rather than reverted if someone else liquidates first or the + * borrower closes or reopens; + * 3. a block-number expiry. + */ +export function liquidationValidity(args: { + lending: Address; + pair: Address; + vibeIsToken0: boolean; + borrower: Address; + position: Position; + reserves: LendingReserves; + maxBlock: bigint; +}): LiquidationValidity { + const k = args.reserves.vibe * args.reserves.usdv; + let ceiling = usdvReserveCeiling(args.position, k); + if (ceiling === 0n) throw new Error('Position cannot be liquidated at any price.'); + if (ceiling > RESERVE0_MASK) ceiling = RESERVE0_MASK; + const [mask, value] = args.vibeIsToken0 + ? [RESERVE1_MASK, ceiling << RESERVE_BITS] + : [RESERVE0_MASK, ceiling]; + return { + predicates: [ + storagePredicate(args.pair, PAIR_RESERVES_SLOT, mask, '<=', value), + storagePredicate( + args.lending, + positionSlot(args.borrower), + WORD_MASK, + '=', + packPosition(args.position), + ), + blockExpiryPredicate(args.maxBlock), + ], + ceiling, + ceilingPriceWad: ceilingPriceWad(ceiling, k), + }; +} diff --git a/app/vibenet/demos/validity/liquidations/LiquidationsDemo.tsx b/app/vibenet/demos/validity/liquidations/LiquidationsDemo.tsx new file mode 100644 index 00000000..0f9370f8 --- /dev/null +++ b/app/vibenet/demos/validity/liquidations/LiquidationsDemo.tsx @@ -0,0 +1,1029 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { parseEventLogs, type Address, type Hex, type PublicClient } from 'viem'; + +import { trackValidityLiquidation } from '../../../../analytics/events'; +import { Button } from '../../../../components/ui/Button'; +import { Card } from '../../../../components/ui/Card'; +import { cn } from '../../../../components/ui/cn'; +import { Text } from '../../../../components/ui/Text'; +import { vibenetApi } from '../../../library/client'; +import { VIBENET_EXPLORER_PATH, VIBENET_WS_URL } from '../../../library/config'; +import { AccountDemoShell } from '../../_components/AccountDemoShell'; +import { DemoHeader } from '../../_components/DemoHeader'; +import { ChevronIcon } from '../../_shared/dropdown'; +import { AccountEngineProvider, useAccountEngine } from '../../account/useAccountEngine'; +import { CodeSnippet } from '../components/CodeSnippet'; +import { USDV_DECIMALS, VIBE_DECIMALS, WAD } from '../lib/constants'; +import { signK1Eip1559Call } from '../lib/eip1559'; +import { + encodeLiquidate, + healthFactorWad, + isLiquidatable, + liquidationPriceWad, + liquidationReward, + liquidationValidity, + probeLending, + readBook, + spotPriceWad, + vibeLendAbi, + type BookEntry, + type LendingReserves, +} from '../lib/lending'; +import { maxBlockForExpiry } from '../lib/orders'; +import { formatPrice, prettyValidity } from '../lib/predicates'; +import { formatTokenAmount, vibeIsToken0 } from '../lib/quote'; +import { describeValidityError, makePublicClient, sendValidityTransaction, type RpcSend } from '../lib/rpc'; +import { probeSingleton } from '../lib/singleton'; +import { connectJsonRpcStream, headNumber, type StreamHead } from '../lib/stream'; +import { + blocksUnderwater, + isSettled, + LIQUIDATION_EXPIRY_OPTIONS, + nonceFree, + positionKey, + shortAddress, + totalEarned, + trackUnderwater, + type AttemptKind, + type LiquidationAttempt, + type LiquidationEvent, + type LiquidationExpiry, + type Underwater, +} from './attempts'; + +const POLL_MS = 500; +const RECEIPT_POLL_MS = 1_000; +const LOG_POLL_MS = 1_500; +const LOG_LOOKBACK_BLOCKS = 600n; +const RETRY_MS = 1_000; +const LIQUIDATE_GAS = 150_000n; +const MAX_FEED = 30; +const MAX_HISTORY = 20; + +type Market = { lending: Address; pair: Address; vibeIsToken0: boolean }; +type Snapshot = { + entries: BookEntry[]; + reserves: LendingReserves; + block: bigint; + at: number; +}; + +const CONTRACT_TYPES = ['Position', 'VIBE'] as const; + +const CONTRACT_SNIPPET = `struct Position { + uint128 collateral; // low 128 bits + uint128 debt; // high 128 bits +} +mapping(address => Position) public positions; // slot 0 + +function liquidate(address borrower) + external + returns (uint256 reward) +{ + Position memory p = positions[borrower]; + require(p.collateral > 0, "no position"); + (uint256 vibeReserve, uint256 usdvReserve) = reserves(); + // collateral * price * 80% < debt + require( + p.collateral * usdvReserve * 8_000 + < p.debt * vibeReserve * 10_000, + "healthy" + ); + delete positions[borrower]; + reward = p.collateral * 500 / 10_000; // 5% + VIBE.transfer(msg.sender, reward); +}`; + +export function LiquidationsDemo() { + return ( + + + + ); +} + +function LiquidationsDemoInner() { + const engine = useAccountEngine(); + const acct = engine.acct; + const [client, setClient] = useState(null); + const [market, setMarket] = useState(null); + const [setupError, setSetupError] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [underwater, setUnderwater] = useState>(new Map()); + const [feed, setFeed] = useState([]); + const [expiry, setExpiry] = useState(15); + const [selected, setSelected] = useState
(null); + const [attempt, setAttempt] = useState(null); + const [history, setHistory] = useState([]); + const [signerReady, setSignerReady] = useState(false); + const [signerError, setSignerError] = useState(null); + const [error, setError] = useState(null); + + const rpcSendRef = useRef(null); + const attemptRef = useRef(null); + const attemptIdRef = useRef(0); + const submittingRef = useRef(false); + /** First-seen-liquidatable block per `borrower:collateral:debt`, kept after the position is gone. */ + const underwaterLogRef = useRef>(new Map()); + useEffect(() => { + attemptRef.current = attempt; + }, [attempt]); + + const signerAddress = engine.activeSigner?.kind === 'k1' ? (engine.activeSigner.address ?? null) : null; + + // Discover the shared pool and the VibeLend singleton. + useEffect(() => { + let cancelled = false; + let retryId: number | undefined; + const nextClient = makePublicClient(() => rpcSendRef.current); + setClient(nextClient); + const discover = async () => { + try { + const deployment = await probeSingleton(nextClient); + const lending = deployment + ? await probeLending(nextClient, deployment.tokenA, deployment.tokenB, deployment.pair) + : null; + if (cancelled) return; + if (!deployment || !lending) { + setSetupError(null); + retryId = window.setTimeout(() => void discover(), RETRY_MS); + return; + } + setMarket({ + lending, + pair: deployment.pair, + vibeIsToken0: vibeIsToken0(deployment), + }); + setSetupError(null); + } catch (err) { + if (cancelled) return; + setSetupError(err instanceof Error ? err.message : 'Could not reach shared Vibenet infrastructure.'); + retryId = window.setTimeout(() => void discover(), RETRY_MS); + } + }; + void discover(); + return () => { + cancelled = true; + if (retryId !== undefined) window.clearTimeout(retryId); + }; + }, []); + + // Fund the in-browser K1 signer that pays for liquidations. + useEffect(() => { + if (!client || !signerAddress) { + setSignerReady(false); + setSignerError(signerAddress ? null : 'A K1 owner is required to sign EIP-1559 transactions.'); + return; + } + let cancelled = false; + void (async () => { + setSignerReady(false); + setSignerError(null); + try { + let balance = await client.getBalance({ address: signerAddress }); + if (balance === 0n) { + await vibenetApi.faucet.drip({ address: signerAddress }).catch(() => undefined); + const deadline = Date.now() + 10_000; + while (balance === 0n && Date.now() < deadline) { + await new Promise((resolve) => window.setTimeout(resolve, 500)); + balance = await client.getBalance({ address: signerAddress }); + } + } + if (cancelled) return; + if (balance === 0n) throw new Error('The faucet top-up for your signer did not land.'); + setSignerReady(true); + } catch (err) { + if (!cancelled) setSignerError(err instanceof Error ? err.message : 'Could not prepare the signer.'); + } + })(); + return () => { + cancelled = true; + }; + }, [client, signerAddress]); + + // Follow the book on every head (WebSocket) or on a short poll. + useEffect(() => { + if (!client || !market) return; + let cancelled = false; + let inFlight = false; + let pollId: number | undefined; + let stream: ReturnType | undefined; + + const sync = async (head?: bigint) => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const [book, block] = await Promise.all([ + readBook(client, market.lending), + head === undefined ? client.getBlockNumber({ cacheTime: 0 }) : Promise.resolve(head), + ]); + if (cancelled) return; + const at = Date.now(); + setSnapshot({ ...book, block, at }); + // Expire an armed validity tx past its bound, leaving two blocks for + // the receipt poll to see an inclusion in the last valid block. + const armed = attemptRef.current; + if (armed?.status === 'armed' && armed.maxBlock !== undefined && block > armed.maxBlock + 2n) { + attemptRef.current = { ...armed, status: 'expired' }; + setAttempt((a) => (a && a.id === armed.id && a.status === 'armed' ? { ...a, status: 'expired' } : a)); + trackValidityLiquidation('validity', 'expired'); + } + setUnderwater((previous) => { + const next = trackUnderwater( + previous, + book.entries.map((entry) => ({ + ...entry, + liquidatable: isLiquidatable(entry, book.reserves), + })), + block, + ); + const log = underwaterLogRef.current; + for (const [borrower, seen] of next) { + const key = `${borrower}:${seen.key}`; + if (!log.has(key)) log.set(key, seen.block); + } + // A position that recovered restarts its under-water clock. + for (const entry of book.entries) { + if (!isLiquidatable(entry, book.reserves)) log.delete(`${entry.borrower}:${positionKey(entry)}`); + } + while (log.size > 200) log.delete(log.keys().next().value!); + return next; + }); + } catch { + // Keep the last snapshot while the feed reconnects. + } finally { + inFlight = false; + } + }; + + const startPoll = () => { + if (pollId !== undefined) return; + rpcSendRef.current = null; + void sync(); + pollId = window.setInterval(() => void sync(), POLL_MS); + }; + + if (VIBENET_WS_URL) { + const wsUrl = VIBENET_WS_URL; + void (async () => { + stream = connectJsonRpcStream(wsUrl); + stream.setOnClose(() => { + rpcSendRef.current = null; + if (!cancelled) startPoll(); + }); + await stream.ready; + rpcSendRef.current = (method, params) => stream!.request(method, params); + await stream.subscribe(['newHeads'], (raw) => { + const block = headNumber(raw as StreamHead); + if (block !== null) void sync(block); + }); + if (cancelled) stream.close(); + else void sync(); + })().catch(() => { + rpcSendRef.current = null; + stream?.close(); + if (!cancelled) startPoll(); + }); + } else { + startPoll(); + } + + return () => { + cancelled = true; + rpcSendRef.current = null; + if (pollId !== undefined) window.clearInterval(pollId); + stream?.close(); + }; + }, [client, market]); + + // Liquidation feed from Liquidated events. + useEffect(() => { + if (!client || !market) return; + let cancelled = false; + let from: bigint | null = null; + const poll = async () => { + try { + const head = await client.getBlockNumber({ cacheTime: 0 }); + const fromBlock: bigint = from ?? (head > LOG_LOOKBACK_BLOCKS ? head - LOG_LOOKBACK_BLOCKS : 0n); + if (fromBlock > head) return; + const logs = await client.getContractEvents({ + address: market.lending, + abi: vibeLendAbi, + eventName: 'Liquidated', + fromBlock, + toBlock: head, + }); + if (cancelled) return; + from = head + 1n; + if (logs.length === 0) return; + const events = logs.map((log) => { + const args = ( + log as unknown as { + args: { + borrower: Address; + liquidator: Address; + collateral: bigint; + debt: bigint; + reward: bigint; + }; + } + ).args; + const key = `${args.borrower}:${positionKey({ collateral: args.collateral, debt: args.debt })}`; + return { + ...args, + block: log.blockNumber ?? head, + txHash: log.transactionHash as Hex, + underwaterBlock: underwaterLogRef.current.get(key), + } satisfies LiquidationEvent; + }); + setFeed((previous) => { + const seen = new Set(); + return [...events, ...previous] + .filter((event) => { + const id = `${event.txHash}:${event.borrower}`; + if (seen.has(id)) return false; + seen.add(id); + return true; + }) + .sort((a, b) => (a.block === b.block ? 0 : a.block > b.block ? -1 : 1)) + .slice(0, MAX_FEED); + }); + } catch { + // Retry on the next tick. + } + }; + void poll(); + const id = window.setInterval(() => void poll(), LOG_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [client, market]); + + // Someone else liquidated the position we were targeting. + useEffect(() => { + const current = attemptRef.current; + if (!current || !signerAddress || (current.status !== 'armed' && current.status !== 'signing')) return; + const rival = feed.find( + (event) => + event.borrower.toLowerCase() === current.borrower.toLowerCase() && + event.liquidator.toLowerCase() !== signerAddress.toLowerCase() && + event.block >= current.submittedBlock, + ); + if (!rival) return; + setAttempt((a) => (a && a.id === current.id ? { ...a, status: 'beaten', includedBlock: rival.block } : a)); + trackValidityLiquidation(current.kind, 'beaten'); + }, [feed, signerAddress]); + + // Receipts for our attempt. + const settle = useCallback( + (id: number, status: 'success' | 'reverted', includedBlock: bigint, reward: bigint | undefined) => { + setAttempt((a) => { + if (!a || a.id !== id || (isSettled(a.status) && a.status !== 'expired')) return a; + const underwaterBlock = + a.underwaterBlock ?? underwaterLogRef.current.get(`${a.borrower}:${positionKey(a.position)}`); + return { ...a, status, includedBlock, underwaterBlock, reward }; + }); + }, + [], + ); + + useEffect(() => { + if ( + !client || + !attempt?.hash || + (attempt.status !== 'armed' && attempt.status !== 'pending' && attempt.status !== 'expired') + ) + return; + // Expired attempts get a short grace window for a receipt from the last valid block. + const { hash, id, kind } = attempt; + let cancelled = false; + const poll = () => { + void client + .getTransactionReceipt({ hash }) + .then((receipt) => { + if (cancelled) return; + const status = receipt.status === 'success' ? 'success' : 'reverted'; + const [liquidated] = parseEventLogs({ + abi: vibeLendAbi, + eventName: 'Liquidated', + logs: receipt.logs, + }); + const reward = (liquidated as { args?: { reward?: bigint } } | undefined)?.args?.reward; + settle(id, status, receipt.blockNumber, reward); + trackValidityLiquidation(kind, status); + }) + .catch(() => {}); + }; + poll(); + const intervalId = window.setInterval(poll, RECEIPT_POLL_MS); + const stopId = + attempt.status === 'expired' ? window.setTimeout(() => window.clearInterval(intervalId), 5_000) : undefined; + return () => { + cancelled = true; + window.clearInterval(intervalId); + if (stopId !== undefined) window.clearTimeout(stopId); + }; + }, [attempt?.hash, attempt?.id, attempt?.kind, attempt?.status, client, settle]); // eslint-disable-line react-hooks/exhaustive-deps + + // Reset per-account state on account switch. + const accountKeyRef = useRef(undefined); + useEffect(() => { + const key = acct?.id ?? null; + if (accountKeyRef.current === undefined) { + accountKeyRef.current = key; + return; + } + if (accountKeyRef.current === key) return; + accountKeyRef.current = key; + setAttempt(null); + setHistory([]); + setError(null); + }, [acct]); + + const head = snapshot?.block ?? null; + const canStart = Boolean(acct && client && market && signerReady && snapshot) && nonceFree(attempt, head); + + const submit = async (kind: AttemptKind, borrower: Address) => { + if (submittingRef.current || !client || !market || !canStart) return; + submittingRef.current = true; + setError(null); + try { + const [book, block] = await Promise.all([ + readBook(client, market.lending), + client.getBlockNumber({ cacheTime: 0 }), + ]); + const entry = book.entries.find((e) => e.borrower.toLowerCase() === borrower.toLowerCase()); + if (!entry) { + setError('That position is already gone. Pick another one.'); + return; + } + const position = { collateral: entry.collateral, debt: entry.debt }; + const id = ++attemptIdRef.current; + const base: LiquidationAttempt = { + id, + kind, + borrower, + position, + status: 'signing', + submittedAt: Date.now(), + submittedBlock: block, + underwaterBlock: underwaterLogRef.current.get(`${borrower}:${positionKey(position)}`), + }; + const previous = attemptRef.current; + if (previous) setHistory((h) => [previous, ...h].slice(0, MAX_HISTORY)); + + let predicates; + if (kind === 'validity') { + const maxBlock = maxBlockForExpiry(block, expiry); + const validity = liquidationValidity({ + lending: market.lending, + pair: market.pair, + vibeIsToken0: market.vibeIsToken0, + borrower, + position, + reserves: book.reserves, + maxBlock, + }); + predicates = validity.predicates; + base.maxBlock = maxBlock; + base.triggerPriceWad = validity.ceilingPriceWad; + base.predicates = predicates; + } + setAttempt(base); + setSelected(borrower); + + const serialized = await signK1Eip1559Call({ + client, + signer: engine.activeSigner, + call: encodeLiquidate(market.lending, borrower), + gas: LIQUIDATE_GAS, + }); + const hash = predicates + ? await sendValidityTransaction(serialized, predicates) + : await client.sendRawTransaction({ + serializedTransaction: serialized, + }); + setAttempt((a) => (a && a.id === id ? { ...a, hash, status: kind === 'validity' ? 'armed' : 'pending' } : a)); + trackValidityLiquidation(kind, 'submitted'); + } catch (err) { + const message = describeValidityError(err); + setAttempt((a) => (a && a.status === 'signing' ? { ...a, status: 'error', error: message } : a)); + setError(message); + trackValidityLiquidation(kind, 'error'); + } finally { + submittingRef.current = false; + } + }; + + const rows = useMemo(() => { + if (!snapshot) return []; + return snapshot.entries + .map((entry) => ({ + ...entry, + health: healthFactorWad(entry, snapshot.reserves), + liquidatable: isLiquidatable(entry, snapshot.reserves), + liquidationPrice: liquidationPriceWad(entry), + reward: liquidationReward(entry), + })) + .sort((a, b) => (a.health < b.health ? -1 : a.health > b.health ? 1 : 0)); + }, [snapshot]); + + const spot = snapshot ? spotPriceWad(snapshot.reserves) : null; + const focus = selected && rows.some((row) => row.borrower === selected) ? selected : (rows[0]?.borrower ?? null); + const focusRow = rows.find((row) => row.borrower === focus) ?? null; + const armedFor = attempt && (attempt.status === 'armed' || attempt.status === 'signing') ? attempt.borrower : null; + + const earned = totalEarned([attempt, ...history]); + const earnedByValidity = totalEarned([attempt, ...history], 'validity'); + const underwaterCount = rows.filter((row) => row.liquidatable).length; + + const previewPredicates = + attempt?.predicates ?? + (focusRow && market && snapshot + ? liquidationValidity({ + lending: market.lending, + pair: market.pair, + vibeIsToken0: market.vibeIsToken0, + borrower: focusRow.borrower, + position: focusRow, + reserves: snapshot.reserves, + maxBlock: maxBlockForExpiry(snapshot.block, expiry), + }).predicates + : null); + + const status = !market + ? setupError + ? `Shared infrastructure is not ready; retrying. ${setupError}` + : 'Waiting for the shared VibeLend market' + : !signerReady + ? (signerError ?? 'Funding your signer from the faucet') + : null; + + const yours = feed.filter((event) => signerAddress && event.liquidator.toLowerCase() === signerAddress.toLowerCase()); + const others = feed.length - yours.length; + + return ( + + + + +
+ + + + + +
+ + There is no dedicated price oracle. VibeLend reads the spot price straight from the VIBE/USDV AMM pool + reserves, which is what lets a validity predicate on the pool's storage stand in for the price check. + +
+ + +
+
+ + Your liquidator + + + Arm ahead, or react. + + + The rival keeper liquidates about three seconds after a position goes under water. A validity liquidation + is already in the sequencer's pool, so it can land in the first block the price allows. + + +
+ + Validity expiry + +
+ {LIQUIDATION_EXPIRY_OPTIONS.map((seconds) => ( + + ))} +
+
+ +
+ + +
+ {attempt && + !nonceFree(attempt, head) && + attempt.status === 'beaten' && + attempt.maxBlock !== undefined && + head !== null ? ( + + Your beaten transaction holds its nonce until block #{attempt.maxBlock.toString()} ( + {formatSeconds(attempt.maxBlock - head)}). + + ) : null} + {error ? ( + + {error} + + ) : null} +
+ +
+ {attempt ? ( + + ) : ( + + {status ?? + 'Pick a position in the book below. Arm a validity liquidation on it, then watch whether it beats the keeper.'} + + )} +
+
+
+ + +
+
+ + VibeLend book + + + Liquidatable below 80% loan-to-value. Reward: 5% of collateral. + +
+ + Sorted by health factor + +
+
+ + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + setSelected(row.borrower)} + className={cn('cursor-pointer', row.borrower === focus && 'bg-bds-blue-0 dark:bg-[#0c1222]')} + > + + + + + + + + + )) + )} + +
BorrowerCollateralDebtLiquidation priceHealthReward +
+ {snapshot + ? 'No open positions right now. Borrowers reopen within a few seconds.' + : 'Loading the book…'} +
+ {shortAddress(row.borrower)} + {row.borrower === armedFor ? armed : null} + {formatTokenAmount(row.collateral, VIBE_DECIMALS)} VIBE{formatTokenAmount(row.debt, USDV_DECIMALS)} USDV${formatPrice(row.liquidationPrice, 5)} + + {formatTokenAmount(row.reward, VIBE_DECIMALS)} VIBE + {row.liquidatable ? ( + + Under water + {underwater.get(row.borrower) && head !== null + ? ` · ${formatSeconds(head - underwater.get(row.borrower)!.block)}` + : ''} + + ) : null} +
+
+
+ +
+ +
+ + Your attempts + + + {(attempt ? 1 : 0) + history.length} total · {formatTokenAmount(earned, VIBE_DECIMALS)} VIBE earned + +
+
+ {!attempt && history.length === 0 ? ( + + No attempts yet. + + ) : ( + [attempt, ...history] + .filter((a): a is LiquidationAttempt => a !== null) + .map((a) => ) + )} +
+
+ + +
+ + Recent liquidations + + + You {yours.length} · others {others} + +
+
+ {feed.length === 0 ? ( + + No liquidations in the last two minutes. + + ) : ( + feed.map((event) => { + const mine = Boolean(signerAddress && event.liquidator.toLowerCase() === signerAddress.toLowerCase()); + const delay = blocksUnderwater(event.underwaterBlock, event.block); + return ( +
+
+ + {shortAddress(event.borrower)} by {mine ? 'you' : shortAddress(event.liquidator)} + + {formatTokenAmount(event.reward, VIBE_DECIMALS)} VIBE + + + + Block #{event.block.toLocaleString()} + {delay !== null ? ` · ${delay.toString()} blocks under water` : ''} + +
+ + {event.txHash.slice(0, 10)}… + +
+ ); + }) + )} +
+
+
+ +
+ +
+ + Advanced details + + + Contract and validity predicates + +
+ +
+
+
+ + The sequencer can compare a storage word against a constant, but it cannot divide one reserve by the + other. The pool charges no fee and no one removes liquidity, so its constant product{' '} + k never falls. That turns the price condition into a bound on the USDV + reserve alone. Once + + + usdvReserve² · collateral · 8000 < debt · 10000 · k + + + the position is liquidatable no matter how k has grown. + + + The second predicate pins the borrower's packed position word. If the keeper liquidates first, or the + borrower closes or reopens, the word changes and the sequencer never includes your transaction, so it + cannot revert and waste gas. + +
+
+ + +
+ {market ? ( + + VibeLend {market.lending} + + ) : null} +
+
+
+ ); +} + +function formatSeconds(blocks: bigint): string { + const seconds = Number(blocks) * 0.2; + return `${seconds.toFixed(1)}s`; +} + +function AttemptPanel({ attempt, head }: { attempt: LiquidationAttempt; head: bigint | null }) { + const delay = blocksUnderwater(attempt.underwaterBlock, attempt.includedBlock); + const label = + attempt.status === 'armed' + ? attempt.triggerPriceWad !== undefined + ? `Armed. Lands once the price is at or below $${formatPrice(attempt.triggerPriceWad, 5)}.` + : 'Armed.' + : attempt.status === 'signing' + ? 'Signing…' + : attempt.status === 'pending' + ? 'Sent. Waiting for a receipt.' + : attempt.status === 'success' + ? `Liquidated in block #${attempt.includedBlock?.toLocaleString()}. You earned ${formatTokenAmount(attempt.reward ?? liquidationReward(attempt.position), VIBE_DECIMALS)} VIBE.` + : attempt.status === 'beaten' + ? 'Beaten. Someone else liquidated this position first; your transaction will never be included.' + : attempt.status === 'expired' + ? 'Expired. The price never reached the position’s trigger in time.' + : attempt.status === 'reverted' + ? 'Reverted. The position was healthy again or already gone when your transaction landed.' + : (attempt.error ?? 'Failed.'); + return ( +
+
+ + {attempt.kind === 'validity' ? 'Validity liquidation' : 'Manual liquidation'} ·{' '} + {shortAddress(attempt.borrower)} + + +
+ + {label} + + {attempt.status === 'armed' && attempt.maxBlock !== undefined && head !== null ? ( + + Expires at block #{attempt.maxBlock.toString()} ( + {formatSeconds(attempt.maxBlock > head ? attempt.maxBlock - head : 0n)} left) + + ) : null} + {delay !== null && attempt.status === 'success' ? ( + + Landed {delay.toString()} blocks after the position went under water. + + ) : null} + {attempt.hash ? ( + + {attempt.hash.slice(0, 18)}… + + ) : null} +
+ ); +} + +function AttemptRow({ attempt }: { attempt: LiquidationAttempt }) { + const delay = blocksUnderwater(attempt.underwaterBlock, attempt.includedBlock); + return ( +
+
+ + {attempt.kind === 'validity' ? 'Validity' : 'Manual'} · {shortAddress(attempt.borrower)} + + + {attempt.includedBlock !== undefined + ? `Block #${attempt.includedBlock.toLocaleString()}${delay !== null && attempt.status === 'success' ? ` · ${delay.toString()} blocks under water` : ''}` + : `Submitted at block #${attempt.submittedBlock.toLocaleString()}`} + +
+
+ {attempt.status === 'success' && attempt.reward !== undefined ? ( + + +{formatTokenAmount(attempt.reward, VIBE_DECIMALS)} VIBE + + ) : null} + +
+
+ ); +} + +function HealthBar({ health, liquidatable }: { health: bigint; liquidatable: boolean }) { + const value = Number(health) / Number(WAD); + // 1.0 → empty, 1.25+ → full. + const fill = Math.max(0, Math.min(1, (value - 1) / 0.25)); + return ( +
+
+
+
+ {value.toFixed(3)} +
+ ); +} + +function StatusPill({ status }: { status: LiquidationAttempt['status'] }) { + const positive = status === 'success'; + const negative = status === 'reverted' || status === 'expired' || status === 'error' || status === 'beaten'; + return ( + + {status} + + ); +} + +function StatCell({ label, value, emphasis = false }: { label: string; value: string; emphasis?: boolean }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/app/vibenet/demos/validity/liquidations/attempts.test.ts b/app/vibenet/demos/validity/liquidations/attempts.test.ts new file mode 100644 index 00000000..f80da2a3 --- /dev/null +++ b/app/vibenet/demos/validity/liquidations/attempts.test.ts @@ -0,0 +1,85 @@ +import type { Address } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { blocksUnderwater, nonceFree, totalEarned, trackUnderwater, type LiquidationAttempt } from './attempts'; + +const A = '0x00000000000000000000000000000000000000aa' as Address; +const B = '0x00000000000000000000000000000000000000bb' as Address; + +const attempt = (patch: Partial): LiquidationAttempt => ({ + id: 1, + kind: 'validity', + borrower: A, + position: { collateral: 10n, debt: 1n }, + status: 'armed', + submittedAt: 0, + submittedBlock: 100n, + maxBlock: 175n, + ...patch, +}); + +describe('nonceFree', () => { + it('blocks a new attempt while one is in flight', () => { + expect(nonceFree(null, 100n)).toBe(true); + expect(nonceFree(attempt({ status: 'armed' }), 120n)).toBe(false); + expect(nonceFree(attempt({ status: 'pending', kind: 'manual' }), 120n)).toBe(false); + expect(nonceFree(attempt({ status: 'success' }), 120n)).toBe(true); + }); + + it('holds a beaten validity tx until its expiry block passes', () => { + const beaten = attempt({ status: 'beaten' }); + expect(nonceFree(beaten, 175n)).toBe(false); + expect(nonceFree(beaten, 176n)).toBe(true); + expect(nonceFree(beaten, null)).toBe(false); + }); +}); + +describe('trackUnderwater', () => { + const pos = { collateral: 10n, debt: 1n }; + + it('keeps the first block a position was seen liquidatable', () => { + const first = trackUnderwater(new Map(), [{ ...pos, borrower: A, liquidatable: true }], 10n); + const second = trackUnderwater(first, [{ ...pos, borrower: A, liquidatable: true }], 14n); + expect(second.get(A)?.block).toBe(10n); + }); + + it('restarts when the position recovers or is replaced', () => { + const first = trackUnderwater(new Map(), [{ ...pos, borrower: A, liquidatable: true }], 10n); + const recovered = trackUnderwater(first, [{ ...pos, borrower: A, liquidatable: false }], 12n); + expect(recovered.has(A)).toBe(false); + const replaced = trackUnderwater(first, [{ collateral: 20n, debt: 3n, borrower: A, liquidatable: true }], 15n); + expect(replaced.get(A)?.block).toBe(15n); + }); + + it('drops borrowers whose positions are gone', () => { + const first = trackUnderwater(new Map(), [{ ...pos, borrower: A, liquidatable: true }], 10n); + expect(trackUnderwater(first, [{ ...pos, borrower: B, liquidatable: true }], 11n).has(A)).toBe(false); + }); +}); + +it('blocksUnderwater never goes negative when our poll saw the position late', () => { + expect(blocksUnderwater(10n, 13n)).toBe(3n); + expect(blocksUnderwater(13n, 12n)).toBe(0n); + expect(blocksUnderwater(undefined, 12n)).toBeNull(); +}); + +it('totalEarned counts rewards only from successful attempts', () => { + expect( + totalEarned([ + attempt({ status: 'success', reward: 5n }), + attempt({ status: 'reverted', reward: 7n }), + null, + attempt({ status: 'success', reward: 11n }), + attempt({ status: 'success' }), + ]), + ).toBe(16n); +}); + +it('totalEarned can count only validity liquidations', () => { + const attempts = [ + attempt({ status: 'success', reward: 5n }), + attempt({ status: 'success', reward: 11n, kind: 'manual' }), + ]; + expect(totalEarned(attempts, 'validity')).toBe(5n); + expect(totalEarned(attempts, 'manual')).toBe(11n); +}); diff --git a/app/vibenet/demos/validity/liquidations/attempts.ts b/app/vibenet/demos/validity/liquidations/attempts.ts new file mode 100644 index 00000000..6a913ea7 --- /dev/null +++ b/app/vibenet/demos/validity/liquidations/attempts.ts @@ -0,0 +1,121 @@ +import type { Address, Hex } from 'viem'; + +import type { Position } from '../lib/lending'; +import type { ValidityPredicate } from '../lib/types'; + +export const LIQUIDATION_EXPIRY_OPTIONS = [15, 30, 60] as const; +export type LiquidationExpiry = (typeof LIQUIDATION_EXPIRY_OPTIONS)[number]; + +export type AttemptKind = 'validity' | 'manual'; + +export type AttemptStatus = + | 'signing' + /** Validity tx in the sequencer's pool, waiting for its predicates. */ + | 'armed' + /** Plain tx broadcast, waiting for a receipt. */ + | 'pending' + | 'success' + | 'reverted' + | 'expired' + /** Someone else liquidated the position first. */ + | 'beaten' + | 'error'; + +export type LiquidationAttempt = { + id: number; + kind: AttemptKind; + borrower: Address; + position: Position; + status: AttemptStatus; + submittedAt: number; + submittedBlock: bigint; + /** Validity expiry bound (inclusive). */ + maxBlock?: bigint; + hash?: Hex; + includedBlock?: bigint; + /** VIBE paid to us by the `Liquidated` event in a successful receipt. */ + reward?: bigint; + /** Block in which the position was first seen liquidatable, if observed. */ + underwaterBlock?: bigint; + /** USDV-per-VIBE (1e18) price at which the reserve predicate opens. */ + triggerPriceWad?: bigint; + predicates?: ValidityPredicate[]; + error?: string; +}; + +export type LiquidationEvent = { + borrower: Address; + liquidator: Address; + collateral: bigint; + debt: bigint; + reward: bigint; + block: bigint; + txHash: Hex; + underwaterBlock?: bigint; +}; + +const SETTLED: ReadonlySet = new Set(['success', 'reverted', 'expired', 'error']); + +export function isSettled(status: AttemptStatus): boolean { + return SETTLED.has(status); +} + +/** + * Whether the account's next nonce is free for a new attempt. A beaten + * validity tx can never land (its position predicate no longer matches), but + * the sequencer keeps it pooled until its expiry block, so a new transaction on + * the same nonce would be rejected as an underpriced replacement. + */ +export function nonceFree(attempt: LiquidationAttempt | null, head: bigint | null): boolean { + if (!attempt) return true; + if (isSettled(attempt.status)) return true; + if (attempt.status === 'beaten') { + return attempt.kind === 'manual' || (attempt.maxBlock !== undefined && head !== null && head > attempt.maxBlock); + } + return false; +} + +/** Blocks from the position going under water to the liquidation landing. */ +export function blocksUnderwater( + underwaterBlock: bigint | undefined, + includedBlock: bigint | undefined, +): bigint | null { + if (underwaterBlock === undefined || includedBlock === undefined) return null; + return includedBlock >= underwaterBlock ? includedBlock - underwaterBlock : 0n; +} + +export const positionKey = (position: Position) => `${position.collateral}:${position.debt}`; + +export type Underwater = { block: bigint; key: string }; + +/** + * Update the first-seen-liquidatable block per borrower. A position that + * recovers, or is replaced by a different position, starts over. + */ +export function trackUnderwater( + previous: ReadonlyMap, + entries: readonly (Position & { borrower: Address; liquidatable: boolean })[], + block: bigint, +): Map { + const next = new Map(); + for (const entry of entries) { + if (!entry.liquidatable) continue; + const key = positionKey(entry); + const seen = previous.get(entry.borrower); + next.set(entry.borrower, seen && seen.key === key ? seen : { block, key }); + } + return next; +} + +export function shortAddress(address: string): string { + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +/** VIBE earned across successful attempts, optionally only those of one kind. */ +export function totalEarned(attempts: readonly (LiquidationAttempt | null)[], kind?: AttemptKind): bigint { + return attempts.reduce( + (sum, a) => + a?.status === 'success' && a.reward !== undefined && (kind === undefined || a.kind === kind) ? sum + a.reward : sum, + 0n, + ); +} diff --git a/app/vibenet/demos/validity/liquidations/layout.tsx b/app/vibenet/demos/validity/liquidations/layout.tsx new file mode 100644 index 00000000..1c785d0d --- /dev/null +++ b/app/vibenet/demos/validity/liquidations/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +export const metadata: Metadata = { + title: 'Liquidations · Validity Transactions', + description: + 'Liquidate under-collateralized loans on a demo lending market before a rival keeper, with a validity transaction that waits for the price to cross.', +}; + +export default function LiquidationsLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/app/vibenet/demos/validity/liquidations/page.tsx b/app/vibenet/demos/validity/liquidations/page.tsx new file mode 100644 index 00000000..19a6a4d1 --- /dev/null +++ b/app/vibenet/demos/validity/liquidations/page.tsx @@ -0,0 +1,5 @@ +import { LiquidationsDemo } from './LiquidationsDemo'; + +export default function LiquidationsPage() { + return ; +} diff --git a/app/vibenet/demos/validity/metadata.test.ts b/app/vibenet/demos/validity/metadata.test.ts index 8c13650a..a7ab5ab3 100644 --- a/app/vibenet/demos/validity/metadata.test.ts +++ b/app/vibenet/demos/validity/metadata.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest'; import { metadata as groupMetadata } from './layout'; import { metadata as conditionalSwapsMetadata } from './conditional-swaps/layout'; import { metadata as raceTheAgentMetadata } from './race-the-agent/layout'; +import { metadata as liquidationsMetadata } from './liquidations/layout'; describe('validity route metadata', () => { it('names the group and nested demo independently', () => { expect(groupMetadata.title).toBe('Validity Transactions · Vibenet'); expect(conditionalSwapsMetadata.title).toBe('Conditional Swaps · Validity Transactions'); expect(raceTheAgentMetadata.title).toBe('Race the Agent · Validity Transactions'); + expect(liquidationsMetadata.title).toBe('Liquidations · Validity Transactions'); }); }); diff --git a/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx b/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx index 38ee9f01..cf3dadb2 100644 --- a/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx +++ b/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx @@ -27,6 +27,7 @@ import { probeConditionalWithdrawal, readConditionalWithdrawalState, } from '../lib/conditionalWithdrawal'; +import { CodeSnippet } from '../components/CodeSnippet'; import { signK1Eip1559Call } from '../lib/eip1559'; import { maxBlockForExpiry } from '../lib/orders'; import { blockExpiryPredicate } from '../lib/predicates'; @@ -81,6 +82,8 @@ contract ConditionalWithdrawal { } }`; +const RACE_TYPES = ['IERC20', 'ConditionalWithdrawal'] as const; + export function RaceTheAgentDemo() { return ( @@ -657,8 +660,8 @@ function RaceTheAgentDemoInner() { ) : null}
- - + +
@@ -743,62 +746,6 @@ function AttemptHistoryCard({ title, attempts }: { title: string; attempts: Atte ); } -type CodeLanguage = 'json' | 'solidity'; -type CodeToken = { text: string; kind: 'comment' | 'key' | 'keyword' | 'literal' | 'number' | 'plain' | 'string' | 'type' }; - -const CODE_TOKEN_CLASS: Record = { - comment: 'text-bds-gray-50 dark:text-[#7f8c98]', - key: 'text-base-blue dark:text-[#7eb8ff]', - keyword: 'text-purple-700 dark:text-[#c792ea]', - literal: 'text-bds-orange-70 dark:text-[#ff9d76]', - number: 'text-bds-orange-70 dark:text-[#f5c542]', - plain: 'text-bds-gray-80 dark:text-[#d6deeb]', - string: 'text-bds-green-70 dark:text-[#7ee0a8]', - type: 'text-base-blue dark:text-[#82aaff]', -}; - -function tokenizeCode(source: string, language: CodeLanguage): CodeToken[] { - const pattern = language === 'json' - ? /("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?|[{}[\]:,])/g - : /(\/\/[^\n]*|"(?:\\.|[^"\\])*"|\b(?:contract|interface|function|external|public|immutable|constant|returns|return|require|bool|uint256|address)\b|\b(?:IERC20|ConditionalWithdrawal)\b|\b\d+(?:\s+ether)?\b)/g; - const tokens: CodeToken[] = []; - let last = 0; - for (const hit of source.matchAll(pattern)) { - const text = hit[0]; - const index = hit.index ?? 0; - if (index > last) tokens.push({ text: source.slice(last, index), kind: 'plain' }); - let kind: CodeToken['kind'] = 'plain'; - if (language === 'json') { - if (text.startsWith('"')) kind = text.endsWith(':') ? 'key' : 'string'; - else if (text === 'true' || text === 'false' || text === 'null') kind = 'literal'; - else if (/^-?\d/.test(text)) kind = 'number'; - } else if (text.startsWith('//')) kind = 'comment'; - else if (text.startsWith('"')) kind = 'string'; - else if (/^\d/.test(text)) kind = 'number'; - else if (text === 'IERC20' || text === 'ConditionalWithdrawal') kind = 'type'; - else kind = 'keyword'; - tokens.push({ text, kind }); - last = index + text.length; - } - if (last < source.length) tokens.push({ text: source.slice(last), kind: 'plain' }); - return tokens; -} - -function CodeSnippet({ label, code, language }: { label: string; code: string; language: CodeLanguage }) { - return ( - - {label} -
-        
-          {tokenizeCode(code, language).map((token, index) => (
-            {token.text}
-          ))}
-        
-      
-
- ); -} - function Metric({ label, value }: { label: string; value: string }) { return (
diff --git a/public/AGENTS.md b/public/AGENTS.md index 334201ed..35c5145a 100644 --- a/public/AGENTS.md +++ b/public/AGENTS.md @@ -25,7 +25,7 @@ Machine-readable entry point for agents working with Base Chain network state. | /vibenet/explorer | daily | re-fetch every session; never cache across sessions | | /vibenet/faucet | monthly | stable within a session | | /api/snapshots | daily | re-fetch every session; never cache across sessions | -| /, /vibenet, /vibenet/demos/200, /vibenet/demos/account, /vibenet/demos/b20, /vibenet/demos/validity, /vibenet/demos/validity/conditional-swaps, /vibenet/demos/validity/race-the-agent | infrequent | stable within a session | +| /, /vibenet, /vibenet/demos/200, /vibenet/demos/account, /vibenet/demos/b20, /vibenet/demos/validity, /vibenet/demos/validity/conditional-swaps, /vibenet/demos/validity/liquidations, /vibenet/demos/validity/race-the-agent | infrequent | stable within a session | ## Machine-readable endpoints @@ -87,6 +87,7 @@ Discovered from the Next.js app directory. - [/vibenet/demos/b20](https://chain.base.org/vibenet/demos/b20) — Explore, configure, and issue Base-native B20 tokens on Vibenet. - [/vibenet/demos/validity](https://chain.base.org/vibenet/demos/validity) — Explore Vibenet demos built with transactions that execute only while their onchain validity conditions hold. - [/vibenet/demos/validity/conditional-swaps](https://chain.base.org/vibenet/demos/validity/conditional-swaps) — Place a validity-backed swap on Vibenet that waits for a target price, then fills or expires as the market moves. +- [/vibenet/demos/validity/liquidations](https://chain.base.org/vibenet/demos/validity/liquidations) — Liquidate under-collateralized loans on a demo lending market before a rival keeper, with a validity transaction that waits for the price to cross. - [/vibenet/demos/validity/race-the-agent](https://chain.base.org/vibenet/demos/validity/race-the-agent) — Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition. - [/vibenet/explorer](https://chain.base.org/vibenet/explorer) — Browse blocks, transactions, and addresses on the Vibenet devnet. - [/vibenet/faucet](https://chain.base.org/vibenet/faucet) — Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. diff --git a/public/llms-full.txt b/public/llms-full.txt index f4011209..664bf8a1 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -22,6 +22,7 @@ - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. - [Validity Transactions · Vibenet](https://chain.base.org/vibenet/demos/validity): Explore Vibenet demos built with transactions that execute only while their onchain validity conditions hold. - [Conditional Swaps · Validity Transactions](https://chain.base.org/vibenet/demos/validity/conditional-swaps): Place a validity-backed swap on Vibenet that waits for a target price, then fills or expires as the market moves. +- [Liquidations · Validity Transactions](https://chain.base.org/vibenet/demos/validity/liquidations): Liquidate under-collateralized loans on a demo lending market before a rival keeper, with a validity transaction that waits for the price to cross. - [Race the Agent · Validity Transactions](https://chain.base.org/vibenet/demos/validity/race-the-agent): Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition. - [Explorer · Vibenet](https://chain.base.org/vibenet/explorer): Browse blocks, transactions, and addresses on the Vibenet devnet. (changes daily; re-fetch before relying on it) - [Faucet · Vibenet](https://chain.base.org/vibenet/faucet): Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. (changes monthly; re-fetch before relying on it) diff --git a/public/llms.txt b/public/llms.txt index 79737649..045d1604 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -19,6 +19,7 @@ Freshness: /snapshots and /vibenet/explorer change daily. /vibenet/faucet change - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. - [Validity Transactions · Vibenet](https://chain.base.org/vibenet/demos/validity): Explore Vibenet demos built with transactions that execute only while their onchain validity conditions hold. - [Conditional Swaps · Validity Transactions](https://chain.base.org/vibenet/demos/validity/conditional-swaps): Place a validity-backed swap on Vibenet that waits for a target price, then fills or expires as the market moves. +- [Liquidations · Validity Transactions](https://chain.base.org/vibenet/demos/validity/liquidations): Liquidate under-collateralized loans on a demo lending market before a rival keeper, with a validity transaction that waits for the price to cross. - [Race the Agent · Validity Transactions](https://chain.base.org/vibenet/demos/validity/race-the-agent): Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition. - [Explorer · Vibenet](https://chain.base.org/vibenet/explorer): Browse blocks, transactions, and addresses on the Vibenet devnet. - [Faucet · Vibenet](https://chain.base.org/vibenet/faucet): Request testnet tokens on Vibenet to fund accounts and try in-flight Base features.