diff --git a/.github/workflows/deploy-subgraph.yaml b/.github/workflows/deploy-subgraph.yaml index 6a588e531b..2463bab54a 100644 --- a/.github/workflows/deploy-subgraph.yaml +++ b/.github/workflows/deploy-subgraph.yaml @@ -55,6 +55,8 @@ jobs: - run: > (cd lib/rain.interpreter/lib/rain.interpreter.interface/lib/rain.math.float && nix develop -c rainix-sol-prelude) + - run: > + (nix develop -c bash -c "cd lib/rain.interpreter/lib/rain.interpreter.interface/lib/forge-std && forge build") - run: nix develop -c bash -c rainix-sol-prelude diff --git a/.github/workflows/test-subgraph.yml b/.github/workflows/test-subgraph.yml index 3fd9f45595..b6cb43e83f 100644 --- a/.github/workflows/test-subgraph.yml +++ b/.github/workflows/test-subgraph.yml @@ -45,6 +45,8 @@ jobs: - run: | (cd lib/rain.interpreter/lib/rain.interpreter.interface/lib/rain.math.float && nix develop -c rainix-sol-prelude) + - run: > + (nix develop -c bash -c "cd lib/rain.interpreter/lib/rain.interpreter.interface/lib/forge-std && forge build") - name: Build subgraph run: nix develop -c subgraph-build diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index 57b09caf87..3c6b1d1d82 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -40,6 +40,13 @@ type Vault @entity { balance: Bytes! "All balance changes for this vault" balanceChanges: [VaultBalanceChange!]! @derivedFrom(field: "vault") + "reference VaultList singleton record to derive the vaults list from" + vaultList: VaultList! +} + +type VaultList @entity { + id: String! + vaults: [Vault!]! @derivedFrom(field: "vaultList") # list of all vaults } interface VaultBalanceChange { diff --git a/subgraph/src/handlers.ts b/subgraph/src/handlers.ts index a9b66da0ff..6fb77857af 100644 --- a/subgraph/src/handlers.ts +++ b/subgraph/src/handlers.ts @@ -22,6 +22,8 @@ import { } from "./clear"; import { createTransactionEntity } from "./transaction"; import { createOrderbookEntity } from "./orderbook"; +import { ethereum } from "@graphprotocol/graph-ts"; +import { handleVaultlessBalance } from "./vault"; export function handleDeposit(event: DepositV2): void { createTransactionEntity(event); @@ -70,3 +72,7 @@ export function handleAfterClear(event: AfterClearV2): void { createOrderbookEntity(event); _handleAfterClear(event); } + +export function vaultBlockHandler(_block: ethereum.Block): void { + handleVaultlessBalance(); +} \ No newline at end of file diff --git a/subgraph/src/vault.ts b/subgraph/src/vault.ts index e32ada6451..52bff9edcf 100644 --- a/subgraph/src/vault.ts +++ b/subgraph/src/vault.ts @@ -1,7 +1,13 @@ -import { Bytes, crypto } from "@graphprotocol/graph-ts"; -import { Vault } from "../generated/schema"; +import { Address, Bytes, crypto, dataSource } from "@graphprotocol/graph-ts"; +import { Vault, VaultList } from "../generated/schema"; import { getERC20Entity } from "./erc20"; import { Float, getCalculator } from "./float"; +import { ethereum } from "@graphprotocol/graph-ts" +import { Multicall3 } from "../generated/OrderBook/Multicall3"; + +export const VAULT_LIST_ID = "SINGLETON"; +export const MUTLICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"; +export const ZERO_BYTES_32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; export type VaultId = Bytes; @@ -21,14 +27,14 @@ export function createEmptyVault( vaultId: VaultId, token: Bytes ): Vault { + getVaultList(); // make sure vault list exists let vault = new Vault(vaultEntityId(orderbook, owner, vaultId, token)); vault.orderbook = orderbook; vault.vaultId = vaultId; vault.token = getERC20Entity(token); vault.owner = owner; - vault.balance = Bytes.fromHexString( - "0x0000000000000000000000000000000000000000000000000000000000000000" - ); + vault.balance = Bytes.fromHexString(ZERO_BYTES_32); + vault.vaultList = VAULT_LIST_ID; vault.save(); return vault; } @@ -70,3 +76,90 @@ export function handleVaultBalanceChange( newVaultBalance: vault.balance, }; } + +export function getVaultList(): VaultList { + let vaultList = VaultList.load(VAULT_LIST_ID); + if (!vaultList) { + vaultList = new VaultList(VAULT_LIST_ID); + vaultList.save(); + } + return vaultList; +} + +// updates vaultless for all vautless vaults using multicall +// this is used in block handler and is updated at each block +export function handleVaultlessBalance(): void { + // Get the OrderBook and multicall3 contract instance + const orderBookAddress = dataSource.address() + const multicall3 = Multicall3.bind(Address.fromString(MUTLICALL3_ADDRESS)); + + // Load all vaults from the store + const BATCH_SIZE = 1000; + const vaultList = getVaultList().vaults.load(); + const vaultlessVaultsBatch: Vault[][] = [[]]; + + for (let i = 0; i < vaultList.length; i++) { + const vault = vaultList[i]; + + // skip non vautless vaults + if (vault.vaultId.notEqual(Bytes.fromHexString(ZERO_BYTES_32))) continue; + + if (vaultlessVaultsBatch[vaultlessVaultsBatch.length - 1].length < BATCH_SIZE) { + vaultlessVaultsBatch[vaultlessVaultsBatch.length - 1].push(vault) + } else { + vaultlessVaultsBatch.push([vault]); + } + } + + // Batch calls using tryAggregate for better performance + const batchCalls: ethereum.Tuple[][] = []; + for (let i = 0; i < vaultlessVaultsBatch.length; i++) { + const vaultlessVaults = vaultlessVaultsBatch[i]; + const calls: ethereum.Tuple[] = []; + for (let j = 0; j < vaultlessVaults.length; j++) { + let vault = vaultlessVaults[j]; + + // Encode vaultBalance2(address owner, address token, bytes32 vaultId) call + const callData = ethereum.encode( + ethereum.Value.fromTuple( + changetype([ + ethereum.Value.fromAddress(Address.fromBytes(vault.owner)), + ethereum.Value.fromAddress(Address.fromBytes(vault.token)), + ethereum.Value.fromFixedBytes(vault.vaultId) + ]) + ) + )!; + const call3 = changetype([ + ethereum.Value.fromAddress(orderBookAddress), + ethereum.Value.fromBoolean(true), // allowFailure = true + ethereum.Value.fromBytes(callData) + ]); + calls.push(call3); + } + batchCalls.push(calls); + } + + for (let i = 0; i < batchCalls.length; i++) { + // Make multicall + const result = multicall3.tryCall( + "aggregate3", + "aggregate3((address,bool,bytes)[]):((bool,bytes)[])", + [ethereum.Value.fromTupleArray(batchCalls[i])] + ); + + if (result.reverted) continue; + const results = result.value[0].toTupleArray(); + if (results.length !== vaultlessVaultsBatch[i].length) continue; + + for (let j = 0; j < vaultlessVaultsBatch[i].length; j++) { + const success = results[j][0].toBoolean(); + const returnData = results[j][1].toBytes(); + if (!success) continue; + const decoded = ethereum.decode('bytes32', returnData); + if (decoded) { + vaultlessVaultsBatch[i][j].balance = decoded.toBytes(); + vaultlessVaultsBatch[i][j].save(); + } + } + } +} diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml index f627ea243d..d1820203df 100644 --- a/subgraph/subgraph.yaml +++ b/subgraph/subgraph.yaml @@ -24,6 +24,8 @@ dataSources: file: ../out/ERC20.sol/ERC20.json - name: DecimalFloat file: ../lib/rain.interpreter/lib/rain.interpreter.interface/lib/rain.math.float/out/DecimalFloat.sol/DecimalFloat.json + - name: Multicall3 + file: ../lib/rain.interpreter/lib/rain.interpreter.interface/lib/forge-std/out/IMulticall3.sol/IMulticall3.json eventHandlers: - event: DepositV2(address,address,bytes32,uint256) handler: handleDeposit @@ -41,4 +43,6 @@ dataSources: handler: handleClear - event: AfterClearV2(address,(bytes32,bytes32,bytes32,bytes32)) handler: handleAfterClear + blockHandlers: + - handler: vaultBlockHandler file: ./src/handlers.ts diff --git a/subgraph/tests/vault.test.ts b/subgraph/tests/vault.test.ts index a4b8a11905..a5791ef431 100644 --- a/subgraph/tests/vault.test.ts +++ b/subgraph/tests/vault.test.ts @@ -6,9 +6,18 @@ import { afterEach, clearInBlockStore, beforeEach, + createMockedFunction, + dataSourceMock, } from "matchstick-as"; -import { handleVaultBalanceChange, vaultEntityId } from "../src/vault"; -import { Bytes, BigInt, Address } from "@graphprotocol/graph-ts"; +import { + createEmptyVault, + handleVaultBalanceChange, + handleVaultlessBalance, + MUTLICALL3_ADDRESS, + vaultEntityId, + ZERO_BYTES_32 +} from "../src/vault"; +import { Bytes, BigInt, Address, ethereum } from "@graphprotocol/graph-ts"; import { createDepositEvent, createWithdrawEvent } from "./event-mocks.test"; import { createMockERC20Functions } from "./erc20.test"; import { @@ -266,3 +275,252 @@ describe("Vault balance changes", () => { assert.bytesEquals(balanceChange.oldVaultBalance, FLOAT_100); }); }); + +describe("Vaultless balance updates", () => { + beforeEach(createMockDecimalFloatFunctions); + + afterEach(() => { + clearStore(); + clearInBlockStore(); + }); + + test("handleVaultlessBalance() updates balances for vaultless vaults", () => { + let token1 = "0x1111111111111111111111111111111111111111"; + let token2 = "0x2222222222222222222222222222222222222222"; + createMockERC20Functions(Address.fromString(token1)); + createMockERC20Functions(Address.fromString(token2)); + + let owner1 = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let owner2 = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let orderbook = Address.fromString("0xcccccccccccccccccccccccccccccccccccccccc"); + dataSourceMock.setAddress("0xcccccccccccccccccccccccccccccccccccccccc"); + + // Create vaultless vaults (vaultId = 0x00...00) + createEmptyVault( + orderbook, + Bytes.fromHexString(owner1), + Bytes.fromHexString(ZERO_BYTES_32), + Bytes.fromHexString(token1), + ); + createEmptyVault( + orderbook, + Bytes.fromHexString(owner2), + Bytes.fromHexString(ZERO_BYTES_32), + Bytes.fromHexString(token2), + ); + + // mock multicall vaultBalance2() calls + const call1 = changetype([ + ethereum.Value.fromAddress(orderbook), + ethereum.Value.fromBoolean(true), + ethereum.Value.fromBytes( + ethereum.encode( + ethereum.Value.fromTuple( + changetype([ + ethereum.Value.fromAddress(Address.fromString(owner1)), + ethereum.Value.fromAddress(Address.fromString(token1)), + ethereum.Value.fromFixedBytes(Bytes.fromHexString(ZERO_BYTES_32)) + ]) + ) + )! + ) + ]); + const call2 = changetype([ + ethereum.Value.fromAddress(orderbook), + ethereum.Value.fromBoolean(true), + ethereum.Value.fromBytes( + ethereum.encode( + ethereum.Value.fromTuple( + changetype([ + ethereum.Value.fromAddress(Address.fromString(owner2)), + ethereum.Value.fromAddress(Address.fromString(token2)), + ethereum.Value.fromFixedBytes(Bytes.fromHexString(ZERO_BYTES_32)) + ]) + ) + )! + ) + ]); + + // Mock multicall3 aggregate3 response + // Result format: ((bool success, bytes returnData)[]) + let result1 = changetype([ + ethereum.Value.fromBoolean(true), + ethereum.Value.fromBytes(FLOAT_100) + ]); + let result2 = changetype([ + ethereum.Value.fromBoolean(true), + ethereum.Value.fromBytes(FLOAT_200) + ]); + + createMockedFunction( + Address.fromString(MUTLICALL3_ADDRESS), + "aggregate3", + "aggregate3((address,bool,bytes)[]):((bool,bytes)[])" + ) + .withArgs([ethereum.Value.fromTupleArray([ + call1, + call2, + ])]) + .returns([ethereum.Value.fromTupleArray([result1, result2])]); + createMockedFunction( + Address.fromString(MUTLICALL3_ADDRESS), + "aggregate3", + "aggregate3((address,bool,bytes)[]):((bool,bytes)[])" + ) + .withArgs([ethereum.Value.fromTupleArray([ + call2, + call1, + ])]) + .returns([ethereum.Value.fromTupleArray([result2, result1])]); + + // Execute + handleVaultlessBalance(); + + // Verify balances were updated + let vault1Id = vaultEntityId( + orderbook, + Bytes.fromHexString(owner1), + Bytes.fromHexString(ZERO_BYTES_32), + Bytes.fromHexString(token1) + ); + let vault2Id = vaultEntityId( + orderbook, + Bytes.fromHexString(owner2), + Bytes.fromHexString(ZERO_BYTES_32), + Bytes.fromHexString(token2) + ); + + assert.fieldEquals( + "Vault", + vault1Id.toHexString(), + "balance", + FLOAT_100.toHexString() + ); + assert.fieldEquals( + "Vault", + vault2Id.toHexString(), + "balance", + FLOAT_200.toHexString() + ); + }); + + test("handleVaultlessBalance() ignores non-vaultless vaults", () => { + let token = "0x1111111111111111111111111111111111111111"; + createMockERC20Functions(Address.fromString(token)); + + let owner = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let orderbook = Address.fromString("0xcccccccccccccccccccccccccccccccccccccccc"); + let nonZeroVaultId = "0x1111111111111111111111111111111111111111111111111111111111111111"; + dataSourceMock.setAddress("0xcccccccccccccccccccccccccccccccccccccccc"); + + // Create a non-vaultless vault + const vault =createEmptyVault( + orderbook, + Bytes.fromHexString(owner), + Bytes.fromHexString(nonZeroVaultId), + Bytes.fromHexString(token), + ); + vault.balance = FLOAT_100; + vault.save(); + + // Mock empty multicall response since no vaultless vaults exist + createMockedFunction( + Address.fromString(MUTLICALL3_ADDRESS), + "aggregate3", + "aggregate3((address,bool,bytes)[]):((bool,bytes)[])" + ) + .withArgs([ethereum.Value.fromTupleArray([])]) + .returns([ethereum.Value.fromTupleArray([])]); + + handleVaultlessBalance(); + + // Balance should remain unchanged + let vaultId = vaultEntityId( + orderbook, + Bytes.fromHexString(owner), + Bytes.fromHexString(nonZeroVaultId), + Bytes.fromHexString(token) + ); + + assert.fieldEquals( + "Vault", + vaultId.toHexString(), + "balance", + FLOAT_100.toHexString() + ); + }); + + test("handleVaultlessBalance() handles multicall failures gracefully", () => { + let token = "0x1111111111111111111111111111111111111111"; + createMockERC20Functions(Address.fromString(token)); + + let owner = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let orderbook = Address.fromString("0xcccccccccccccccccccccccccccccccccccccccc"); + dataSourceMock.setAddress("0xcccccccccccccccccccccccccccccccccccccccc"); + + // Create vaultless vault + const vault = createEmptyVault( + orderbook, + Bytes.fromHexString(owner), + Bytes.fromHexString(ZERO_BYTES_32), + Bytes.fromHexString(token), + ); + vault.balance = FLOAT_100; + vault.save(); + + // mock multicall vaultBalance2() calls + const call = changetype([ + ethereum.Value.fromAddress(orderbook), + ethereum.Value.fromBoolean(true), + ethereum.Value.fromBytes( + ethereum.encode( + ethereum.Value.fromTuple( + changetype([ + ethereum.Value.fromAddress(Address.fromString(owner)), + ethereum.Value.fromAddress(Address.fromString(token)), + ethereum.Value.fromFixedBytes(Bytes.fromHexString(ZERO_BYTES_32)) + ]) + ) + )! + ) + ]); + + // Mock failed result + let failedResult = changetype([ + ethereum.Value.fromBoolean(false), + ethereum.Value.fromBytes(Bytes.fromHexString("0x")) + ]); + + createMockedFunction( + Address.fromString(MUTLICALL3_ADDRESS), + "aggregate3", + "aggregate3((address,bool,bytes)[]):((bool,bytes)[])" + ) + .withArgs([ethereum.Value.fromTupleArray([call])]) + .returns([ethereum.Value.fromTupleArray([failedResult])]); + + handleVaultlessBalance(); + + // Balance should remain unchanged on failure + let vaultId = vaultEntityId( + orderbook, + Bytes.fromHexString(owner), + Bytes.fromHexString(ZERO_BYTES_32), + Bytes.fromHexString(token) + ); + + assert.fieldEquals( + "Vault", + vaultId.toHexString(), + "balance", + FLOAT_100.toHexString() + ); + }); + + test("handleVaultlessBalance() handles empty vault list", () => { + // No vaults created - should not error + handleVaultlessBalance(); + + assert.entityCount("Vault", 0); + }); +}); diff --git a/subgraph/tsconfig.json b/subgraph/tsconfig.json new file mode 100644 index 0000000000..4e866720d0 --- /dev/null +++ b/subgraph/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@graphprotocol/graph-ts/types/tsconfig.base.json", + "include": ["src", "tests"] +}