diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a71e982..30efead 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.25.6" + go-version-file: go.mod + cache-dependency-path: go.sum - name: Run Go tests run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b628f4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Intermediate contract artifacts (generated by Makefile) +custody/*.abi +custody/*.bin diff --git a/.gitmodules b/.gitmodules index 0c03bd8..13c20a5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ url = https://github.com/foundry-rs/forge-std [submodule "contracts/evm/lib/openzeppelin-contracts"] path = contracts/evm/lib/openzeppelin-contracts - url = https://github.com/OpenZeppelin/openzeppelin-contracts.git + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..514ce0d --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +SOL_SOURCES := $(shell find contracts/evm/src -name '*.sol') +BINDINGS := custody/iwithdraw.go custody/ideposit.go custody/simple_custody.go + +.PHONY: generate +generate: $(BINDINGS) + +# Sentinel tracks forge build; only re-runs when .sol sources change. +contracts/evm/out/.build-sentinel: $(SOL_SOURCES) + cd contracts/evm && forge build + @touch $@ + +custody/iwithdraw.go: contracts/evm/out/.build-sentinel + jq .abi contracts/evm/out/IWithdraw.sol/IWithdraw.json > custody/IWithdraw.abi + abigen --abi custody/IWithdraw.abi --pkg custody --type IWithdraw --out $@ + +custody/ideposit.go: contracts/evm/out/.build-sentinel + jq .abi contracts/evm/out/IDeposit.sol/IDeposit.json > custody/IDeposit.abi + abigen --abi custody/IDeposit.abi --pkg custody --type IDeposit --out $@ + +custody/simple_custody.go: contracts/evm/out/.build-sentinel + jq .abi contracts/evm/out/SimpleCustody.sol/SimpleCustody.json > custody/SimpleCustody.abi + jq -r .bytecode.object contracts/evm/out/SimpleCustody.sol/SimpleCustody.json > custody/SimpleCustody.bin + abigen --abi custody/SimpleCustody.abi --bin custody/SimpleCustody.bin --pkg custody --type SimpleCustody --out $@ + diff --git a/chain/IWithdraw.abi b/chain/IWithdraw.abi deleted file mode 100644 index 08fc672..0000000 --- a/chain/IWithdraw.abi +++ /dev/null @@ -1 +0,0 @@ -[{"type":"function","name":"finalizeWithdraw","inputs":[{"name":"withdrawalId","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"rejectWithdraw","inputs":[{"name":"withdrawalId","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"startWithdraw","inputs":[{"name":"user","type":"address","internalType":"address"},{"name":"token","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"},{"name":"nonce","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"withdrawalId","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"},{"type":"event","name":"WithdrawFinalized","inputs":[{"name":"withdrawalId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"success","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"WithdrawStarted","inputs":[{"name":"withdrawalId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"user","type":"address","indexed":true,"internalType":"address"},{"name":"token","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"nonce","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"error","name":"ETHTransferFailed","inputs":[]},{"type":"error","name":"InsufficientLiquidity","inputs":[]},{"type":"error","name":"WithdrawalAlreadyExists","inputs":[]},{"type":"error","name":"WithdrawalAlreadyFinalized","inputs":[]},{"type":"error","name":"WithdrawalNotFound","inputs":[]},{"type":"error","name":"ZeroAmount","inputs":[]}] diff --git a/chain/README.md b/chain/README.md deleted file mode 100644 index cdf275b..0000000 --- a/chain/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# chain - -The `chain` package provides Go bindings and a high-level listener for the `ICustody` smart contract events. - -## Listener - -The `Listener` struct allows you to subscribe to contract events and wait for a specified number of block confirmations before processing them. This is crucial for ensuring that your application only acts on finalized transactions, mitigating the risk of chain reorgs. - -### Usage - -```go -package main - -import ( - "context" - "log" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/layer-3/nitewatch/chain" -) - -func main() { - // 1. Connect to an Ethereum node (must support subscriptions, e.g., via WebSocket) - client, err := ethclient.Dial("ws://127.0.0.1:8545") - if err != nil { - log.Fatalf("Failed to connect to the Ethereum client: %v", err) - } - - // 2. Create a new Listener - // address: The deployed ICustody contract address - // confirmations: Number of blocks to wait for confirmation (e.g., 12 for mainnet) - contractAddress := common.HexToAddress("0x...") - listener, err := chain.NewListener(client, contractAddress, 12) - if err != nil { - log.Fatalf("Failed to create listener: %v", err) - } - - // 3. Subscribe to Deposited events - ctx := context.Background() - deposits := make(chan *chain.ICustodyDeposited) - - go func() { - if err := listener.WatchDeposited(ctx, deposits); err != nil { - log.Printf("WatchDeposited error: %v", err) - } - }() - - // 4. Process confirmed events - for event := range deposits { - log.Printf("Confirmed deposit: User=%s Token=%s Amount=%s Block=%d", - event.User.Hex(), event.Token.Hex(), event.Amount.String(), event.Raw.BlockNumber) - } -} -``` - -## Confirmations - -The `confirmations` parameter in `NewListener` determines how many blocks must be mined on top of the block containing the event before it is emitted. - -- `confirmations = 0`: Events are emitted immediately upon detection. Reorgs are not handled (removed events are ignored). -- `confirmations > 0`: Events are buffered until the chain reaches the required depth. If a reorg occurs and the event is removed from the canonical chain during the confirmation period, it is discarded and never emitted. diff --git a/chain/SimpleCustody.abi b/chain/SimpleCustody.abi deleted file mode 100644 index c1c44f0..0000000 --- a/chain/SimpleCustody.abi +++ /dev/null @@ -1,528 +0,0 @@ -[ - { - "type": "constructor", - "inputs": [ - { - "name": "admin", - "type": "address", - "internalType": "address" - }, - { - "name": "neodax", - "type": "address", - "internalType": "address" - }, - { - "name": "nitewatch", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "DEFAULT_ADMIN_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "NEODAX_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "NITEWATCH_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "deposit", - "inputs": [ - { - "name": "token", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "payable" - }, - { - "type": "function", - "name": "finalizeWithdraw", - "inputs": [ - { - "name": "withdrawalId", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "getRoleAdmin", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "grantRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "hasRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "rejectWithdraw", - "inputs": [ - { - "name": "withdrawalId", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "renounceRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "callerConfirmation", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "revokeRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "startWithdraw", - "inputs": [ - { - "name": "user", - "type": "address", - "internalType": "address" - }, - { - "name": "token", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "nonce", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "withdrawalId", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "supportsInterface", - "inputs": [ - { - "name": "interfaceId", - "type": "bytes4", - "internalType": "bytes4" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "withdrawals", - "inputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "user", - "type": "address", - "internalType": "address" - }, - { - "name": "token", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "exists", - "type": "bool", - "internalType": "bool" - }, - { - "name": "finalized", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "event", - "name": "Deposited", - "inputs": [ - { - "name": "user", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "token", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleAdminChanged", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "previousAdminRole", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "newAdminRole", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleGranted", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "sender", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleRevoked", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "sender", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "WithdrawFinalized", - "inputs": [ - { - "name": "withdrawalId", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "success", - "type": "bool", - "indexed": false, - "internalType": "bool" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "WithdrawStarted", - "inputs": [ - { - "name": "withdrawalId", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "user", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "token", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - }, - { - "name": "nonce", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "error", - "name": "AccessControlBadConfirmation", - "inputs": [] - }, - { - "type": "error", - "name": "AccessControlUnauthorizedAccount", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - }, - { - "name": "neededRole", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "ETHTransferFailed", - "inputs": [] - }, - { - "type": "error", - "name": "InsufficientLiquidity", - "inputs": [] - }, - { - "type": "error", - "name": "MsgValueMismatch", - "inputs": [] - }, - { - "type": "error", - "name": "NonZeroMsgValueForERC20", - "inputs": [] - }, - { - "type": "error", - "name": "ReentrancyGuardReentrantCall", - "inputs": [] - }, - { - "type": "error", - "name": "SafeERC20FailedOperation", - "inputs": [ - { - "name": "token", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "WithdrawalAlreadyExists", - "inputs": [] - }, - { - "type": "error", - "name": "WithdrawalAlreadyFinalized", - "inputs": [] - }, - { - "type": "error", - "name": "WithdrawalNotFound", - "inputs": [] - }, - { - "type": "error", - "name": "ZeroAmount", - "inputs": [] - } -] diff --git a/chain/SimpleCustody.bin b/chain/SimpleCustody.bin deleted file mode 100644 index 03059da..0000000 --- a/chain/SimpleCustody.bin +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561000f575f5ffd5b50604051611d9c380380611d9c833981810160405281019061003191906102c1565b600161004f6100446100d260201b60201c565b6100fb60201b60201c565b5f01819055506100675f5f1b8461010460201b60201c565b506100987f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f38361010460201b60201c565b506100c97ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa8261010460201b60201c565b50505050610311565b5f7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005f1b905090565b5f819050919050565b5f61011583836101f960201b60201c565b6101ef5760015f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff02191690831515021790555061018c61025c60201b60201c565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a4600190506101f3565b5f90505b92915050565b5f5f5f8481526020019081526020015f205f015f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff16905092915050565b5f33905090565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61029082610267565b9050919050565b6102a081610286565b81146102aa575f5ffd5b50565b5f815190506102bb81610297565b92915050565b5f5f5f606084860312156102d8576102d7610263565b5b5f6102e5868287016102ad565b93505060206102f6868287016102ad565b9250506040610307868287016102ad565b9150509250925092565b611a7e8061031e5f395ff3fe6080604052600436106100dc575f3560e01c80635a98c2231161007e578063d547741f11610058578063d547741f146102a4578063d87e1f41146102cc578063da86f31514610308578063efbf64a714610332576100dc565b80635a98c2231461021457806391d148541461023e578063a217fddf1461027a576100dc565b8063248a9ca3116100ba578063248a9ca31461016c5780632f2ff15d146101a857806336568abe146101d057806347e7ef24146101f8576100dc565b806301ffc9a7146100e057806305e95be71461011c57806311edc78f14610144575b5f5ffd5b3480156100eb575f5ffd5b50610106600480360381019061010191906115c9565b610372565b604051610113919061160e565b60405180910390f35b348015610127575f5ffd5b50610142600480360381019061013d919061165a565b6103eb565b005b34801561014f575f5ffd5b5061016a6004803603810190610165919061165a565b6107f7565b005b348015610177575f5ffd5b50610192600480360381019061018d919061165a565b61092f565b60405161019f9190611694565b60405180910390f35b3480156101b3575f5ffd5b506101ce60048036038101906101c99190611707565b61094b565b005b3480156101db575f5ffd5b506101f660048036038101906101f19190611707565b61096d565b005b610212600480360381019061020d9190611778565b6109e8565b005b34801561021f575f5ffd5b50610228610c78565b6040516102359190611694565b60405180910390f35b348015610249575f5ffd5b50610264600480360381019061025f9190611707565b610c9c565b604051610271919061160e565b60405180910390f35b348015610285575f5ffd5b5061028e610cff565b60405161029b9190611694565b60405180910390f35b3480156102af575f5ffd5b506102ca60048036038101906102c59190611707565b610d05565b005b3480156102d7575f5ffd5b506102f260048036038101906102ed91906117b6565b610d27565b6040516102ff9190611694565b60405180910390f35b348015610313575f5ffd5b5061031c610fd6565b6040516103299190611694565b60405180910390f35b34801561033d575f5ffd5b506103586004803603810190610353919061165a565b610ffa565b604051610369959493929190611838565b60405180910390f35b5f7f7965db0b000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614806103e457506103e382611083565b5b9050919050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa610415816110ec565b61041d611100565b5f60015f8481526020019081526020015f209050806003015f9054906101000a900460ff16610478576040517f8d0fc1dd00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060030160019054906101000a900460ff16156104c1576040517fae89945400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60018160030160016101000a81548160ff0219169083151502179055505f815f015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505f826001015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505f836002015490505f845f015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f846001015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f84600201819055505f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036106d1578047101561062c576040517fbb55fd2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8373ffffffffffffffffffffffffffffffffffffffff1682604051610651906118b6565b5f6040518083038185875af1925050503d805f811461068b576040519150601f19603f3d011682016040523d82523d5f602084013e610690565b606091505b50509050806106cb576040517fb12d13eb00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b506107ae565b808273ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b815260040161070b91906118ca565b602060405180830381865afa158015610726573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061074a91906118f7565b1015610782576040517fbb55fd2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6107ad83828473ffffffffffffffffffffffffffffffffffffffff166111229092919063ffffffff16565b5b857f150e5422471a0e0b0bf81bb0c466ec4b78850d2feeea6955c7e5eb33468a9c9c60016040516107df919061160e565b60405180910390a2505050506107f3611175565b5050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa610821816110ec565b610829611100565b5f60015f8481526020019081526020015f209050806003015f9054906101000a900460ff16610884576040517f8d0fc1dd00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060030160019054906101000a900460ff16156108cd576040517fae89945400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60018160030160016101000a81548160ff021916908315150217905550827f150e5422471a0e0b0bf81bb0c466ec4b78850d2feeea6955c7e5eb33468a9c9c5f60405161091a919061160e565b60405180910390a25061092b611175565b5050565b5f5f5f8381526020019081526020015f20600101549050919050565b6109548261092f565b61095d816110ec565b610967838361118f565b50505050565b610975611278565b73ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146109d9576040517f6697b23200000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6109e3828261127f565b505050565b6109f0611100565b5f8103610a29576040517f1f2a200500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8190505f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610a9e57813414610a99576040517fbc6f88c500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b610c06565b5f3414610ad7576040517fa57ec87300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8373ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b8152600401610b1191906118ca565b602060405180830381865afa158015610b2c573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b5091906118f7565b9050610b7f3330858773ffffffffffffffffffffffffffffffffffffffff16611368909392919063ffffffff16565b808473ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b8152600401610bb991906118ca565b602060405180830381865afa158015610bd4573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610bf891906118f7565b610c02919061194f565b9150505b8273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a783604051610c639190611982565b60405180910390a350610c74611175565b5050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa81565b5f5f5f8481526020019081526020015f205f015f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff16905092915050565b5f5f1b81565b610d0e8261092f565b610d17816110ec565b610d21838361127f565b50505050565b5f7f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f3610d52816110ec565b610d5a611100565b5f8403610d93576040517f1f2a200500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b463087878787604051602001610dae9695949392919061199b565b60405160208183030381529060405280519060200120915060015f8381526020019081526020015f206003015f9054906101000a900460ff1615610e1e576040517f157c65e100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6040518060a001604052808773ffffffffffffffffffffffffffffffffffffffff1681526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018581526020016001151581526020015f151581525060015f8481526020019081526020015f205f820151815f015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506020820151816001015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550604082015181600201556060820151816003015f6101000a81548160ff02191690831515021790555060808201518160030160016101000a81548160ff0219169083151502179055509050508473ffffffffffffffffffffffffffffffffffffffff168673ffffffffffffffffffffffffffffffffffffffff16837f669c87d38156449c65caf07041b1568372d50fc03f2cc46add1d68cebc2eb9898787604051610fbd9291906119fa565b60405180910390a4610fcd611175565b50949350505050565b7f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f381565b6001602052805f5260405f205f91509050805f015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690806001015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690806002015490806003015f9054906101000a900460ff16908060030160019054906101000a900460ff16905085565b5f7f01ffc9a7000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916149050919050565b6110fd816110f8611278565b6113bd565b50565b61110861140e565b600261111a61111561144f565b611478565b5f0181905550565b61112f8383836001611481565b61117057826040517f5274afe700000000000000000000000000000000000000000000000000000000815260040161116791906118ca565b60405180910390fd5b505050565b600161118761118261144f565b611478565b5f0181905550565b5f61119a8383610c9c565b61126e5760015f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff02191690831515021790555061120b611278565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a460019050611272565b5f90505b92915050565b5f33905090565b5f61128a8383610c9c565b1561135e575f5f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff0219169083151502179055506112fb611278565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b60405160405180910390a460019050611362565b5f90505b92915050565b6113768484848460016114e3565b6113b757836040517f5274afe70000000000000000000000000000000000000000000000000000000081526004016113ae91906118ca565b60405180910390fd5b50505050565b6113c78282610c9c565b61140a5780826040517fe2517d3f000000000000000000000000000000000000000000000000000000008152600401611401929190611a21565b60405180910390fd5b5050565b611416611554565b1561144d576040517f3ee5aeb500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b565b5f7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005f1b905090565b5f819050919050565b5f5f63a9059cbb60e01b9050604051815f525f1960601c86166004528460245260205f60445f5f8b5af1925060015f511483166114d55783831516156114c9573d5f823e3d81fd5b5f873b113d1516831692505b806040525050949350505050565b5f5f6323b872dd60e01b9050604051815f525f1960601c87166004525f1960601c86166024528460445260205f60645f5f8c5af1925060015f51148316611541578383151615611535573d5f823e3d81fd5b5f883b113d1516831692505b806040525f606052505095945050505050565b5f600261156761156261144f565b611478565b5f015414905090565b5f5ffd5b5f7fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6115a881611574565b81146115b2575f5ffd5b50565b5f813590506115c38161159f565b92915050565b5f602082840312156115de576115dd611570565b5b5f6115eb848285016115b5565b91505092915050565b5f8115159050919050565b611608816115f4565b82525050565b5f6020820190506116215f8301846115ff565b92915050565b5f819050919050565b61163981611627565b8114611643575f5ffd5b50565b5f8135905061165481611630565b92915050565b5f6020828403121561166f5761166e611570565b5b5f61167c84828501611646565b91505092915050565b61168e81611627565b82525050565b5f6020820190506116a75f830184611685565b92915050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6116d6826116ad565b9050919050565b6116e6816116cc565b81146116f0575f5ffd5b50565b5f81359050611701816116dd565b92915050565b5f5f6040838503121561171d5761171c611570565b5b5f61172a85828601611646565b925050602061173b858286016116f3565b9150509250929050565b5f819050919050565b61175781611745565b8114611761575f5ffd5b50565b5f813590506117728161174e565b92915050565b5f5f6040838503121561178e5761178d611570565b5b5f61179b858286016116f3565b92505060206117ac85828601611764565b9150509250929050565b5f5f5f5f608085870312156117ce576117cd611570565b5b5f6117db878288016116f3565b94505060206117ec878288016116f3565b93505060406117fd87828801611764565b925050606061180e87828801611764565b91505092959194509250565b611823816116cc565b82525050565b61183281611745565b82525050565b5f60a08201905061184b5f83018861181a565b611858602083018761181a565b6118656040830186611829565b61187260608301856115ff565b61187f60808301846115ff565b9695505050505050565b5f81905092915050565b50565b5f6118a15f83611889565b91506118ac82611893565b5f82019050919050565b5f6118c082611896565b9150819050919050565b5f6020820190506118dd5f83018461181a565b92915050565b5f815190506118f18161174e565b92915050565b5f6020828403121561190c5761190b611570565b5b5f611919848285016118e3565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61195982611745565b915061196483611745565b925082820390508181111561197c5761197b611922565b5b92915050565b5f6020820190506119955f830184611829565b92915050565b5f60c0820190506119ae5f830189611829565b6119bb602083018861181a565b6119c8604083018761181a565b6119d5606083018661181a565b6119e26080830185611829565b6119ef60a0830184611829565b979650505050505050565b5f604082019050611a0d5f830185611829565b611a1a6020830184611829565b9392505050565b5f604082019050611a345f83018561181a565b611a416020830184611685565b939250505056fea2646970667358221220675434c4bbddcb90fe2fd6368a14981a2f3c3f613b72899e2626a1e12ecf1b0664736f6c634300081e0033 diff --git a/chain/gen.go b/chain/gen.go deleted file mode 100644 index 185843a..0000000 --- a/chain/gen.go +++ /dev/null @@ -1,4 +0,0 @@ -package chain - -//go:generate sh -c "jq .abi ../contracts/evm/out/IWithdraw.sol/IWithdraw.json > IWithdraw.abi && abigen --abi IWithdraw.abi --pkg chain --type IWithdraw --out iwithdraw.go" -//go:generate sh -c "jq .abi ../contracts/evm/out/SimpleCustody.sol/SimpleCustody.json > SimpleCustody.abi && jq -r .bytecode.object ../contracts/evm/out/SimpleCustody.sol/SimpleCustody.json > SimpleCustody.bin && abigen --abi SimpleCustody.abi --bin SimpleCustody.bin --pkg chain --type SimpleCustody --out simple_custody.go" diff --git a/chain/listener.go b/chain/listener.go deleted file mode 100644 index b6e942b..0000000 --- a/chain/listener.go +++ /dev/null @@ -1,199 +0,0 @@ -package chain - -import ( - "context" - "fmt" - - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/core/types" - - nw "github.com/layer-3/nitewatch" -) - -// HeadSubscriber abstracts the ability to subscribe to new block headers. -// *ethclient.Client satisfies this interface. -type HeadSubscriber interface { - SubscribeNewHead(ctx context.Context, ch chan<- *types.Header) (ethereum.Subscription, error) -} - -// Listener handles monitoring the blockchain for events from the IWithdraw contract. -type Listener struct { - headSub HeadSubscriber - withdraw *IWithdraw - confirmations uint64 -} - -// NewListener creates a new Listener instance. -// headSub: a client supporting header subscriptions (e.g. *ethclient.Client via WebSocket) -// withdraw: bound IWithdraw contract instance -// confirmations: number of block confirmations required before an event is considered final -func NewListener(headSub HeadSubscriber, withdraw *IWithdraw, confirmations uint64) *Listener { - return &Listener{ - headSub: headSub, - withdraw: withdraw, - confirmations: confirmations, - } -} - -// Compile-time check that Listener implements nw.EventListener. -var _ nw.EventListener = (*Listener)(nil) - -// WatchWithdrawStarted subscribes to WithdrawStarted events and sends confirmed domain events to the sink channel. -func (l *Listener) WatchWithdrawStarted(ctx context.Context, sink chan<- *nw.WithdrawStartedEvent) error { - raw := make(chan *IWithdrawWithdrawStarted) - - errCh := make(chan error, 1) - go func() { - errCh <- watchWithConfirmations(ctx, raw, l.confirmations, - func(rawSink chan<- *IWithdrawWithdrawStarted) (ethereum.Subscription, error) { - return l.withdraw.WatchWithdrawStarted(&bind.WatchOpts{Context: ctx}, rawSink, nil, nil, nil) - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - return l.headSub.SubscribeNewHead(ctx, ch) - }, - func(e *IWithdrawWithdrawStarted) types.Log { return e.Raw }, - ) - }() - - defer close(sink) - for ev := range raw { - select { - case sink <- &nw.WithdrawStartedEvent{ - WithdrawalID: ev.WithdrawalId, - User: ev.User, - Token: ev.Token, - Amount: ev.Amount, - Nonce: ev.Nonce, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - }: - case <-ctx.Done(): - return ctx.Err() - } - } - - return <-errCh -} - -// WatchWithdrawFinalized subscribes to WithdrawFinalized events and sends confirmed domain events to the sink channel. -func (l *Listener) WatchWithdrawFinalized(ctx context.Context, sink chan<- *nw.WithdrawFinalizedEvent) error { - raw := make(chan *IWithdrawWithdrawFinalized) - - errCh := make(chan error, 1) - go func() { - errCh <- watchWithConfirmations(ctx, raw, l.confirmations, - func(rawSink chan<- *IWithdrawWithdrawFinalized) (ethereum.Subscription, error) { - return l.withdraw.WatchWithdrawFinalized(&bind.WatchOpts{Context: ctx}, rawSink, nil) - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - return l.headSub.SubscribeNewHead(ctx, ch) - }, - func(e *IWithdrawWithdrawFinalized) types.Log { return e.Raw }, - ) - }() - - defer close(sink) - for ev := range raw { - select { - case sink <- &nw.WithdrawFinalizedEvent{ - WithdrawalID: ev.WithdrawalId, - Success: ev.Success, - BlockNumber: ev.Raw.BlockNumber, - TxHash: ev.Raw.TxHash, - }: - case <-ctx.Done(): - return ctx.Err() - } - } - - return <-errCh -} - -// watchWithConfirmations is the generic confirmation-tracking event watcher. -// It buffers events until they reach the required block depth before emitting them to sink. -// If confirmations is 0, events are emitted immediately. -func watchWithConfirmations[E any]( - ctx context.Context, - sink chan<- E, - confirmations uint64, - subscribe func(chan<- E) (ethereum.Subscription, error), - subscribeHead func(chan<- *types.Header) (ethereum.Subscription, error), - getRaw func(E) types.Log, -) error { - defer close(sink) - - rawSink := make(chan E) - sub, err := subscribe(rawSink) - if err != nil { - return fmt.Errorf("failed to subscribe to events: %w", err) - } - defer sub.Unsubscribe() - - headers := make(chan *types.Header) - headSub, err := subscribeHead(headers) - if err != nil { - return fmt.Errorf("failed to subscribe to new heads: %w", err) - } - defer headSub.Unsubscribe() - - type pendingEvent struct { - event E - blockNumber uint64 - } - var pending []pendingEvent - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-sub.Err(): - return fmt.Errorf("event subscription error: %w", err) - case err := <-headSub.Err(): - return fmt.Errorf("header subscription error: %w", err) - case ev := <-rawSink: - raw := getRaw(ev) - if raw.Removed { - n := 0 - for _, p := range pending { - pRaw := getRaw(p.event) - if pRaw.TxHash != raw.TxHash || pRaw.Index != raw.Index { - pending[n] = p - n++ - } - } - pending = pending[:n] - } else if confirmations == 0 { - select { - case sink <- ev: - case <-ctx.Done(): - return ctx.Err() - } - } else { - pending = append(pending, pendingEvent{ - event: ev, - blockNumber: raw.BlockNumber, - }) - } - case head := <-headers: - if confirmations == 0 { - continue - } - currentBlock := head.Number.Uint64() - n := 0 - for _, p := range pending { - if currentBlock+1 >= p.blockNumber+confirmations { - select { - case sink <- p.event: - case <-ctx.Done(): - return ctx.Err() - } - } else { - pending[n] = p - n++ - } - } - pending = pending[:n] - } - } -} diff --git a/chain/listener_test.go b/chain/listener_test.go deleted file mode 100644 index 80b33f6..0000000 --- a/chain/listener_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package chain - -import ( - "context" - "math/big" - "testing" - "time" - - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" -) - -// testEvent is a simple event type for testing the generic watchWithConfirmations. -type testEvent struct { - ID int - Raw types.Log -} - -// mockSubscription implements ethereum.Subscription for testing. -type mockSubscription struct { - errCh chan error - unsub bool - unsubC chan struct{} -} - -func newMockSubscription() *mockSubscription { - return &mockSubscription{ - errCh: make(chan error, 1), - unsubC: make(chan struct{}), - } -} - -func (m *mockSubscription) Err() <-chan error { return m.errCh } -func (m *mockSubscription) Unsubscribe() { - if !m.unsub { - m.unsub = true - close(m.unsubC) - } -} - -func TestWatchWithConfirmations_ZeroConfirmations(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - eventCh := make(chan *testEvent) - headCh := make(chan *types.Header) - sink := make(chan *testEvent, 10) - - eventSub := newMockSubscription() - headSub := newMockSubscription() - - go func() { - err := watchWithConfirmations( - ctx, sink, 0, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - go func() { - for ev := range eventCh { - rawSink <- ev - } - }() - return eventSub, nil - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - go func() { - for h := range headCh { - ch <- h - } - }() - return headSub, nil - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - _ = err - }() - - // Send an event - should be delivered immediately - eventCh <- &testEvent{ - ID: 1, - Raw: types.Log{ - BlockNumber: 100, - TxHash: common.HexToHash("0x01"), - }, - } - - select { - case ev := <-sink: - if ev.ID != 1 { - t.Fatalf("expected event ID 1, got %d", ev.ID) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for event") - } - - cancel() -} - -func TestWatchWithConfirmations_WithConfirmations(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - eventCh := make(chan *testEvent) - headCh := make(chan *types.Header) - sink := make(chan *testEvent, 10) - - eventSub := newMockSubscription() - headSub := newMockSubscription() - - go func() { - _ = watchWithConfirmations( - ctx, sink, 3, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - go func() { - for ev := range eventCh { - rawSink <- ev - } - }() - return eventSub, nil - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - go func() { - for h := range headCh { - ch <- h - } - }() - return headSub, nil - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - }() - - // Send event at block 100 - eventCh <- &testEvent{ - ID: 1, - Raw: types.Log{ - BlockNumber: 100, - TxHash: common.HexToHash("0x01"), - }, - } - - // Allow event to be buffered - time.Sleep(50 * time.Millisecond) - - // Block 101 - not enough confirmations (depth=2, need 3) - headCh <- &types.Header{Number: big.NewInt(101)} - time.Sleep(50 * time.Millisecond) - - select { - case <-sink: - t.Fatal("event should not be delivered yet") - default: - } - - // Block 102 - depth=3, should confirm (102+1 >= 100+3) - headCh <- &types.Header{Number: big.NewInt(102)} - - select { - case ev := <-sink: - if ev.ID != 1 { - t.Fatalf("expected event ID 1, got %d", ev.ID) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for confirmed event") - } - - cancel() -} - -func TestWatchWithConfirmations_ReorgRemoval(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - eventCh := make(chan *testEvent) - headCh := make(chan *types.Header) - sink := make(chan *testEvent, 10) - - eventSub := newMockSubscription() - headSub := newMockSubscription() - - go func() { - _ = watchWithConfirmations( - ctx, sink, 3, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - go func() { - for ev := range eventCh { - rawSink <- ev - } - }() - return eventSub, nil - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - go func() { - for h := range headCh { - ch <- h - } - }() - return headSub, nil - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - }() - - txHash := common.HexToHash("0x01") - - // Send event at block 100 - eventCh <- &testEvent{ - ID: 1, - Raw: types.Log{BlockNumber: 100, TxHash: txHash, Index: 0}, - } - - time.Sleep(50 * time.Millisecond) - - // Reorg: same event is removed - eventCh <- &testEvent{ - ID: 1, - Raw: types.Log{BlockNumber: 100, TxHash: txHash, Index: 0, Removed: true}, - } - - time.Sleep(50 * time.Millisecond) - - // Even with enough blocks, the removed event should not be delivered - headCh <- &types.Header{Number: big.NewInt(105)} - time.Sleep(50 * time.Millisecond) - - select { - case <-sink: - t.Fatal("removed event should not be delivered") - default: - } - - cancel() -} - -func TestWatchWithConfirmations_MultipleEvents(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - eventCh := make(chan *testEvent) - headCh := make(chan *types.Header) - sink := make(chan *testEvent, 10) - - eventSub := newMockSubscription() - headSub := newMockSubscription() - - go func() { - _ = watchWithConfirmations( - ctx, sink, 2, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - go func() { - for ev := range eventCh { - rawSink <- ev - } - }() - return eventSub, nil - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - go func() { - for h := range headCh { - ch <- h - } - }() - return headSub, nil - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - }() - - // Events at different blocks - eventCh <- &testEvent{ID: 1, Raw: types.Log{BlockNumber: 100, TxHash: common.HexToHash("0x01")}} - eventCh <- &testEvent{ID: 2, Raw: types.Log{BlockNumber: 101, TxHash: common.HexToHash("0x02")}} - eventCh <- &testEvent{ID: 3, Raw: types.Log{BlockNumber: 102, TxHash: common.HexToHash("0x03")}} - - time.Sleep(50 * time.Millisecond) - - // Block 101: confirms event 1 (101+1 >= 100+2), not event 2 or 3 - headCh <- &types.Header{Number: big.NewInt(101)} - - select { - case ev := <-sink: - if ev.ID != 1 { - t.Fatalf("expected event 1 first, got %d", ev.ID) - } - case <-time.After(time.Second): - t.Fatal("timed out") - } - - // Block 103: confirms events 2 and 3 - headCh <- &types.Header{Number: big.NewInt(103)} - - received := map[int]bool{} - for i := 0; i < 2; i++ { - select { - case ev := <-sink: - received[ev.ID] = true - case <-time.After(time.Second): - t.Fatal("timed out") - } - } - - if !received[2] || !received[3] { - t.Fatalf("expected events 2 and 3, got %v", received) - } - - cancel() -} - -func TestWatchWithConfirmations_ContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - - sink := make(chan *testEvent, 10) - eventSub := newMockSubscription() - headSub := newMockSubscription() - - errCh := make(chan error, 1) - go func() { - errCh <- watchWithConfirmations( - ctx, sink, 0, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - return eventSub, nil - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - return headSub, nil - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - }() - - cancel() - - select { - case err := <-errCh: - if err != context.Canceled { - t.Fatalf("expected context.Canceled, got: %v", err) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancellation") - } -} - -func TestWatchWithConfirmations_SubscribeEventError(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sink := make(chan *testEvent, 10) - - errCh := make(chan error, 1) - go func() { - errCh <- watchWithConfirmations( - ctx, sink, 0, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - return nil, context.DeadlineExceeded - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - return newMockSubscription(), nil - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - }() - - select { - case err := <-errCh: - if err == nil { - t.Fatal("expected error") - } - case <-time.After(time.Second): - t.Fatal("timed out") - } -} - -func TestWatchWithConfirmations_SubscribeHeadError(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sink := make(chan *testEvent, 10) - eventSub := newMockSubscription() - - errCh := make(chan error, 1) - go func() { - errCh <- watchWithConfirmations( - ctx, sink, 0, - func(rawSink chan<- *testEvent) (ethereum.Subscription, error) { - return eventSub, nil - }, - func(ch chan<- *types.Header) (ethereum.Subscription, error) { - return nil, context.DeadlineExceeded - }, - func(e *testEvent) types.Log { return e.Raw }, - ) - }() - - select { - case err := <-errCh: - if err == nil { - t.Fatal("expected error") - } - case <-time.After(time.Second): - t.Fatal("timed out") - } -} diff --git a/chain/simulated_backend_test.go b/chain/simulated_backend_test.go deleted file mode 100644 index 6910aef..0000000 --- a/chain/simulated_backend_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package chain - -import ( - "context" - "crypto/ecdsa" - "math/big" - "testing" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/accounts/abi/bind/backends" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" -) - -func TestSimpleCustodyFlow(t *testing.T) { - // 1. Setup Accounts - // We need 4 accounts: Admin, NeoDAX, Nitewatch, User - keys := make([]*ecdsa.PrivateKey, 4) - addrs := make([]common.Address, 4) - auths := make([]*bind.TransactOpts, 4) - - alloc := make(types.GenesisAlloc) - balance := new(big.Int).Mul(big.NewInt(1000), big.NewInt(1e18)) // 1000 ETH - - for i := 0; i < 4; i++ { - key, err := crypto.GenerateKey() - if err != nil { - t.Fatalf("Failed to generate key: %v", err) - } - keys[i] = key - addrs[i] = crypto.PubkeyToAddress(key.PublicKey) - - auth, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) - if err != nil { - t.Fatalf("Failed to create auth: %v", err) - } - auths[i] = auth - - alloc[addrs[i]] = types.Account{Balance: balance} - } - - adminAuth := auths[0] - neodaxAuth := auths[1] - nitewatchAuth := auths[2] - userAuth := auths[3] - - adminAddr := addrs[0] - neodaxAddr := addrs[1] - nitewatchAddr := addrs[2] - userAddr := addrs[3] - - // 2. Setup Simulated Backend - sim := backends.NewSimulatedBackend(alloc, 8000000) - defer sim.Close() - - // 3. Deploy Contract - custodyAddr, _, custody, err := DeploySimpleCustody(adminAuth, sim, adminAddr, neodaxAddr, nitewatchAddr) - if err != nil { - t.Fatalf("Failed to deploy SimpleCustody: %v", err) - } - sim.Commit() - - // 4. Test Deposit Flow - t.Run("Deposit ETH", func(t *testing.T) { - depositAmount := big.NewInt(1e18) // 1 ETH - - // User deposits - // Note: Deposit requires value to be sent. `userAuth` needs to be updated with value. - userAuth.Value = depositAmount - tx, err := custody.Deposit(userAuth, common.Address{}, depositAmount) - userAuth.Value = nil // Reset - if err != nil { - t.Fatalf("Failed to deposit: %v", err) - } - sim.Commit() - - receipt, err := sim.TransactionReceipt(context.Background(), tx.Hash()) - if err != nil { - t.Fatalf("Failed to get receipt: %v", err) - } - if receipt.Status != types.ReceiptStatusSuccessful { - t.Fatal("Deposit transaction failed") - } - - // Check contract balance - contractBalance, err := sim.BalanceAt(context.Background(), custodyAddr, nil) - if err != nil { - t.Fatalf("Failed to get contract balance: %v", err) - } - if contractBalance.Cmp(depositAmount) != 0 { - t.Errorf("Expected contract balance %v, got %v", depositAmount, contractBalance) - } - }) - - // 5. Test Withdrawal Flow - t.Run("Withdraw ETH", func(t *testing.T) { - withdrawAmount := big.NewInt(5e17) // 0.5 ETH - nonce := big.NewInt(1) - - // Record User balance before - userBalBefore, err := sim.BalanceAt(context.Background(), userAddr, nil) - if err != nil { - t.Fatalf("Failed to get user balance: %v", err) - } - - // A. Start Withdraw (NeoDAX) - tx, err := custody.StartWithdraw(neodaxAuth, userAddr, common.Address{}, withdrawAmount, nonce) - if err != nil { - t.Fatalf("Failed to start withdraw: %v", err) - } - sim.Commit() - - receipt, err := sim.TransactionReceipt(context.Background(), tx.Hash()) - if err != nil { - t.Fatalf("Failed to get start withdraw receipt: %v", err) - } - if receipt.Status != types.ReceiptStatusSuccessful { - t.Fatal("StartWithdraw transaction failed") - } - - // Parse event to get withdrawalId - // In a real scenario we'd parse logs. Here we can iterate logs. - var withdrawalId [32]byte - found := false - for _, log := range receipt.Logs { - event, err := custody.ParseWithdrawStarted(*log) - if err == nil { - withdrawalId = event.WithdrawalId - found = true - break - } - } - if !found { - t.Fatal("WithdrawStarted event not found") - } - - // B. Finalize Withdraw (Nitewatch) - tx, err = custody.FinalizeWithdraw(nitewatchAuth, withdrawalId) - if err != nil { - t.Fatalf("Failed to finalize withdraw: %v", err) - } - sim.Commit() - - receipt, err = sim.TransactionReceipt(context.Background(), tx.Hash()) - if err != nil { - t.Fatalf("Failed to get finalize withdraw receipt: %v", err) - } - if receipt.Status != types.ReceiptStatusSuccessful { - t.Fatal("FinalizeWithdraw transaction failed") - } - - // Check User balance after - userBalAfter, err := sim.BalanceAt(context.Background(), userAddr, nil) - if err != nil { - t.Fatalf("Failed to get user balance: %v", err) - } - - expectedBal := new(big.Int).Add(userBalBefore, withdrawAmount) - // Note: user pays gas for nothing here? No, user doesn't call anything. - // User receives funds. Gas is paid by Nitewatch. - // So user balance should be exactly previous + withdrawn amount. - - if userBalAfter.Cmp(expectedBal) != 0 { - t.Errorf("Expected user balance %v, got %v", expectedBal, userBalAfter) - } - }) -} diff --git a/cmd/example/usage.go b/cmd/example/usage.go deleted file mode 100644 index c086cbe..0000000 --- a/cmd/example/usage.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "context" - "crypto/ecdsa" - "fmt" - "log" - "math/big" - "time" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/accounts/abi/bind/backends" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/layer-3/nitewatch/chain" -) - -func main() { - // 1. Setup Accounts - // We generate keys for Admin, NeoDAX, Nitewatch, and a User. - keys := make([]*ecdsa.PrivateKey, 4) - addrs := make([]common.Address, 4) - auths := make([]*bind.TransactOpts, 4) - - alloc := make(types.GenesisAlloc) - balance := new(big.Int).Mul(big.NewInt(1000), big.NewInt(1e18)) // 1000 ETH - - fmt.Println("Setting up accounts...") - for i := 0; i < 4; i++ { - key, err := crypto.GenerateKey() - if err != nil { - log.Fatalf("Failed to generate key: %v", err) - } - keys[i] = key - addrs[i] = crypto.PubkeyToAddress(key.PublicKey) - - auth, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) - if err != nil { - log.Fatalf("Failed to create auth: %v", err) - } - auths[i] = auth - - alloc[addrs[i]] = types.Account{Balance: balance} - } - - adminAuth := auths[0] - neodaxAuth := auths[1] - nitewatchAuth := auths[2] - userAuth := auths[3] - - // 2. Setup Simulated Backend - fmt.Println("Initializing simulated backend...") - sim := backends.NewSimulatedBackend(alloc, 8000000) - defer sim.Close() - - // 3. Deploy Contract - fmt.Println("Deploying SimpleCustody contract...") - custodyAddr, _, custody, err := chain.DeploySimpleCustody(adminAuth, sim, addrs[0], addrs[1], addrs[2]) - if err != nil { - log.Fatalf("Failed to deploy SimpleCustody: %v", err) - } - sim.Commit() - fmt.Printf("Contract deployed at: %s\n", custodyAddr.Hex()) - - // 4. Start Event Listeners (Background) - go func() { - // Watch for Deposits - depositCh := make(chan *chain.SimpleCustodyDeposited) - depositSub, err := custody.WatchDeposited(&bind.WatchOpts{}, depositCh, nil, nil) - if err != nil { - log.Printf("Failed to watch deposits: %v", err) - return - } - defer depositSub.Unsubscribe() - - // Watch for Withdrawal Requests - withdrawStartedCh := make(chan *chain.SimpleCustodyWithdrawStarted) - withdrawStartedSub, err := custody.WatchWithdrawStarted(&bind.WatchOpts{}, withdrawStartedCh, nil, nil, nil) - if err != nil { - log.Printf("Failed to watch withdraw started: %v", err) - return - } - defer withdrawStartedSub.Unsubscribe() - - // Watch for Finalized Withdrawals - withdrawFinalizedCh := make(chan *chain.SimpleCustodyWithdrawFinalized) - withdrawFinalizedSub, err := custody.WatchWithdrawFinalized(&bind.WatchOpts{}, withdrawFinalizedCh, nil) - if err != nil { - log.Printf("Failed to watch withdraw finalized: %v", err) - return - } - defer withdrawFinalizedSub.Unsubscribe() - - fmt.Println("Listening for events...") - - for { - select { - case ev := <-depositCh: - fmt.Printf("[EVENT] Deposited: User=%s Token=%s Amount=%s\n", ev.User.Hex(), ev.Token.Hex(), ev.Amount.String()) - case ev := <-withdrawStartedCh: - fmt.Printf("[EVENT] WithdrawStarted: ID=%x User=%s Amount=%s\n", ev.WithdrawalId, ev.User.Hex(), ev.Amount.String()) - case ev := <-withdrawFinalizedCh: - fmt.Printf("[EVENT] WithdrawFinalized: ID=%x Success=%v\n", ev.WithdrawalId, ev.Success) - case err := <-depositSub.Err(): - log.Printf("Deposit subscription error: %v", err) - return - case err := <-withdrawStartedSub.Err(): - log.Printf("WithdrawStarted subscription error: %v", err) - return - case err := <-withdrawFinalizedSub.Err(): - log.Printf("WithdrawFinalized subscription error: %v", err) - return - } - } - }() - - // Allow listeners to subscribe - time.Sleep(100 * time.Millisecond) - - // 5. Execute Deposit - fmt.Println("\n--- Executing Deposit ---") - depositAmount := big.NewInt(1e18) // 1 ETH - userAuth.Value = depositAmount - tx, err := custody.Deposit(userAuth, common.Address{}, depositAmount) - userAuth.Value = nil // Reset value - if err != nil { - log.Fatalf("Failed to deposit: %v", err) - } - fmt.Printf("Deposit transaction sent: %s\n", tx.Hash().Hex()) - sim.Commit() - - // Wait a bit for event processing - time.Sleep(200 * time.Millisecond) - - // 6. Execute Withdrawal - fmt.Println("\n--- Executing Withdrawal ---") - withdrawAmount := big.NewInt(5e17) // 0.5 ETH - nonce := big.NewInt(1) - - // NeoDAX starts withdrawal - fmt.Println("NeoDAX initiating withdrawal...") - tx, err = custody.StartWithdraw(neodaxAuth, addrs[3], common.Address{}, withdrawAmount, nonce) - if err != nil { - log.Fatalf("Failed to start withdraw: %v", err) - } - sim.Commit() - - receipt, err := sim.TransactionReceipt(context.Background(), tx.Hash()) - if err != nil { - log.Fatalf("Failed to get receipt: %v", err) - } - - // Find the withdrawal ID from the receipt logs to pass to finalize - // In a real app, the Nitewatch daemon would pick this up from the event stream. - // Here we parse it manually for the "Nitewatch" actor simulation. - var withdrawalId [32]byte - for _, log := range receipt.Logs { - event, err := custody.ParseWithdrawStarted(*log) - if err == nil { - withdrawalId = event.WithdrawalId - break - } - } - fmt.Printf("Withdrawal ID: %x\n", withdrawalId) - - // Wait for event listener to print "WithdrawStarted" - time.Sleep(200 * time.Millisecond) - - // Nitewatch finalizes withdrawal - fmt.Println("Nitewatch finalizing withdrawal...") - tx, err = custody.FinalizeWithdraw(nitewatchAuth, withdrawalId) - if err != nil { - log.Fatalf("Failed to finalize withdraw: %v", err) - } - sim.Commit() - - // Wait for event listener to print "WithdrawFinalized" - time.Sleep(200 * time.Millisecond) - - fmt.Println("\n--- Demo Complete ---") -} diff --git a/cmd/nitewatch/config.yaml b/cmd/nitewatch/config.yaml new file mode 100644 index 0000000..68e2ff2 --- /dev/null +++ b/cmd/nitewatch/config.yaml @@ -0,0 +1,19 @@ +blockchain: + rpc_url: "${NITEWATCH_RPC_URL}" + contract_address: "${NITEWATCH_CONTRACT_ADDRESS}" + private_key: "${NITEWATCH_PRIVATE_KEY}" + +limits: + # Native ETH (zero address) + "0x0000000000000000000000000000000000000000": + hourly: "1000000000000000000" # 1 ETH + daily: "10000000000000000000" # 10 ETH + +# per_user_overrides: +# "0xUserAddress...": +# "0x0000000000000000000000000000000000000000": +# hourly: "5000000000000000000" +# daily: "50000000000000000000" + +listen_addr: ":8080" +db_path: "nitewatch.db" diff --git a/cmd/nitewatch/limits.yaml b/cmd/nitewatch/limits.yaml deleted file mode 100644 index d547c80..0000000 --- a/cmd/nitewatch/limits.yaml +++ /dev/null @@ -1,5 +0,0 @@ -limits: - # Example Token Address (Replace with actual deployment address) - "0x5FbDB2315678afecb367f032d93F642f64180aa3": - hourly: "1000000000000000000" # 1 ETH - daily: "10000000000000000000" # 10 ETH diff --git a/cmd/nitewatch/main.go b/cmd/nitewatch/main.go index f80fef3..ea8a281 100644 --- a/cmd/nitewatch/main.go +++ b/cmd/nitewatch/main.go @@ -1,181 +1,39 @@ package main import ( - "context" - _ "embed" - "flag" - "log" + "fmt" + "log/slog" "os" - "os/signal" - "syscall" - "time" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" - "gopkg.in/yaml.v3" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - - nw "github.com/layer-3/nitewatch" - "github.com/layer-3/nitewatch/chain" - "github.com/layer-3/nitewatch/core" - "github.com/layer-3/nitewatch/store" + "github.com/layer-3/nitewatch/config" + "github.com/layer-3/nitewatch/service" ) -//go:embed limits.yaml -var limitsConfig []byte - func main() { - rpcURL := flag.String("rpc", "ws://127.0.0.1:8545", "Ethereum RPC URL (WebSocket required)") - contractAddr := flag.String("contract", "", "IWithdraw contract address") - privateKeyHex := flag.String("key", "", "Private key for finalizing withdrawals (hex)") - confirmations := flag.Uint64("confirmations", 12, "Number of block confirmations to wait") - dbPath := flag.String("db", "nitewatch.db", "Path to SQLite database") - flag.Parse() - - if *contractAddr == "" || *privateKeyHex == "" { - log.Fatal("Contract address and private key are required") - } - - // 1. Initialize Store - gormDB, err := gorm.Open(sqlite.Open(*dbPath), &gorm.Config{}) - if err != nil { - log.Fatalf("Failed to open database: %v", err) - } - - db, err := store.NewAdapter(gormDB) - if err != nil { - log.Fatalf("Failed to initialize database: %v", err) - } - - // 2. Load Configuration and Initialize Checker - var cfg core.Config - if err := yaml.Unmarshal(limitsConfig, &cfg); err != nil { - log.Fatalf("Failed to parse embedded limits.yaml: %v", err) - } - - checker, err := core.NewChecker(cfg, db) - if err != nil { - log.Fatalf("Failed to initialize checker: %v", err) - } - - // 3. Connect to Ethereum - client, err := ethclient.Dial(*rpcURL) - if err != nil { - log.Fatalf("Failed to connect to RPC: %v", err) + if len(os.Args) < 2 || os.Args[1] != "worker" { + fmt.Fprintln(os.Stderr, "usage: nitewatch worker") + os.Exit(1) } - defer client.Close() - chainID, err := client.ChainID(context.Background()) - if err != nil { - log.Fatalf("Failed to get chain ID: %v", err) + configPath := os.Getenv("NITEWATCH_CONFIG") + if configPath == "" { + configPath = "config.yaml" } - // 4. Setup Signer - key, err := crypto.HexToECDSA(*privateKeyHex) + conf, err := config.Load(configPath) if err != nil { - log.Fatalf("Failed to parse private key: %v", err) + slog.Error("Failed to load configuration", "error", err) + os.Exit(1) } - auth, err := bind.NewKeyedTransactorWithChainID(key, chainID) + svc, err := service.New(*conf) if err != nil { - log.Fatalf("Failed to create transactor: %v", err) + slog.Error("Failed to create service", "error", err) + os.Exit(1) } - // 5. Setup Contract Binding - addr := common.HexToAddress(*contractAddr) - custodyContract, err := chain.NewIWithdraw(addr, client) - if err != nil { - log.Fatalf("Failed to bind contract: %v", err) - } - - // 6. Setup Listener - listener := chain.NewListener(client, custodyContract, *confirmations) - - // 7. Start Watching - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - withdrawals := make(chan *nw.WithdrawStartedEvent) - - // Handle shutdown - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-sigCh - log.Println("Shutting down...") - cancel() - }() - - go func() { - log.Println("Listening for WithdrawStarted events...") - if err := listener.WatchWithdrawStarted(ctx, withdrawals); err != nil { - if ctx.Err() == nil { - log.Printf("WatchWithdrawStarted error: %v", err) - } - } - }() - - // 8. Process Loop - for event := range withdrawals { - log.Printf("New withdrawal request: ID=%x User=%s Token=%s Amount=%s", - event.WithdrawalID, event.User.Hex(), event.Token.Hex(), event.Amount) - - // Check Limits - if err := checker.Check(event.Token, event.Amount); err != nil { - log.Printf("Withdrawal %x blocked by policy: %v. Rejecting on-chain...", event.WithdrawalID, err) - - txAuth := *auth - txAuth.Context = ctx - tx, err := custodyContract.RejectWithdraw(&txAuth, event.WithdrawalID) - if err != nil { - log.Printf("Failed to reject withdrawal %x: %v", event.WithdrawalID, err) - } else { - log.Printf("Sent reject tx: %s for withdrawal %x", tx.Hash().Hex(), event.WithdrawalID) - bind.WaitMined(ctx, client, tx) - } - continue - } - - // Finalize - txAuth := *auth - txAuth.Context = ctx - - tx, err := custodyContract.FinalizeWithdraw(&txAuth, event.WithdrawalID) - if err != nil { - log.Printf("Failed to finalize withdrawal %x: %v", event.WithdrawalID, err) - continue - } - - log.Printf("Sent finalize tx: %s for withdrawal %x", tx.Hash().Hex(), event.WithdrawalID) - - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - log.Printf("Transaction mining failed: %v", err) - continue - } - - if receipt.Status == 1 { - log.Printf("Withdrawal %x finalized successfully on-chain.", event.WithdrawalID) - - // Record usage in DB - record := &nw.Withdrawal{ - WithdrawalID: event.WithdrawalID, - User: event.User, - Token: event.Token, - Amount: event.Amount, - BlockNumber: receipt.BlockNumber.Uint64(), - TxHash: tx.Hash(), - Timestamp: time.Now(), - } - - if err := checker.Record(record); err != nil { - log.Printf("Failed to record withdrawal %x in DB: %v", event.WithdrawalID, err) - } - } else { - log.Printf("Withdrawal %x finalization tx failed (reverted).", event.WithdrawalID) - } + if err := svc.RunWorker(); err != nil { + slog.Error("Worker failed", "error", err) + os.Exit(1) } } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..e616249 --- /dev/null +++ b/config/config.go @@ -0,0 +1,114 @@ +package config + +import ( + "errors" + "fmt" + "math/big" + "os" + "strings" + + "github.com/ethereum/go-ethereum/common" + "gopkg.in/yaml.v3" +) + +type Config struct { + Blockchain BlockchainConfig `yaml:"blockchain"` + Limits LimitsConfig `yaml:"limits"` + PerUserOverrides map[string]LimitsConfig `yaml:"per_user_overrides"` + ListenAddr string `yaml:"listen_addr"` + DBPath string `yaml:"db_path"` +} + +type BlockchainConfig struct { + RPCURL string `yaml:"rpc_url"` + ContractAddr string `yaml:"contract_address"` + PrivateKey string `yaml:"private_key"` +} + +// LimitsConfig maps token contract addresses to their withdrawal rate limits. +type LimitsConfig map[string]LimitConfig + +type LimitConfig struct { + Hourly string `yaml:"hourly"` + Daily string `yaml:"daily"` +} + +func (c Config) Validate() error { + if err := c.Blockchain.Validate(); err != nil { + return fmt.Errorf("invalid blockchain config: %w", err) + } + if len(c.Limits) == 0 { + return errors.New("at least one token limit must be configured") + } + if err := validateLimitsConfig(c.Limits, "limits"); err != nil { + return err + } + for userAddr, tokenLimits := range c.PerUserOverrides { + if !common.IsHexAddress(userAddr) { + return fmt.Errorf("invalid user address in per_user_overrides: %s", userAddr) + } + if err := validateLimitsConfig(tokenLimits, fmt.Sprintf("per_user_overrides[%s]", userAddr)); err != nil { + return err + } + } + return nil +} + +func validateLimitsConfig(lc LimitsConfig, section string) error { + for addr, lim := range lc { + if !common.IsHexAddress(addr) { + return fmt.Errorf("invalid token address in %s: %s", section, addr) + } + if lim.Hourly != "" { + if _, ok := new(big.Int).SetString(lim.Hourly, 10); !ok { + return fmt.Errorf("invalid hourly limit for %s in %s: %s", addr, section, lim.Hourly) + } + } + if lim.Daily != "" { + if _, ok := new(big.Int).SetString(lim.Daily, 10); !ok { + return fmt.Errorf("invalid daily limit for %s in %s: %s", addr, section, lim.Daily) + } + } + } + return nil +} + +func (c BlockchainConfig) Validate() error { + if c.RPCURL == "" { + return errors.New("missing blockchain RPC URL") + } + if !strings.HasPrefix(c.RPCURL, "ws://") && !strings.HasPrefix(c.RPCURL, "wss://") { + return fmt.Errorf("RPC URL must use WebSocket (ws:// or wss://), got: %s", c.RPCURL) + } + if !common.IsHexAddress(c.ContractAddr) { + return fmt.Errorf("invalid contract address: %s", c.ContractAddr) + } + if c.PrivateKey == "" { + return errors.New("missing private key") + } + return nil +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + expanded := os.ExpandEnv(string(data)) + + var cfg Config + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + if cfg.DBPath == "" { + cfg.DBPath = "nitewatch.db" + } + + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":8080" + } + + return &cfg, nil +} diff --git a/contracts/evm/deployments/11155111/SimpleCustody.sol:SimpleCustody/2026-02-17T10:26:25.json b/contracts/evm/deployments/11155111/SimpleCustody.sol/2026-02-17T102625.json similarity index 100% rename from contracts/evm/deployments/11155111/SimpleCustody.sol:SimpleCustody/2026-02-17T10:26:25.json rename to contracts/evm/deployments/11155111/SimpleCustody.sol/2026-02-17T102625.json diff --git a/contracts/evm/lib/forge-std b/contracts/evm/lib/forge-std index 1801b05..0844d7e 160000 --- a/contracts/evm/lib/forge-std +++ b/contracts/evm/lib/forge-std @@ -1 +1 @@ -Subproject commit 1801b0541f4fda118a10798fd3486bb7051c5dd6 +Subproject commit 0844d7e1fc5e60d77b68e469bff60265f236c398 diff --git a/core/checker.go b/core/checker.go deleted file mode 100644 index c9e34f2..0000000 --- a/core/checker.go +++ /dev/null @@ -1,119 +0,0 @@ -package core - -import ( - "errors" - "fmt" - "math/big" - "time" - - "github.com/ethereum/go-ethereum/common" - nw "github.com/layer-3/nitewatch" -) - -var ( - ErrNoLimitsConfigured = errors.New("no limits configured for token") - ErrHourlyLimitExceeded = errors.New("hourly limit exceeded") - ErrDailyLimitExceeded = errors.New("daily limit exceeded") -) - -// LimitConfig defines the withdrawal constraints for a token (YAML friendly). -type LimitConfig struct { - Hourly string `yaml:"hourly"` - Daily string `yaml:"daily"` -} - -// Config maps token addresses to their limits. -type Config struct { - Limits map[string]LimitConfig `yaml:"limits"` -} - -// limit defines the internal parsed withdrawal constraints. -type limit struct { - Hourly *big.Int - Daily *big.Int -} - -// Checker manages withdrawal limits using a database store. -type Checker struct { - limits map[common.Address]limit - store nw.WithdrawalStore - nowFunc func() time.Time -} - -// NewChecker creates a new limit checker with the provided configuration and store. -func NewChecker(cfg Config, store nw.WithdrawalStore) (*Checker, error) { - limits := make(map[common.Address]limit) - for addrStr, conf := range cfg.Limits { - if !common.IsHexAddress(addrStr) { - return nil, fmt.Errorf("invalid address in config: %s", addrStr) - } - addr := common.HexToAddress(addrStr) - - l := limit{} - if conf.Hourly != "" { - val, ok := new(big.Int).SetString(conf.Hourly, 10) - if !ok { - return nil, fmt.Errorf("invalid hourly limit for %s: %s", addrStr, conf.Hourly) - } - l.Hourly = val - } - if conf.Daily != "" { - val, ok := new(big.Int).SetString(conf.Daily, 10) - if !ok { - return nil, fmt.Errorf("invalid daily limit for %s: %s", addrStr, conf.Daily) - } - l.Daily = val - } - limits[addr] = l - } - - return &Checker{ - limits: limits, - store: store, - nowFunc: time.Now, - }, nil -} - -// Check verifies if a withdrawal amount is within limits for the given token. -// It queries the store for total withdrawn amounts in the current hour and day. -func (c *Checker) Check(token common.Address, amount *big.Int) error { - l, ok := c.limits[token] - if !ok { - return fmt.Errorf("%w: %s", ErrNoLimitsConfigured, token.Hex()) - } - - now := c.nowFunc() - - if l.Hourly != nil { - startOfHour := now.Truncate(time.Hour) - total, err := c.store.GetTotalWithdrawn(token, startOfHour) - if err != nil { - return fmt.Errorf("failed to get hourly withdrawn amount: %w", err) - } - - newTotal := new(big.Int).Add(total, amount) - if newTotal.Cmp(l.Hourly) > 0 { - return fmt.Errorf("%w for %s: %s > %s", ErrHourlyLimitExceeded, token.Hex(), newTotal, l.Hourly) - } - } - - if l.Daily != nil { - startOfDay := now.Truncate(24 * time.Hour) - total, err := c.store.GetTotalWithdrawn(token, startOfDay) - if err != nil { - return fmt.Errorf("failed to get daily withdrawn amount: %w", err) - } - - newTotal := new(big.Int).Add(total, amount) - if newTotal.Cmp(l.Daily) > 0 { - return fmt.Errorf("%w for %s: %s > %s", ErrDailyLimitExceeded, token.Hex(), newTotal, l.Daily) - } - } - - return nil -} - -// Record persists the withdrawal event to the store. -func (c *Checker) Record(w *nw.Withdrawal) error { - return c.store.Save(w) -} diff --git a/core/checker_test.go b/core/checker_test.go deleted file mode 100644 index e0a01fa..0000000 --- a/core/checker_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package core - -import ( - "errors" - "math/big" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - nw "github.com/layer-3/nitewatch" -) - -// mockStore is an in-memory WithdrawalStore for testing. -type mockStore struct { - withdrawals []*nw.Withdrawal - err error // if set, GetTotalWithdrawn returns this error -} - -func (m *mockStore) Save(w *nw.Withdrawal) error { - m.withdrawals = append(m.withdrawals, w) - return nil -} - -func (m *mockStore) GetTotalWithdrawn(token common.Address, since time.Time) (*big.Int, error) { - if m.err != nil { - return nil, m.err - } - total := new(big.Int) - for _, w := range m.withdrawals { - if w.Token == token && !w.Timestamp.Before(since) { - total.Add(total, w.Amount) - } - } - return total, nil -} - -var ( - tokenA = common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - tokenB = common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") - user = common.HexToAddress("0x1111111111111111111111111111111111111111") -) - -func TestNewChecker_ValidConfig(t *testing.T) { - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, &mockStore{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if c == nil { - t.Fatal("expected non-nil checker") - } -} - -func TestNewChecker_InvalidAddress(t *testing.T) { - cfg := Config{ - Limits: map[string]LimitConfig{ - "not-an-address": {Hourly: "1000"}, - }, - } - _, err := NewChecker(cfg, &mockStore{}) - if err == nil { - t.Fatal("expected error for invalid address") - } -} - -func TestNewChecker_InvalidHourlyLimit(t *testing.T) { - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "not-a-number"}, - }, - } - _, err := NewChecker(cfg, &mockStore{}) - if err == nil { - t.Fatal("expected error for invalid hourly limit") - } -} - -func TestNewChecker_InvalidDailyLimit(t *testing.T) { - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Daily: "xyz"}, - }, - } - _, err := NewChecker(cfg, &mockStore{}) - if err == nil { - t.Fatal("expected error for invalid daily limit") - } -} - -func TestCheck_NoLimitsConfigured(t *testing.T) { - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000"}, - }, - } - c, err := NewChecker(cfg, &mockStore{}) - if err != nil { - t.Fatal(err) - } - - err = c.Check(tokenB, big.NewInt(100)) - if !errors.Is(err, ErrNoLimitsConfigured) { - t.Fatalf("expected ErrNoLimitsConfigured, got: %v", err) - } -} - -func TestCheck_UnderHourlyLimit(t *testing.T) { - store := &mockStore{} - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { - return time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) - } - - if err := c.Check(tokenA, big.NewInt(500)); err != nil { - t.Fatalf("expected no error, got: %v", err) - } -} - -func TestCheck_ExactHourlyLimit(t *testing.T) { - store := &mockStore{} - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { - return time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) - } - - // Exact limit should pass (not exceed) - if err := c.Check(tokenA, big.NewInt(1000)); err != nil { - t.Fatalf("expected no error at exact limit, got: %v", err) - } -} - -func TestCheck_ExceedHourlyLimit(t *testing.T) { - now := time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) - store := &mockStore{ - withdrawals: []*nw.Withdrawal{ - {Token: tokenA, Amount: big.NewInt(800), Timestamp: now.Add(-10 * time.Minute)}, - }, - } - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { return now } - - err = c.Check(tokenA, big.NewInt(300)) - if !errors.Is(err, ErrHourlyLimitExceeded) { - t.Fatalf("expected ErrHourlyLimitExceeded, got: %v", err) - } -} - -func TestCheck_ExceedDailyLimit(t *testing.T) { - now := time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) - store := &mockStore{ - withdrawals: []*nw.Withdrawal{ - // Old withdrawal within the day but outside the hour - {Token: tokenA, Amount: big.NewInt(4500), Timestamp: now.Add(-3 * time.Hour)}, - }, - } - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { return now } - - err = c.Check(tokenA, big.NewInt(600)) - if !errors.Is(err, ErrDailyLimitExceeded) { - t.Fatalf("expected ErrDailyLimitExceeded, got: %v", err) - } -} - -func TestCheck_PreviousHourNotCounted(t *testing.T) { - now := time.Date(2025, 1, 1, 13, 5, 0, 0, time.UTC) - store := &mockStore{ - withdrawals: []*nw.Withdrawal{ - // Withdrawal from the previous hour - {Token: tokenA, Amount: big.NewInt(900), Timestamp: time.Date(2025, 1, 1, 12, 50, 0, 0, time.UTC)}, - }, - } - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { return now } - - // Previous hour withdrawal shouldn't count towards hourly limit - if err := c.Check(tokenA, big.NewInt(900)); err != nil { - t.Fatalf("expected no error (previous hour), got: %v", err) - } -} - -func TestCheck_HourlyOnlyConfig(t *testing.T) { - store := &mockStore{} - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { - return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - } - - // Without daily limit, large amount within hourly should pass - if err := c.Check(tokenA, big.NewInt(999)); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCheck_DailyOnlyConfig(t *testing.T) { - store := &mockStore{} - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { - return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - } - - // Without hourly limit, this should pass - if err := c.Check(tokenA, big.NewInt(4000)); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCheck_StoreError(t *testing.T) { - store := &mockStore{err: errors.New("db connection lost")} - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000", Daily: "5000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - c.nowFunc = func() time.Time { - return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - } - - err = c.Check(tokenA, big.NewInt(100)) - if err == nil { - t.Fatal("expected error from store") - } -} - -func TestRecord(t *testing.T) { - store := &mockStore{} - cfg := Config{ - Limits: map[string]LimitConfig{ - tokenA.Hex(): {Hourly: "1000"}, - }, - } - c, err := NewChecker(cfg, store) - if err != nil { - t.Fatal(err) - } - - w := &nw.Withdrawal{ - WithdrawalID: [32]byte{1}, - User: user, - Token: tokenA, - Amount: big.NewInt(500), - Timestamp: time.Now(), - } - if err := c.Record(w); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(store.withdrawals) != 1 { - t.Fatalf("expected 1 withdrawal in store, got %d", len(store.withdrawals)) - } -} diff --git a/custody/ideposit.go b/custody/ideposit.go new file mode 100644 index 0000000..f0988aa --- /dev/null +++ b/custody/ideposit.go @@ -0,0 +1,356 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package custody + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// IDepositMetaData contains all meta data concerning the IDeposit contract. +var IDepositMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"deposit\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"event\",\"name\":\"Deposited\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"MsgValueMismatch\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NonZeroMsgValueForERC20\",\"inputs\":[]}]", +} + +// IDepositABI is the input ABI used to generate the binding from. +// Deprecated: Use IDepositMetaData.ABI instead. +var IDepositABI = IDepositMetaData.ABI + +// IDeposit is an auto generated Go binding around an Ethereum contract. +type IDeposit struct { + IDepositCaller // Read-only binding to the contract + IDepositTransactor // Write-only binding to the contract + IDepositFilterer // Log filterer for contract events +} + +// IDepositCaller is an auto generated read-only Go binding around an Ethereum contract. +type IDepositCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDepositTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IDepositTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDepositFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IDepositFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDepositSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IDepositSession struct { + Contract *IDeposit // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IDepositCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IDepositCallerSession struct { + Contract *IDepositCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IDepositTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IDepositTransactorSession struct { + Contract *IDepositTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IDepositRaw is an auto generated low-level Go binding around an Ethereum contract. +type IDepositRaw struct { + Contract *IDeposit // Generic contract binding to access the raw methods on +} + +// IDepositCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IDepositCallerRaw struct { + Contract *IDepositCaller // Generic read-only contract binding to access the raw methods on +} + +// IDepositTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IDepositTransactorRaw struct { + Contract *IDepositTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIDeposit creates a new instance of IDeposit, bound to a specific deployed contract. +func NewIDeposit(address common.Address, backend bind.ContractBackend) (*IDeposit, error) { + contract, err := bindIDeposit(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IDeposit{IDepositCaller: IDepositCaller{contract: contract}, IDepositTransactor: IDepositTransactor{contract: contract}, IDepositFilterer: IDepositFilterer{contract: contract}}, nil +} + +// NewIDepositCaller creates a new read-only instance of IDeposit, bound to a specific deployed contract. +func NewIDepositCaller(address common.Address, caller bind.ContractCaller) (*IDepositCaller, error) { + contract, err := bindIDeposit(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IDepositCaller{contract: contract}, nil +} + +// NewIDepositTransactor creates a new write-only instance of IDeposit, bound to a specific deployed contract. +func NewIDepositTransactor(address common.Address, transactor bind.ContractTransactor) (*IDepositTransactor, error) { + contract, err := bindIDeposit(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IDepositTransactor{contract: contract}, nil +} + +// NewIDepositFilterer creates a new log filterer instance of IDeposit, bound to a specific deployed contract. +func NewIDepositFilterer(address common.Address, filterer bind.ContractFilterer) (*IDepositFilterer, error) { + contract, err := bindIDeposit(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IDepositFilterer{contract: contract}, nil +} + +// bindIDeposit binds a generic wrapper to an already deployed contract. +func bindIDeposit(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IDepositMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IDeposit *IDepositRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IDeposit.Contract.IDepositCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IDeposit *IDepositRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IDeposit.Contract.IDepositTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IDeposit *IDepositRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IDeposit.Contract.IDepositTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IDeposit *IDepositCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IDeposit.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IDeposit *IDepositTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IDeposit.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IDeposit *IDepositTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IDeposit.Contract.contract.Transact(opts, method, params...) +} + +// Deposit is a paid mutator transaction binding the contract method 0x47e7ef24. +// +// Solidity: function deposit(address token, uint256 amount) payable returns() +func (_IDeposit *IDepositTransactor) Deposit(opts *bind.TransactOpts, token common.Address, amount *big.Int) (*types.Transaction, error) { + return _IDeposit.contract.Transact(opts, "deposit", token, amount) +} + +// Deposit is a paid mutator transaction binding the contract method 0x47e7ef24. +// +// Solidity: function deposit(address token, uint256 amount) payable returns() +func (_IDeposit *IDepositSession) Deposit(token common.Address, amount *big.Int) (*types.Transaction, error) { + return _IDeposit.Contract.Deposit(&_IDeposit.TransactOpts, token, amount) +} + +// Deposit is a paid mutator transaction binding the contract method 0x47e7ef24. +// +// Solidity: function deposit(address token, uint256 amount) payable returns() +func (_IDeposit *IDepositTransactorSession) Deposit(token common.Address, amount *big.Int) (*types.Transaction, error) { + return _IDeposit.Contract.Deposit(&_IDeposit.TransactOpts, token, amount) +} + +// IDepositDepositedIterator is returned from FilterDeposited and is used to iterate over the raw logs and unpacked data for Deposited events raised by the IDeposit contract. +type IDepositDepositedIterator struct { + Event *IDepositDeposited // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDepositDepositedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDepositDeposited) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDepositDeposited) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDepositDepositedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDepositDepositedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDepositDeposited represents a Deposited event raised by the IDeposit contract. +type IDepositDeposited struct { + User common.Address + Token common.Address + Amount *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDeposited is a free log retrieval operation binding the contract event 0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7. +// +// Solidity: event Deposited(address indexed user, address indexed token, uint256 amount) +func (_IDeposit *IDepositFilterer) FilterDeposited(opts *bind.FilterOpts, user []common.Address, token []common.Address) (*IDepositDepositedIterator, error) { + + var userRule []interface{} + for _, userItem := range user { + userRule = append(userRule, userItem) + } + var tokenRule []interface{} + for _, tokenItem := range token { + tokenRule = append(tokenRule, tokenItem) + } + + logs, sub, err := _IDeposit.contract.FilterLogs(opts, "Deposited", userRule, tokenRule) + if err != nil { + return nil, err + } + return &IDepositDepositedIterator{contract: _IDeposit.contract, event: "Deposited", logs: logs, sub: sub}, nil +} + +// WatchDeposited is a free log subscription operation binding the contract event 0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7. +// +// Solidity: event Deposited(address indexed user, address indexed token, uint256 amount) +func (_IDeposit *IDepositFilterer) WatchDeposited(opts *bind.WatchOpts, sink chan<- *IDepositDeposited, user []common.Address, token []common.Address) (event.Subscription, error) { + + var userRule []interface{} + for _, userItem := range user { + userRule = append(userRule, userItem) + } + var tokenRule []interface{} + for _, tokenItem := range token { + tokenRule = append(tokenRule, tokenItem) + } + + logs, sub, err := _IDeposit.contract.WatchLogs(opts, "Deposited", userRule, tokenRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDepositDeposited) + if err := _IDeposit.contract.UnpackLog(event, "Deposited", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDeposited is a log parse operation binding the contract event 0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7. +// +// Solidity: event Deposited(address indexed user, address indexed token, uint256 amount) +func (_IDeposit *IDepositFilterer) ParseDeposited(log types.Log) (*IDepositDeposited, error) { + event := new(IDepositDeposited) + if err := _IDeposit.contract.UnpackLog(event, "Deposited", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/chain/iwithdraw.go b/custody/iwithdraw.go similarity index 99% rename from chain/iwithdraw.go rename to custody/iwithdraw.go index ea3f63f..a055393 100644 --- a/chain/iwithdraw.go +++ b/custody/iwithdraw.go @@ -1,7 +1,7 @@ // Code generated - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. -package chain +package custody import ( "errors" diff --git a/custody/listener.go b/custody/listener.go new file mode 100644 index 0000000..280aa4a --- /dev/null +++ b/custody/listener.go @@ -0,0 +1,387 @@ +package custody + +import ( + "context" + "errors" + "fmt" + "math/big" + "regexp" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" + logging "github.com/ipfs/go-log/v2" + "github.com/layer-3/clearsync/pkg/debounce" +) + +var listenerLogger = logging.Logger("custody-listener") + +const ( + maxBackOffCount = 5 +) + +// Listener handles monitoring the blockchain for events from the custody contract. +type Listener struct { + client bind.ContractBackend + contractAddr common.Address + withdrawFilterer *IWithdrawFilterer + depositFilterer *IDepositFilterer +} + +// NewListener creates a new Listener instance. +// client: an Ethereum client supporting log subscriptions (e.g. *ethclient.Client via WebSocket) +// contractAddr: address of the custody contract +// withdraw: bound IWithdraw contract instance +// deposit: bound IDeposit contract instance (can be nil if deposit events are not needed) +func NewListener(client bind.ContractBackend, contractAddr common.Address, withdraw *IWithdraw, deposit *IDeposit) *Listener { + l := &Listener{ + client: client, + contractAddr: contractAddr, + } + if withdraw != nil { + l.withdrawFilterer = &withdraw.IWithdrawFilterer + } + if deposit != nil { + l.depositFilterer = &deposit.IDepositFilterer + } + return l +} + +// Compile-time check that Listener implements EventListener. +var _ EventListener = (*Listener)(nil) + +// WatchWithdrawStarted subscribes to WithdrawStarted events and sends them to the sink channel. +// This function blocks forever; run it in a goroutine. The sink channel is closed when it returns. +func (l *Listener) WatchWithdrawStarted(ctx context.Context, sink chan<- *WithdrawStartedEvent, fromBlock uint64, fromLogIndex uint32) { + defer close(sink) + + parsedABI, err := IWithdrawMetaData.GetAbi() + if err != nil { + return + } + topic := parsedABI.Events["WithdrawStarted"].ID + + listenEvents(ctx, l.client, "withdraw-started", l.contractAddr, 0, fromBlock, fromLogIndex, + [][]common.Hash{{topic}}, + func(log types.Log) { + ev, err := l.withdrawFilterer.ParseWithdrawStarted(log) + if err != nil { + return + } + sink <- &WithdrawStartedEvent{ + WithdrawalID: ev.WithdrawalId, + User: ev.User, + Token: ev.Token, + Amount: ev.Amount, + Nonce: ev.Nonce, + BlockNumber: ev.Raw.BlockNumber, + TxHash: ev.Raw.TxHash, + LogIndex: ev.Raw.Index, + } + }, + ) +} + +// WatchWithdrawFinalized subscribes to WithdrawFinalized events and sends them to the sink channel. +// This function blocks forever; run it in a goroutine. The sink channel is closed when it returns. +func (l *Listener) WatchWithdrawFinalized(ctx context.Context, sink chan<- *WithdrawFinalizedEvent, fromBlock uint64, fromLogIndex uint32) { + defer close(sink) + + parsedABI, err := IWithdrawMetaData.GetAbi() + if err != nil { + return + } + topic := parsedABI.Events["WithdrawFinalized"].ID + + listenEvents(ctx, l.client, "withdraw-finalized", l.contractAddr, 0, fromBlock, fromLogIndex, + [][]common.Hash{{topic}}, + func(log types.Log) { + ev, err := l.withdrawFilterer.ParseWithdrawFinalized(log) + if err != nil { + return + } + sink <- &WithdrawFinalizedEvent{ + WithdrawalID: ev.WithdrawalId, + Success: ev.Success, + BlockNumber: ev.Raw.BlockNumber, + TxHash: ev.Raw.TxHash, + LogIndex: ev.Raw.Index, + } + }, + ) +} + +// WatchDeposited subscribes to Deposited events and sends them to the sink channel. +// This function blocks forever; run it in a goroutine. The sink channel is closed when it returns. +func (l *Listener) WatchDeposited(ctx context.Context, sink chan<- *DepositedEvent, fromBlock uint64, fromLogIndex uint32) { + defer close(sink) + + if l.depositFilterer == nil { + return + } + + parsedABI, err := IDepositMetaData.GetAbi() + if err != nil { + return + } + topic := parsedABI.Events["Deposited"].ID + + listenEvents(ctx, l.client, "deposited", l.contractAddr, 0, fromBlock, fromLogIndex, + [][]common.Hash{{topic}}, + func(log types.Log) { + ev, err := l.depositFilterer.ParseDeposited(log) + if err != nil { + return + } + sink <- &DepositedEvent{ + User: ev.User, + Token: ev.Token, + Amount: ev.Amount, + BlockNumber: ev.Raw.BlockNumber, + TxHash: ev.Raw.TxHash, + LogIndex: ev.Raw.Index, + } + }, + ) +} + +// listenEvents subscribes to on-chain logs matching the given topics and feeds them to handler. +// It handles reconnection with backoff, historical log reconciliation, and live subscription. +type logHandler func(log types.Log) + +func listenEvents( + ctx context.Context, + client bind.ContractBackend, + subID string, + contractAddress common.Address, + networkID uint32, + lastBlock uint64, + lastIndex uint32, + topics [][]common.Hash, + handler logHandler, +) { + var backOffCount atomic.Uint64 + var historicalCh, currentCh chan types.Log + var eventSubscription event.Subscription + + listenerLogger.Debugw("starting listening events", "subID", subID, "contractAddress", contractAddress.String()) + for { + if err := ctx.Err(); err != nil { + listenerLogger.Infow("context cancelled, stopping listener", "subID", subID) + if eventSubscription != nil { + eventSubscription.Unsubscribe() + } + return + } + + if eventSubscription == nil { + if !waitForBackOffTimeout(ctx, int(backOffCount.Load()), "event subscription") { + return + } + + historicalCh = make(chan types.Log, 1) + currentCh = make(chan types.Log, 100) + + if lastBlock == 0 { + listenerLogger.Infow("skipping historical logs fetching", "subID", subID, "contractAddress", contractAddress.String()) + } else { + var header *types.Header + var err error + headerCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) + err = debounce.Debounce(headerCtx, listenerLogger, func(ctx context.Context) error { + header, err = client.HeaderByNumber(ctx, nil) + return err + }) + cancel() + if err != nil { + if ctx.Err() != nil { + return + } + listenerLogger.Errorw("failed to get latest block", "error", err, "subID", subID, "contractAddress", contractAddress.String()) + backOffCount.Add(1) + continue + } + + go reconcileBlockRange( + ctx, + client, + subID, + contractAddress, + networkID, + header.Number.Uint64(), + lastBlock, + lastIndex, + topics, + historicalCh, + ) + } + + watchFQ := ethereum.FilterQuery{ + Addresses: []common.Address{contractAddress}, + } + eventSub, err := client.SubscribeFilterLogs(ctx, watchFQ, currentCh) + if err != nil { + if ctx.Err() != nil { + return + } + listenerLogger.Errorw("failed to subscribe on events", "error", err, "subID", subID, "contractAddress", contractAddress.String()) + backOffCount.Add(1) + continue + } + + eventSubscription = eventSub + listenerLogger.Infow("watching events", "subID", subID, "contractAddress", contractAddress.String()) + backOffCount.Store(0) + } + + select { + case <-ctx.Done(): + listenerLogger.Infow("context cancelled, stopping listener", "subID", subID) + eventSubscription.Unsubscribe() + return + case eventLog := <-historicalCh: + listenerLogger.Debugw("received historical event", "subID", subID, "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index) + handler(eventLog) + case eventLog := <-currentCh: + lastBlock = eventLog.BlockNumber + listenerLogger.Debugw("received new event", "subID", subID, "blockNumber", lastBlock, "logIndex", eventLog.Index) + handler(eventLog) + case err := <-eventSubscription.Err(): + if err != nil { + listenerLogger.Errorw("event subscription error", "error", err, "subID", subID, "contractAddress", contractAddress.String()) + eventSubscription.Unsubscribe() + } else { + listenerLogger.Debugw("subscription closed, resubscribing", "subID", subID, "contractAddress", contractAddress.String()) + } + + eventSubscription = nil + } + } +} + +func reconcileBlockRange( + ctx context.Context, + client bind.ContractBackend, + subID string, + contractAddress common.Address, + networkID uint32, + currentBlock uint64, + lastBlock uint64, + lastIndex uint32, + topics [][]common.Hash, + historicalCh chan types.Log, +) { + var backOffCount atomic.Uint64 + const blockStep = 10000 + startBlock := lastBlock + endBlock := startBlock + blockStep + + for currentBlock > startBlock { + if ctx.Err() != nil { + return + } + if !waitForBackOffTimeout(ctx, int(backOffCount.Load()), "reconcile block range") { + return + } + + if endBlock > currentBlock { + endBlock = currentBlock + } + + fetchFQ := ethereum.FilterQuery{ + Addresses: []common.Address{contractAddress}, + FromBlock: new(big.Int).SetUint64(startBlock), + ToBlock: new(big.Int).SetUint64(endBlock), + Topics: topics, + } + + var logs []types.Log + var err error + logsCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) + err = debounce.Debounce(logsCtx, listenerLogger, func(ctx context.Context) error { + logs, err = client.FilterLogs(ctx, fetchFQ) + return err + }) + cancel() + if err != nil { + if strings.Contains(err.Error(), "Exceeded max range limit for eth_getLogs:") { + newEndBlock := endBlock - (endBlock-startBlock)/2 + listenerLogger.Infow("eth_getLogs exceeded max range limit, reducing block range", "subID", subID, "startBlock", startBlock, "oldEndBlock", endBlock, "newEndBlock", newEndBlock) + endBlock = newEndBlock + continue + } + + newStartBlock, newEndBlock, extractErr := extractAdvisedBlockRange(err.Error()) + if extractErr != nil { + listenerLogger.Errorw("failed to filter logs", "error", err, "extractErr", extractErr, "subID", subID, "startBlock", startBlock, "endBlock", endBlock) + backOffCount.Add(1) + continue + } + startBlock, endBlock = newStartBlock, newEndBlock + listenerLogger.Infow("retrying with advised block range", "subID", subID, "startBlock", startBlock, "endBlock", endBlock) + continue + } + listenerLogger.Infow("fetched historical logs", "subID", subID, "count", len(logs), "startBlock", startBlock, "endBlock", endBlock) + + for _, ethLog := range logs { + if ethLog.BlockNumber == lastBlock && ethLog.Index <= uint(lastIndex) { + listenerLogger.Infow("skipping previously known event", "subID", subID, "blockNumber", ethLog.BlockNumber, "logIndex", ethLog.Index) + continue + } + + historicalCh <- ethLog + } + + startBlock = endBlock + 1 + endBlock += blockStep + } +} + +func extractAdvisedBlockRange(msg string) (startBlock, endBlock uint64, err error) { + if !strings.Contains(msg, "query returned more than 10000 results") { + err = errors.New("error message doesn't contain advised block range") + return + } + + re := regexp.MustCompile(`\[0x([0-9a-fA-F]+), 0x([0-9a-fA-F]+)\]`) + match := re.FindStringSubmatch(msg) + if len(match) != 3 { + err = errors.New("failed to extract block range from error message") + return + } + + startBlock, err = strconv.ParseUint(match[1], 16, 64) + if err != nil { + err = fmt.Errorf("failed to parse block range from error message: %w", err) + return + } + endBlock, err = strconv.ParseUint(match[2], 16, 64) + if err != nil { + err = fmt.Errorf("failed to parse block range from error message: %w", err) + return + } + return +} + +func waitForBackOffTimeout(ctx context.Context, backOffCount int, originator string) bool { + if backOffCount > maxBackOffCount { + listenerLogger.Errorw("back off limit reached, exiting", "originator", originator, "backOffCount", backOffCount) + return true + } + + if backOffCount > 0 { + listenerLogger.Infow("backing off", "originator", originator, "backOffCount", backOffCount) + select { + case <-time.After(time.Duration(2^backOffCount-1) * time.Second): + case <-ctx.Done(): + return false + } + } + return true +} diff --git a/chain/simple_custody.go b/custody/simple_custody.go similarity index 99% rename from chain/simple_custody.go rename to custody/simple_custody.go index b3acf12..5f68f9a 100644 --- a/chain/simple_custody.go +++ b/custody/simple_custody.go @@ -1,7 +1,7 @@ // Code generated - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. -package chain +package custody import ( "errors" @@ -32,7 +32,7 @@ var ( // SimpleCustodyMetaData contains all meta data concerning the SimpleCustody contract. var SimpleCustodyMetaData = &bind.MetaData{ ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"admin\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"neodax\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nitewatch\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"DEFAULT_ADMIN_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"NEODAX_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"NITEWATCH_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deposit\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"finalizeWithdraw\",\"inputs\":[{\"name\":\"withdrawalId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getRoleAdmin\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"grantRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"hasRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"rejectWithdraw\",\"inputs\":[{\"name\":\"withdrawalId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"callerConfirmation\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"revokeRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"startWithdraw\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"withdrawalId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdrawals\",\"inputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"exists\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"finalized\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"Deposited\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleAdminChanged\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"previousAdminRole\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"newAdminRole\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleGranted\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleRevoked\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"WithdrawFinalized\",\"inputs\":[{\"name\":\"withdrawalId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"WithdrawStarted\",\"inputs\":[{\"name\":\"withdrawalId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccessControlBadConfirmation\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccessControlUnauthorizedAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"neededRole\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"ETHTransferFailed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientLiquidity\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MsgValueMismatch\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NonZeroMsgValueForERC20\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"WithdrawalAlreadyExists\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WithdrawalAlreadyFinalized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WithdrawalNotFound\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroAmount\",\"inputs\":[]}]", - Bin: "0x608060405234801561000f575f5ffd5b50604051611d9c380380611d9c833981810160405281019061003191906102c1565b600161004f6100446100d260201b60201c565b6100fb60201b60201c565b5f01819055506100675f5f1b8461010460201b60201c565b506100987f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f38361010460201b60201c565b506100c97ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa8261010460201b60201c565b50505050610311565b5f7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005f1b905090565b5f819050919050565b5f61011583836101f960201b60201c565b6101ef5760015f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff02191690831515021790555061018c61025c60201b60201c565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a4600190506101f3565b5f90505b92915050565b5f5f5f8481526020019081526020015f205f015f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff16905092915050565b5f33905090565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61029082610267565b9050919050565b6102a081610286565b81146102aa575f5ffd5b50565b5f815190506102bb81610297565b92915050565b5f5f5f606084860312156102d8576102d7610263565b5b5f6102e5868287016102ad565b93505060206102f6868287016102ad565b9250506040610307868287016102ad565b9150509250925092565b611a7e8061031e5f395ff3fe6080604052600436106100dc575f3560e01c80635a98c2231161007e578063d547741f11610058578063d547741f146102a4578063d87e1f41146102cc578063da86f31514610308578063efbf64a714610332576100dc565b80635a98c2231461021457806391d148541461023e578063a217fddf1461027a576100dc565b8063248a9ca3116100ba578063248a9ca31461016c5780632f2ff15d146101a857806336568abe146101d057806347e7ef24146101f8576100dc565b806301ffc9a7146100e057806305e95be71461011c57806311edc78f14610144575b5f5ffd5b3480156100eb575f5ffd5b50610106600480360381019061010191906115c9565b610372565b604051610113919061160e565b60405180910390f35b348015610127575f5ffd5b50610142600480360381019061013d919061165a565b6103eb565b005b34801561014f575f5ffd5b5061016a6004803603810190610165919061165a565b6107f7565b005b348015610177575f5ffd5b50610192600480360381019061018d919061165a565b61092f565b60405161019f9190611694565b60405180910390f35b3480156101b3575f5ffd5b506101ce60048036038101906101c99190611707565b61094b565b005b3480156101db575f5ffd5b506101f660048036038101906101f19190611707565b61096d565b005b610212600480360381019061020d9190611778565b6109e8565b005b34801561021f575f5ffd5b50610228610c78565b6040516102359190611694565b60405180910390f35b348015610249575f5ffd5b50610264600480360381019061025f9190611707565b610c9c565b604051610271919061160e565b60405180910390f35b348015610285575f5ffd5b5061028e610cff565b60405161029b9190611694565b60405180910390f35b3480156102af575f5ffd5b506102ca60048036038101906102c59190611707565b610d05565b005b3480156102d7575f5ffd5b506102f260048036038101906102ed91906117b6565b610d27565b6040516102ff9190611694565b60405180910390f35b348015610313575f5ffd5b5061031c610fd6565b6040516103299190611694565b60405180910390f35b34801561033d575f5ffd5b506103586004803603810190610353919061165a565b610ffa565b604051610369959493929190611838565b60405180910390f35b5f7f7965db0b000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614806103e457506103e382611083565b5b9050919050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa610415816110ec565b61041d611100565b5f60015f8481526020019081526020015f209050806003015f9054906101000a900460ff16610478576040517f8d0fc1dd00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060030160019054906101000a900460ff16156104c1576040517fae89945400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60018160030160016101000a81548160ff0219169083151502179055505f815f015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505f826001015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505f836002015490505f845f015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f846001015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f84600201819055505f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036106d1578047101561062c576040517fbb55fd2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8373ffffffffffffffffffffffffffffffffffffffff1682604051610651906118b6565b5f6040518083038185875af1925050503d805f811461068b576040519150601f19603f3d011682016040523d82523d5f602084013e610690565b606091505b50509050806106cb576040517fb12d13eb00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b506107ae565b808273ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b815260040161070b91906118ca565b602060405180830381865afa158015610726573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061074a91906118f7565b1015610782576040517fbb55fd2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6107ad83828473ffffffffffffffffffffffffffffffffffffffff166111229092919063ffffffff16565b5b857f150e5422471a0e0b0bf81bb0c466ec4b78850d2feeea6955c7e5eb33468a9c9c60016040516107df919061160e565b60405180910390a2505050506107f3611175565b5050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa610821816110ec565b610829611100565b5f60015f8481526020019081526020015f209050806003015f9054906101000a900460ff16610884576040517f8d0fc1dd00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060030160019054906101000a900460ff16156108cd576040517fae89945400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60018160030160016101000a81548160ff021916908315150217905550827f150e5422471a0e0b0bf81bb0c466ec4b78850d2feeea6955c7e5eb33468a9c9c5f60405161091a919061160e565b60405180910390a25061092b611175565b5050565b5f5f5f8381526020019081526020015f20600101549050919050565b6109548261092f565b61095d816110ec565b610967838361118f565b50505050565b610975611278565b73ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146109d9576040517f6697b23200000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6109e3828261127f565b505050565b6109f0611100565b5f8103610a29576040517f1f2a200500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8190505f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610a9e57813414610a99576040517fbc6f88c500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b610c06565b5f3414610ad7576040517fa57ec87300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8373ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b8152600401610b1191906118ca565b602060405180830381865afa158015610b2c573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b5091906118f7565b9050610b7f3330858773ffffffffffffffffffffffffffffffffffffffff16611368909392919063ffffffff16565b808473ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b8152600401610bb991906118ca565b602060405180830381865afa158015610bd4573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610bf891906118f7565b610c02919061194f565b9150505b8273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a783604051610c639190611982565b60405180910390a350610c74611175565b5050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa81565b5f5f5f8481526020019081526020015f205f015f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff16905092915050565b5f5f1b81565b610d0e8261092f565b610d17816110ec565b610d21838361127f565b50505050565b5f7f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f3610d52816110ec565b610d5a611100565b5f8403610d93576040517f1f2a200500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b463087878787604051602001610dae9695949392919061199b565b60405160208183030381529060405280519060200120915060015f8381526020019081526020015f206003015f9054906101000a900460ff1615610e1e576040517f157c65e100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6040518060a001604052808773ffffffffffffffffffffffffffffffffffffffff1681526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018581526020016001151581526020015f151581525060015f8481526020019081526020015f205f820151815f015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506020820151816001015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550604082015181600201556060820151816003015f6101000a81548160ff02191690831515021790555060808201518160030160016101000a81548160ff0219169083151502179055509050508473ffffffffffffffffffffffffffffffffffffffff168673ffffffffffffffffffffffffffffffffffffffff16837f669c87d38156449c65caf07041b1568372d50fc03f2cc46add1d68cebc2eb9898787604051610fbd9291906119fa565b60405180910390a4610fcd611175565b50949350505050565b7f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f381565b6001602052805f5260405f205f91509050805f015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690806001015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690806002015490806003015f9054906101000a900460ff16908060030160019054906101000a900460ff16905085565b5f7f01ffc9a7000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916149050919050565b6110fd816110f8611278565b6113bd565b50565b61110861140e565b600261111a61111561144f565b611478565b5f0181905550565b61112f8383836001611481565b61117057826040517f5274afe700000000000000000000000000000000000000000000000000000000815260040161116791906118ca565b60405180910390fd5b505050565b600161118761118261144f565b611478565b5f0181905550565b5f61119a8383610c9c565b61126e5760015f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff02191690831515021790555061120b611278565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a460019050611272565b5f90505b92915050565b5f33905090565b5f61128a8383610c9c565b1561135e575f5f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff0219169083151502179055506112fb611278565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b60405160405180910390a460019050611362565b5f90505b92915050565b6113768484848460016114e3565b6113b757836040517f5274afe70000000000000000000000000000000000000000000000000000000081526004016113ae91906118ca565b60405180910390fd5b50505050565b6113c78282610c9c565b61140a5780826040517fe2517d3f000000000000000000000000000000000000000000000000000000008152600401611401929190611a21565b60405180910390fd5b5050565b611416611554565b1561144d576040517f3ee5aeb500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b565b5f7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005f1b905090565b5f819050919050565b5f5f63a9059cbb60e01b9050604051815f525f1960601c86166004528460245260205f60445f5f8b5af1925060015f511483166114d55783831516156114c9573d5f823e3d81fd5b5f873b113d1516831692505b806040525050949350505050565b5f5f6323b872dd60e01b9050604051815f525f1960601c87166004525f1960601c86166024528460445260205f60645f5f8c5af1925060015f51148316611541578383151615611535573d5f823e3d81fd5b5f883b113d1516831692505b806040525f606052505095945050505050565b5f600261156761156261144f565b611478565b5f015414905090565b5f5ffd5b5f7fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6115a881611574565b81146115b2575f5ffd5b50565b5f813590506115c38161159f565b92915050565b5f602082840312156115de576115dd611570565b5b5f6115eb848285016115b5565b91505092915050565b5f8115159050919050565b611608816115f4565b82525050565b5f6020820190506116215f8301846115ff565b92915050565b5f819050919050565b61163981611627565b8114611643575f5ffd5b50565b5f8135905061165481611630565b92915050565b5f6020828403121561166f5761166e611570565b5b5f61167c84828501611646565b91505092915050565b61168e81611627565b82525050565b5f6020820190506116a75f830184611685565b92915050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6116d6826116ad565b9050919050565b6116e6816116cc565b81146116f0575f5ffd5b50565b5f81359050611701816116dd565b92915050565b5f5f6040838503121561171d5761171c611570565b5b5f61172a85828601611646565b925050602061173b858286016116f3565b9150509250929050565b5f819050919050565b61175781611745565b8114611761575f5ffd5b50565b5f813590506117728161174e565b92915050565b5f5f6040838503121561178e5761178d611570565b5b5f61179b858286016116f3565b92505060206117ac85828601611764565b9150509250929050565b5f5f5f5f608085870312156117ce576117cd611570565b5b5f6117db878288016116f3565b94505060206117ec878288016116f3565b93505060406117fd87828801611764565b925050606061180e87828801611764565b91505092959194509250565b611823816116cc565b82525050565b61183281611745565b82525050565b5f60a08201905061184b5f83018861181a565b611858602083018761181a565b6118656040830186611829565b61187260608301856115ff565b61187f60808301846115ff565b9695505050505050565b5f81905092915050565b50565b5f6118a15f83611889565b91506118ac82611893565b5f82019050919050565b5f6118c082611896565b9150819050919050565b5f6020820190506118dd5f83018461181a565b92915050565b5f815190506118f18161174e565b92915050565b5f6020828403121561190c5761190b611570565b5b5f611919848285016118e3565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61195982611745565b915061196483611745565b925082820390508181111561197c5761197b611922565b5b92915050565b5f6020820190506119955f830184611829565b92915050565b5f60c0820190506119ae5f830189611829565b6119bb602083018861181a565b6119c8604083018761181a565b6119d5606083018661181a565b6119e26080830185611829565b6119ef60a0830184611829565b979650505050505050565b5f604082019050611a0d5f830185611829565b611a1a6020830184611829565b9392505050565b5f604082019050611a345f83018561181a565b611a416020830184611685565b939250505056fea2646970667358221220675434c4bbddcb90fe2fd6368a14981a2f3c3f613b72899e2626a1e12ecf1b0664736f6c634300081e0033", + Bin: "0x608060405234801561000f575f5ffd5b50604051611d9c380380611d9c833981810160405281019061003191906102c1565b600161004f6100446100d260201b60201c565b6100fb60201b60201c565b5f01819055506100675f5f1b8461010460201b60201c565b506100987f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f38361010460201b60201c565b506100c97ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa8261010460201b60201c565b50505050610311565b5f7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005f1b905090565b5f819050919050565b5f61011583836101f960201b60201c565b6101ef5760015f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff02191690831515021790555061018c61025c60201b60201c565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a4600190506101f3565b5f90505b92915050565b5f5f5f8481526020019081526020015f205f015f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff16905092915050565b5f33905090565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61029082610267565b9050919050565b6102a081610286565b81146102aa575f5ffd5b50565b5f815190506102bb81610297565b92915050565b5f5f5f606084860312156102d8576102d7610263565b5b5f6102e5868287016102ad565b93505060206102f6868287016102ad565b9250506040610307868287016102ad565b9150509250925092565b611a7e8061031e5f395ff3fe6080604052600436106100dc575f3560e01c80635a98c2231161007e578063d547741f11610058578063d547741f146102a4578063d87e1f41146102cc578063da86f31514610308578063efbf64a714610332576100dc565b80635a98c2231461021457806391d148541461023e578063a217fddf1461027a576100dc565b8063248a9ca3116100ba578063248a9ca31461016c5780632f2ff15d146101a857806336568abe146101d057806347e7ef24146101f8576100dc565b806301ffc9a7146100e057806305e95be71461011c57806311edc78f14610144575b5f5ffd5b3480156100eb575f5ffd5b50610106600480360381019061010191906115c9565b610372565b604051610113919061160e565b60405180910390f35b348015610127575f5ffd5b50610142600480360381019061013d919061165a565b6103eb565b005b34801561014f575f5ffd5b5061016a6004803603810190610165919061165a565b6107f7565b005b348015610177575f5ffd5b50610192600480360381019061018d919061165a565b61092f565b60405161019f9190611694565b60405180910390f35b3480156101b3575f5ffd5b506101ce60048036038101906101c99190611707565b61094b565b005b3480156101db575f5ffd5b506101f660048036038101906101f19190611707565b61096d565b005b610212600480360381019061020d9190611778565b6109e8565b005b34801561021f575f5ffd5b50610228610c78565b6040516102359190611694565b60405180910390f35b348015610249575f5ffd5b50610264600480360381019061025f9190611707565b610c9c565b604051610271919061160e565b60405180910390f35b348015610285575f5ffd5b5061028e610cff565b60405161029b9190611694565b60405180910390f35b3480156102af575f5ffd5b506102ca60048036038101906102c59190611707565b610d05565b005b3480156102d7575f5ffd5b506102f260048036038101906102ed91906117b6565b610d27565b6040516102ff9190611694565b60405180910390f35b348015610313575f5ffd5b5061031c610fd6565b6040516103299190611694565b60405180910390f35b34801561033d575f5ffd5b506103586004803603810190610353919061165a565b610ffa565b604051610369959493929190611838565b60405180910390f35b5f7f7965db0b000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614806103e457506103e382611083565b5b9050919050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa610415816110ec565b61041d611100565b5f60015f8481526020019081526020015f209050806003015f9054906101000a900460ff16610478576040517f8d0fc1dd00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060030160019054906101000a900460ff16156104c1576040517fae89945400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60018160030160016101000a81548160ff0219169083151502179055505f815f015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505f826001015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690505f836002015490505f845f015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f846001015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505f84600201819055505f73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036106d1578047101561062c576040517fbb55fd2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8373ffffffffffffffffffffffffffffffffffffffff1682604051610651906118b6565b5f6040518083038185875af1925050503d805f811461068b576040519150601f19603f3d011682016040523d82523d5f602084013e610690565b606091505b50509050806106cb576040517fb12d13eb00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b506107ae565b808273ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b815260040161070b91906118ca565b602060405180830381865afa158015610726573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061074a91906118f7565b1015610782576040517fbb55fd2700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6107ad83828473ffffffffffffffffffffffffffffffffffffffff166111229092919063ffffffff16565b5b857f150e5422471a0e0b0bf81bb0c466ec4b78850d2feeea6955c7e5eb33468a9c9c60016040516107df919061160e565b60405180910390a2505050506107f3611175565b5050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa610821816110ec565b610829611100565b5f60015f8481526020019081526020015f209050806003015f9054906101000a900460ff16610884576040517f8d0fc1dd00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060030160019054906101000a900460ff16156108cd576040517fae89945400000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60018160030160016101000a81548160ff021916908315150217905550827f150e5422471a0e0b0bf81bb0c466ec4b78850d2feeea6955c7e5eb33468a9c9c5f60405161091a919061160e565b60405180910390a25061092b611175565b5050565b5f5f5f8381526020019081526020015f20600101549050919050565b6109548261092f565b61095d816110ec565b610967838361118f565b50505050565b610975611278565b73ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146109d9576040517f6697b23200000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6109e3828261127f565b505050565b6109f0611100565b5f8103610a29576040517f1f2a200500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8190505f73ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1603610a9e57813414610a99576040517fbc6f88c500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b610c06565b5f3414610ad7576040517fa57ec87300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f8373ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b8152600401610b1191906118ca565b602060405180830381865afa158015610b2c573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610b5091906118f7565b9050610b7f3330858773ffffffffffffffffffffffffffffffffffffffff16611368909392919063ffffffff16565b808473ffffffffffffffffffffffffffffffffffffffff166370a08231306040518263ffffffff1660e01b8152600401610bb991906118ca565b602060405180830381865afa158015610bd4573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610bf891906118f7565b610c02919061194f565b9150505b8273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a783604051610c639190611982565b60405180910390a350610c74611175565b5050565b7ff42609614d16e60ed8a62ea70f772fc08fb4f581d8126a6aeae13d7aee25daaa81565b5f5f5f8481526020019081526020015f205f015f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff16905092915050565b5f5f1b81565b610d0e8261092f565b610d17816110ec565b610d21838361127f565b50505050565b5f7f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f3610d52816110ec565b610d5a611100565b5f8403610d93576040517f1f2a200500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b463087878787604051602001610dae9695949392919061199b565b60405160208183030381529060405280519060200120915060015f8381526020019081526020015f206003015f9054906101000a900460ff1615610e1e576040517f157c65e100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6040518060a001604052808773ffffffffffffffffffffffffffffffffffffffff1681526020018673ffffffffffffffffffffffffffffffffffffffff1681526020018581526020016001151581526020015f151581525060015f8481526020019081526020015f205f820151815f015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506020820151816001015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550604082015181600201556060820151816003015f6101000a81548160ff02191690831515021790555060808201518160030160016101000a81548160ff0219169083151502179055509050508473ffffffffffffffffffffffffffffffffffffffff168673ffffffffffffffffffffffffffffffffffffffff16837f669c87d38156449c65caf07041b1568372d50fc03f2cc46add1d68cebc2eb9898787604051610fbd9291906119fa565b60405180910390a4610fcd611175565b50949350505050565b7f7f207140ff521d8790ff51fbcb7b65fa00c82600e052949aeb1de1aeceafd4f381565b6001602052805f5260405f205f91509050805f015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690806001015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690806002015490806003015f9054906101000a900460ff16908060030160019054906101000a900460ff16905085565b5f7f01ffc9a7000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916827bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1916149050919050565b6110fd816110f8611278565b6113bd565b50565b61110861140e565b600261111a61111561144f565b611478565b5f0181905550565b61112f8383836001611481565b61117057826040517f5274afe700000000000000000000000000000000000000000000000000000000815260040161116791906118ca565b60405180910390fd5b505050565b600161118761118261144f565b611478565b5f0181905550565b5f61119a8383610c9c565b61126e5760015f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff02191690831515021790555061120b611278565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a460019050611272565b5f90505b92915050565b5f33905090565b5f61128a8383610c9c565b1561135e575f5f5f8581526020019081526020015f205f015f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff0219169083151502179055506112fb611278565b73ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16847ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b60405160405180910390a460019050611362565b5f90505b92915050565b6113768484848460016114e3565b6113b757836040517f5274afe70000000000000000000000000000000000000000000000000000000081526004016113ae91906118ca565b60405180910390fd5b50505050565b6113c78282610c9c565b61140a5780826040517fe2517d3f000000000000000000000000000000000000000000000000000000008152600401611401929190611a21565b60405180910390fd5b5050565b611416611554565b1561144d576040517f3ee5aeb500000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b565b5f7f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f005f1b905090565b5f819050919050565b5f5f63a9059cbb60e01b9050604051815f525f1960601c86166004528460245260205f60445f5f8b5af1925060015f511483166114d55783831516156114c9573d5f823e3d81fd5b5f873b113d1516831692505b806040525050949350505050565b5f5f6323b872dd60e01b9050604051815f525f1960601c87166004525f1960601c86166024528460445260205f60645f5f8c5af1925060015f51148316611541578383151615611535573d5f823e3d81fd5b5f883b113d1516831692505b806040525f606052505095945050505050565b5f600261156761156261144f565b611478565b5f015414905090565b5f5ffd5b5f7fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6115a881611574565b81146115b2575f5ffd5b50565b5f813590506115c38161159f565b92915050565b5f602082840312156115de576115dd611570565b5b5f6115eb848285016115b5565b91505092915050565b5f8115159050919050565b611608816115f4565b82525050565b5f6020820190506116215f8301846115ff565b92915050565b5f819050919050565b61163981611627565b8114611643575f5ffd5b50565b5f8135905061165481611630565b92915050565b5f6020828403121561166f5761166e611570565b5b5f61167c84828501611646565b91505092915050565b61168e81611627565b82525050565b5f6020820190506116a75f830184611685565b92915050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6116d6826116ad565b9050919050565b6116e6816116cc565b81146116f0575f5ffd5b50565b5f81359050611701816116dd565b92915050565b5f5f6040838503121561171d5761171c611570565b5b5f61172a85828601611646565b925050602061173b858286016116f3565b9150509250929050565b5f819050919050565b61175781611745565b8114611761575f5ffd5b50565b5f813590506117728161174e565b92915050565b5f5f6040838503121561178e5761178d611570565b5b5f61179b858286016116f3565b92505060206117ac85828601611764565b9150509250929050565b5f5f5f5f608085870312156117ce576117cd611570565b5b5f6117db878288016116f3565b94505060206117ec878288016116f3565b93505060406117fd87828801611764565b925050606061180e87828801611764565b91505092959194509250565b611823816116cc565b82525050565b61183281611745565b82525050565b5f60a08201905061184b5f83018861181a565b611858602083018761181a565b6118656040830186611829565b61187260608301856115ff565b61187f60808301846115ff565b9695505050505050565b5f81905092915050565b50565b5f6118a15f83611889565b91506118ac82611893565b5f82019050919050565b5f6118c082611896565b9150819050919050565b5f6020820190506118dd5f83018461181a565b92915050565b5f815190506118f18161174e565b92915050565b5f6020828403121561190c5761190b611570565b5b5f611919848285016118e3565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61195982611745565b915061196483611745565b925082820390508181111561197c5761197b611922565b5b92915050565b5f6020820190506119955f830184611829565b92915050565b5f60c0820190506119ae5f830189611829565b6119bb602083018861181a565b6119c8604083018761181a565b6119d5606083018661181a565b6119e26080830185611829565b6119ef60a0830184611829565b979650505050505050565b5f604082019050611a0d5f830185611829565b611a1a6020830184611829565b9392505050565b5f604082019050611a345f83018561181a565b611a416020830184611685565b939250505056fea26469706673582212202054cb52e1a19f531553465944e2440f89282f51db42df773bd1c267d4788c2a64736f6c634300081e0033", } // SimpleCustodyABI is the input ABI used to generate the binding from. diff --git a/interfaces.go b/custody/types.go similarity index 61% rename from interfaces.go rename to custody/types.go index e19b1a9..3a6c096 100644 --- a/interfaces.go +++ b/custody/types.go @@ -1,4 +1,4 @@ -package nitewatch +package custody import ( "context" @@ -10,8 +10,6 @@ import ( "github.com/ethereum/go-ethereum/core/types" ) -// --- Domain Events --- - // WithdrawStartedEvent represents a confirmed WithdrawStarted event from the custody contract. type WithdrawStartedEvent struct { WithdrawalID [32]byte @@ -21,6 +19,7 @@ type WithdrawStartedEvent struct { Nonce *big.Int BlockNumber uint64 TxHash common.Hash + LogIndex uint } // WithdrawFinalizedEvent represents a confirmed WithdrawFinalized event from the custody contract. @@ -29,9 +28,18 @@ type WithdrawFinalizedEvent struct { Success bool BlockNumber uint64 TxHash common.Hash + LogIndex uint } -// --- Domain Model --- +// DepositedEvent represents a confirmed Deposited event from the custody contract. +type DepositedEvent struct { + User common.Address + Token common.Address + Amount *big.Int + BlockNumber uint64 + TxHash common.Hash + LogIndex uint +} // Withdrawal represents a recorded withdrawal for limit tracking. type Withdrawal struct { @@ -44,25 +52,34 @@ type Withdrawal struct { Timestamp time.Time } -// --- Interfaces --- - // Custody defines the write operations for the IWithdraw smart contract. -// Cage uses StartWithdraw; Nitewatch uses FinalizeWithdraw and RejectWithdraw. type Custody interface { StartWithdraw(opts *bind.TransactOpts, user common.Address, token common.Address, amount *big.Int, nonce *big.Int) (*types.Transaction, error) FinalizeWithdraw(opts *bind.TransactOpts, withdrawalId [32]byte) (*types.Transaction, error) RejectWithdraw(opts *bind.TransactOpts, withdrawalId [32]byte) (*types.Transaction, error) } -// EventListener defines the ability to subscribe to IWithdraw contract events. -// The sink channel is closed when the context is cancelled or an error occurs. +// EventListener defines the ability to subscribe to custody contract events. +// Each method blocks until the context is cancelled; callers should run them in goroutines. +// The sink channel is closed when the method returns. type EventListener interface { - WatchWithdrawStarted(ctx context.Context, sink chan<- *WithdrawStartedEvent) error - WatchWithdrawFinalized(ctx context.Context, sink chan<- *WithdrawFinalizedEvent) error + WatchWithdrawStarted(ctx context.Context, sink chan<- *WithdrawStartedEvent, fromBlock uint64, fromLogIndex uint32) + WatchWithdrawFinalized(ctx context.Context, sink chan<- *WithdrawFinalizedEvent, fromBlock uint64, fromLogIndex uint32) + WatchDeposited(ctx context.Context, sink chan<- *DepositedEvent, fromBlock uint64, fromLogIndex uint32) } // WithdrawalStore defines the storage operations for tracking withdrawals. type WithdrawalStore interface { Save(w *Withdrawal) error GetTotalWithdrawn(token common.Address, since time.Time) (*big.Int, error) + GetTotalWithdrawnByUser(user common.Address, token common.Address, since time.Time) (*big.Int, error) +} + +// EthBackend is the Ethereum client interface required by the service. +// Both *ethclient.Client and simulated.Client satisfy this interface. +type EthBackend interface { + bind.ContractBackend + bind.DeployBackend + ChainID(ctx context.Context) (*big.Int, error) + Close() } diff --git a/go.mod b/go.mod index 359d963..c33a842 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,29 @@ go 1.25.6 require ( github.com/ethereum/go-ethereum v1.16.8 + github.com/gin-gonic/gin v1.10.0 + github.com/ipfs/go-log/v2 v2.9.1 + github.com/layer-3/clearsync v0.0.129 + github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.18.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( - github.com/DataDog/zstd v1.4.5 // indirect + github.com/DataDog/zstd v1.5.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect github.com/cockroachdb/errors v1.11.3 // indirect github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect @@ -31,22 +40,28 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dchest/siphash v1.2.3 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/emicklei/dot v1.6.2 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/getsentry/sentry-go v0.27.0 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.4 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/go-bexpr v0.1.10 // indirect github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect @@ -55,10 +70,12 @@ require ( github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.16.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect @@ -67,19 +84,23 @@ require ( github.com/minio/sha256-simd v1.0.0 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/pointerstructure v1.2.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pion/dtls/v2 v2.2.7 // indirect github.com/pion/logging v0.2.2 // indirect github.com/pion/stun/v2 v2.0.0 // indirect github.com/pion/transport/v2 v2.2.1 // indirect github.com/pion/transport/v3 v3.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.15.0 // indirect - github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/cors v1.7.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect @@ -87,14 +108,18 @@ require ( github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect github.com/urfave/cli/v2 v2.27.5 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - golang.org/x/crypto v0.44.0 // indirect - golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index f007a0b..612be12 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= +github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= @@ -14,10 +14,18 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -48,10 +56,10 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= -github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= @@ -70,15 +78,31 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -86,7 +110,6 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= @@ -103,12 +126,13 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= @@ -128,27 +152,37 @@ github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSH github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/ipfs/go-log/v2 v2.9.1 h1:3JXwHWU31dsCpvQ+7asz6/QsFJHqFr4gLgQ0FWteujk= +github.com/ipfs/go-log/v2 v2.9.1/go.mod h1:evFx7sBiohUN3AG12mXlZBw5hacBQld3ZPHrowlJYoo= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= -github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/layer-3/clearsync v0.0.129 h1:lB/KrxdHvWoG/0QJSwYXf4vGAgG4Z7NcYkrbpnCiIow= +github.com/layer-3/clearsync v0.0.129/go.mod h1:s/bT4t3kAK+ZrRtUclhY13kpyurOF71/lCdQ5BOwNBI= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -167,6 +201,11 @@ github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxd github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -180,6 +219,8 @@ github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -201,8 +242,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= @@ -212,8 +253,8 @@ github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HD github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -223,13 +264,17 @@ github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= @@ -238,6 +283,10 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -247,16 +296,23 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= -golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -274,8 +330,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -309,8 +365,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -324,8 +380,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -366,3 +422,5 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/checker/checker.go b/internal/checker/checker.go new file mode 100644 index 0000000..e92a7c1 --- /dev/null +++ b/internal/checker/checker.go @@ -0,0 +1,151 @@ +package checker + +import ( + "errors" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/layer-3/nitewatch/custody" +) + +var ( + ErrNoLimitsConfigured = errors.New("no limits configured for token") + ErrHourlyLimitExceeded = errors.New("hourly limit exceeded") + ErrDailyLimitExceeded = errors.New("daily limit exceeded") + ErrUserHourlyLimitExceeded = errors.New("per-user hourly limit exceeded") + ErrUserDailyLimitExceeded = errors.New("per-user daily limit exceeded") + ErrInvalidAmount = errors.New("amount must be positive") + ErrInvalidUser = errors.New("user address must not be zero") +) + +type Limit struct { + Hourly *big.Int + Daily *big.Int +} + +type Checker struct { + globalLimits map[common.Address]Limit + userOverrides map[common.Address]map[common.Address]Limit + store custody.WithdrawalStore + nowFunc func() time.Time +} + +func New( + globalLimits map[common.Address]Limit, + userOverrides map[common.Address]map[common.Address]Limit, + store custody.WithdrawalStore, +) *Checker { + return &Checker{ + globalLimits: globalLimits, + userOverrides: userOverrides, + store: store, + nowFunc: time.Now, + } +} + +func (c *Checker) Check(user common.Address, token common.Address, amount *big.Int) error { + if amount.Sign() <= 0 { + return ErrInvalidAmount + } + if user == (common.Address{}) { + return ErrInvalidUser + } + + if err := c.checkGlobalLimits(token, amount); err != nil { + return err + } + + if err := c.checkUserLimits(user, token, amount); err != nil { + return err + } + + return nil +} + +func (c *Checker) checkGlobalLimits(token common.Address, amount *big.Int) error { + l, ok := c.globalLimits[token] + if !ok { + return fmt.Errorf("%w: %s", ErrNoLimitsConfigured, token.Hex()) + } + + now := c.nowFunc() + + if l.Hourly != nil { + startOfHour := now.Truncate(time.Hour) + total, err := c.store.GetTotalWithdrawn(token, startOfHour) + if err != nil { + return fmt.Errorf("failed to get hourly withdrawn amount: %w", err) + } + newTotal := new(big.Int).Add(total, amount) + if newTotal.Cmp(l.Hourly) > 0 { + return fmt.Errorf("%w for %s: %s > %s", ErrHourlyLimitExceeded, token.Hex(), newTotal, l.Hourly) + } + } + + if l.Daily != nil { + startOfDay := now.Truncate(24 * time.Hour) + total, err := c.store.GetTotalWithdrawn(token, startOfDay) + if err != nil { + return fmt.Errorf("failed to get daily withdrawn amount: %w", err) + } + newTotal := new(big.Int).Add(total, amount) + if newTotal.Cmp(l.Daily) > 0 { + return fmt.Errorf("%w for %s: %s > %s", ErrDailyLimitExceeded, token.Hex(), newTotal, l.Daily) + } + } + + return nil +} + +func (c *Checker) resolveUserLimit(user, token common.Address) *Limit { + if userTokens, ok := c.userOverrides[user]; ok { + if l, ok := userTokens[token]; ok { + return &l + } + } + return nil +} + +func (c *Checker) checkUserLimits(user, token common.Address, amount *big.Int) error { + l := c.resolveUserLimit(user, token) + if l == nil { + return nil + } + + now := c.nowFunc() + + if l.Hourly != nil { + startOfHour := now.Truncate(time.Hour) + total, err := c.store.GetTotalWithdrawnByUser(user, token, startOfHour) + if err != nil { + return fmt.Errorf("failed to get per-user hourly withdrawn amount: %w", err) + } + newTotal := new(big.Int).Add(total, amount) + if newTotal.Cmp(l.Hourly) > 0 { + return fmt.Errorf("%w for user %s token %s: %s > %s", + ErrUserHourlyLimitExceeded, user.Hex(), token.Hex(), newTotal, l.Hourly) + } + } + + if l.Daily != nil { + startOfDay := now.Truncate(24 * time.Hour) + total, err := c.store.GetTotalWithdrawnByUser(user, token, startOfDay) + if err != nil { + return fmt.Errorf("failed to get per-user daily withdrawn amount: %w", err) + } + newTotal := new(big.Int).Add(total, amount) + if newTotal.Cmp(l.Daily) > 0 { + return fmt.Errorf("%w for user %s token %s: %s > %s", + ErrUserDailyLimitExceeded, user.Hex(), token.Hex(), newTotal, l.Daily) + } + } + + return nil +} + +func (c *Checker) Record(w *custody.Withdrawal) error { + return c.store.Save(w) +} diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go new file mode 100644 index 0000000..495cccc --- /dev/null +++ b/internal/checker/checker_test.go @@ -0,0 +1,281 @@ +package checker + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitewatch/custody" +) + +type mockStore struct { + withdrawals []*custody.Withdrawal + err error +} + +func (m *mockStore) Save(w *custody.Withdrawal) error { + m.withdrawals = append(m.withdrawals, w) + return nil +} + +func (m *mockStore) GetTotalWithdrawn(token common.Address, since time.Time) (*big.Int, error) { + if m.err != nil { + return nil, m.err + } + total := new(big.Int) + for _, w := range m.withdrawals { + if w.Token == token && !w.Timestamp.Before(since) { + total.Add(total, w.Amount) + } + } + return total, nil +} + +func (m *mockStore) GetTotalWithdrawnByUser(user common.Address, token common.Address, since time.Time) (*big.Int, error) { + if m.err != nil { + return nil, m.err + } + total := new(big.Int) + for _, w := range m.withdrawals { + if w.User == user && w.Token == token && !w.Timestamp.Before(since) { + total.Add(total, w.Amount) + } + } + return total, nil +} + +var ( + tokenA = common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + tokenB = common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + userA = common.HexToAddress("0x1111111111111111111111111111111111111111") + userB = common.HexToAddress("0x2222222222222222222222222222222222222222") +) + +func globalLimits(token common.Address, hourly, daily *big.Int) map[common.Address]Limit { + return map[common.Address]Limit{ + token: {Hourly: hourly, Daily: daily}, + } +} + +func TestNew_Valid(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, &mockStore{}) + require.NotNil(t, c) +} + +func TestCheck_SanityChecks(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, &mockStore{}) + + t.Run("zero amount", func(t *testing.T) { + err := c.Check(userA, tokenA, big.NewInt(0)) + require.ErrorIs(t, err, ErrInvalidAmount) + }) + + t.Run("negative amount", func(t *testing.T) { + err := c.Check(userA, tokenA, big.NewInt(-1)) + require.ErrorIs(t, err, ErrInvalidAmount) + }) + + t.Run("zero user address", func(t *testing.T) { + err := c.Check(common.Address{}, tokenA, big.NewInt(100)) + require.ErrorIs(t, err, ErrInvalidUser) + }) +} + +func TestCheck_NoLimitsConfigured(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), nil), nil, &mockStore{}) + + err := c.Check(userA, tokenB, big.NewInt(100)) + require.ErrorIs(t, err, ErrNoLimitsConfigured) +} + +func TestCheck_UnderHourlyLimit(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, &mockStore{}) + c.nowFunc = func() time.Time { + return time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) + } + + require.NoError(t, c.Check(userA, tokenA, big.NewInt(500))) +} + +func TestCheck_ExactHourlyLimit(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, &mockStore{}) + c.nowFunc = func() time.Time { + return time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) + } + + require.NoError(t, c.Check(userA, tokenA, big.NewInt(1000))) +} + +func TestCheck_ExceedHourlyLimit(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) + store := &mockStore{ + withdrawals: []*custody.Withdrawal{ + {Token: tokenA, User: userA, Amount: big.NewInt(800), Timestamp: now.Add(-10 * time.Minute)}, + }, + } + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, store) + c.nowFunc = func() time.Time { return now } + + err := c.Check(userA, tokenA, big.NewInt(300)) + require.ErrorIs(t, err, ErrHourlyLimitExceeded) +} + +func TestCheck_ExceedDailyLimit(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) + store := &mockStore{ + withdrawals: []*custody.Withdrawal{ + {Token: tokenA, User: userA, Amount: big.NewInt(4500), Timestamp: now.Add(-3 * time.Hour)}, + }, + } + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, store) + c.nowFunc = func() time.Time { return now } + + err := c.Check(userA, tokenA, big.NewInt(600)) + require.ErrorIs(t, err, ErrDailyLimitExceeded) +} + +func TestCheck_PreviousHourNotCounted(t *testing.T) { + now := time.Date(2025, 1, 1, 13, 5, 0, 0, time.UTC) + store := &mockStore{ + withdrawals: []*custody.Withdrawal{ + {Token: tokenA, User: userA, Amount: big.NewInt(900), Timestamp: time.Date(2025, 1, 1, 12, 50, 0, 0, time.UTC)}, + }, + } + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, store) + c.nowFunc = func() time.Time { return now } + + require.NoError(t, c.Check(userA, tokenA, big.NewInt(900))) +} + +func TestCheck_HourlyOnlyConfig(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), nil), nil, &mockStore{}) + c.nowFunc = func() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + } + + require.NoError(t, c.Check(userA, tokenA, big.NewInt(999))) +} + +func TestCheck_DailyOnlyConfig(t *testing.T) { + c := New(globalLimits(tokenA, nil, big.NewInt(5000)), nil, &mockStore{}) + c.nowFunc = func() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + } + + require.NoError(t, c.Check(userA, tokenA, big.NewInt(4000))) +} + +func TestCheck_StoreError(t *testing.T) { + store := &mockStore{err: errors.New("db connection lost")} + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, store) + c.nowFunc = func() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + } + + err := c.Check(userA, tokenA, big.NewInt(100)) + require.Error(t, err) +} + +func TestRecord(t *testing.T) { + store := &mockStore{} + c := New(globalLimits(tokenA, big.NewInt(1000), nil), nil, store) + + w := &custody.Withdrawal{ + WithdrawalID: [32]byte{1}, + User: userA, + Token: tokenA, + Amount: big.NewInt(500), + Timestamp: time.Now(), + } + require.NoError(t, c.Record(w)) + require.Len(t, store.withdrawals, 1) +} + +// --- Per-user limit tests --- + +func TestCheck_PerUserOverrideTakesPrecedence(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) + store := &mockStore{ + withdrawals: []*custody.Withdrawal{ + {Token: tokenA, User: userA, Amount: big.NewInt(800), Timestamp: now.Add(-10 * time.Minute)}, + }, + } + + global := globalLimits(tokenA, big.NewInt(10000), big.NewInt(50000)) + overrides := map[common.Address]map[common.Address]Limit{ + userA: {tokenA: {Hourly: big.NewInt(1000), Daily: big.NewInt(5000)}}, + } + + c := New(global, overrides, store) + c.nowFunc = func() time.Time { return now } + + // userA has override of 1000 hourly; 800 + 150 = 950 < 1000 → pass + require.NoError(t, c.Check(userA, tokenA, big.NewInt(150))) + + // userA: 800 + 250 = 1050 > 1000 → per-user hourly exceeded + err := c.Check(userA, tokenA, big.NewInt(250)) + require.ErrorIs(t, err, ErrUserHourlyLimitExceeded) +} + +func TestCheck_PerUserLimitIndependentOfGlobal(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC) + + store := &mockStore{ + withdrawals: []*custody.Withdrawal{ + {Token: tokenA, User: userA, Amount: big.NewInt(400), Timestamp: now.Add(-10 * time.Minute)}, + {Token: tokenA, User: userB, Amount: big.NewInt(400), Timestamp: now.Add(-5 * time.Minute)}, + }, + } + + global := globalLimits(tokenA, big.NewInt(1000), nil) + overrides := map[common.Address]map[common.Address]Limit{ + userA: {tokenA: {Hourly: big.NewInt(500)}}, + userB: {tokenA: {Hourly: big.NewInt(500)}}, + } + + c := New(global, overrides, store) + c.nowFunc = func() time.Time { return now } + + // userA: 400 + 200 = 600 > per-user 500 → blocked by per-user limit + err := c.Check(userA, tokenA, big.NewInt(200)) + require.ErrorIs(t, err, ErrUserHourlyLimitExceeded) + + // global: 800 + 250 = 1050 > 1000 → blocked by global limit + err = c.Check(userB, tokenA, big.NewInt(250)) + require.Error(t, err) +} + +func TestCheck_PerUserDailyLimitExceeded(t *testing.T) { + now := time.Date(2025, 1, 1, 18, 30, 0, 0, time.UTC) + store := &mockStore{ + withdrawals: []*custody.Withdrawal{ + {Token: tokenA, User: userA, Amount: big.NewInt(1500), Timestamp: now.Add(-6 * time.Hour)}, + {Token: tokenA, User: userA, Amount: big.NewInt(400), Timestamp: now.Add(-2 * time.Hour)}, + }, + } + + global := globalLimits(tokenA, big.NewInt(10000), big.NewInt(50000)) + overrides := map[common.Address]map[common.Address]Limit{ + userA: {tokenA: {Hourly: big.NewInt(5000), Daily: big.NewInt(2000)}}, + } + + c := New(global, overrides, store) + c.nowFunc = func() time.Time { return now } + + // userA already withdrew 1500+400=1900 today; 1900+200=2100 > daily limit 2000 + err := c.Check(userA, tokenA, big.NewInt(200)) + require.ErrorIs(t, err, ErrUserDailyLimitExceeded) +} + +func TestCheck_NoPerUserLimitsConfigured(t *testing.T) { + c := New(globalLimits(tokenA, big.NewInt(1000), big.NewInt(5000)), nil, &mockStore{}) + c.nowFunc = func() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + } + + require.NoError(t, c.Check(userA, tokenA, big.NewInt(500))) +} diff --git a/internal/store/adapter.go b/internal/store/adapter.go new file mode 100644 index 0000000..554de87 --- /dev/null +++ b/internal/store/adapter.go @@ -0,0 +1,140 @@ +package store + +import ( + "errors" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/layer-3/nitewatch/custody" +) + +type WithdrawalModel struct { + gorm.Model + WithdrawalID string `gorm:"uniqueIndex;type:varchar(66)"` + User string `gorm:"index;type:varchar(42)"` + Token string `gorm:"index;type:varchar(42)"` + Amount string `gorm:"type:text"` + BlockNumber uint64 + TxHash string `gorm:"type:varchar(66)"` + Timestamp time.Time `gorm:"index"` +} + +type BlockCursorModel struct { + StreamName string `gorm:"primaryKey;type:varchar(64)"` + BlockNumber uint64 `gorm:"not null"` + LogIndex uint `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null;autoUpdateTime"` +} + +type WithdrawEventModel struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + WithdrawalID string `gorm:"type:varchar(66);not null;uniqueIndex"` + UserAddress string `gorm:"type:varchar(42);not null"` + TokenAddress string `gorm:"type:varchar(42);not null"` + Amount string `gorm:"type:text;not null"` + Decision string `gorm:"type:varchar(16);not null"` + Reason string `gorm:"type:text;not null;default:''"` + BlockNumber uint64 `gorm:"not null"` + TxHash string `gorm:"type:varchar(66);not null"` + LogIndex uint `gorm:"not null"` + CreatedAt time.Time `gorm:"not null;autoCreateTime"` +} + +type Adapter struct { + db *gorm.DB +} + +func NewAdapter(db *gorm.DB) (*Adapter, error) { + if err := db.AutoMigrate(&WithdrawalModel{}, &BlockCursorModel{}, &WithdrawEventModel{}); err != nil { + return nil, err + } + return &Adapter{db: db}, nil +} + +var _ custody.WithdrawalStore = (*Adapter)(nil) + +func (a *Adapter) Save(w *custody.Withdrawal) error { + model := &WithdrawalModel{ + WithdrawalID: common.Hash(w.WithdrawalID).Hex(), + User: w.User.Hex(), + Token: w.Token.Hex(), + Amount: w.Amount.String(), + BlockNumber: w.BlockNumber, + TxHash: w.TxHash.Hex(), + Timestamp: w.Timestamp, + } + return a.db.Create(model).Error +} + +func (a *Adapter) GetTotalWithdrawn(token common.Address, since time.Time) (*big.Int, error) { + var withdrawals []WithdrawalModel + if err := a.db.Where("token = ? AND timestamp >= ?", token.Hex(), since).Find(&withdrawals).Error; err != nil { + return nil, err + } + return sumAmounts(withdrawals) +} + +func (a *Adapter) GetTotalWithdrawnByUser(user, token common.Address, since time.Time) (*big.Int, error) { + var withdrawals []WithdrawalModel + if err := a.db.Where("user = ? AND token = ? AND timestamp >= ?", user.Hex(), token.Hex(), since).Find(&withdrawals).Error; err != nil { + return nil, err + } + return sumAmounts(withdrawals) +} + +func sumAmounts(withdrawals []WithdrawalModel) (*big.Int, error) { + total := new(big.Int) + for _, w := range withdrawals { + amount, ok := new(big.Int).SetString(w.Amount, 10) + if !ok { + return nil, fmt.Errorf("corrupted amount in withdrawal %s: %q", w.WithdrawalID, w.Amount) + } + total.Add(total, amount) + } + return total, nil +} + +func (a *Adapter) GetCursor(streamName string) (blockNumber uint64, logIndex uint32, err error) { + var cursor BlockCursorModel + result := a.db.Where("stream_name = ?", streamName).First(&cursor) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return 0, 0, nil + } + return 0, 0, result.Error + } + return cursor.BlockNumber, uint32(cursor.LogIndex), nil +} + +func (a *Adapter) RecordWithdrawEvent(ev *WithdrawEventModel) error { + return a.db.Transaction(func(tx *gorm.DB) error { + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(ev) + if result.Error != nil { + return result.Error + } + return upsertCursor(tx, "withdraw_started", ev.BlockNumber, ev.LogIndex) + }) +} + +func (a *Adapter) HasWithdrawEvent(withdrawalID string) bool { + var count int64 + a.db.Model(&WithdrawEventModel{}).Where("withdrawal_id = ?", withdrawalID).Count(&count) + return count > 0 +} + +func upsertCursor(tx *gorm.DB, streamName string, blockNumber uint64, logIndex uint) error { + cursor := BlockCursorModel{ + StreamName: streamName, + BlockNumber: blockNumber, + LogIndex: logIndex, + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "stream_name"}}, + DoUpdates: clause.AssignmentColumns([]string{"block_number", "log_index", "updated_at"}), + }).Create(&cursor).Error +} diff --git a/store/adapter_test.go b/internal/store/adapter_test.go similarity index 56% rename from store/adapter_test.go rename to internal/store/adapter_test.go index f6037f8..9c24558 100644 --- a/store/adapter_test.go +++ b/internal/store/adapter_test.go @@ -6,11 +6,12 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - nw "github.com/layer-3/nitewatch" + "github.com/layer-3/nitewatch/custody" ) func newTestAdapter(t *testing.T) *Adapter { @@ -18,13 +19,9 @@ func newTestAdapter(t *testing.T) *Adapter { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Discard, }) - if err != nil { - t.Fatalf("failed to open in-memory db: %v", err) - } + require.NoError(t, err) adapter, err := NewAdapter(db) - if err != nil { - t.Fatalf("failed to create adapter: %v", err) - } + require.NoError(t, err) return adapter } @@ -37,7 +34,7 @@ var ( func TestSave(t *testing.T) { a := newTestAdapter(t) - w := &nw.Withdrawal{ + w := &custody.Withdrawal{ WithdrawalID: [32]byte{1}, User: user, Token: tokenA, @@ -47,24 +44,17 @@ func TestSave(t *testing.T) { Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), } - if err := a.Save(w); err != nil { - t.Fatalf("Save failed: %v", err) - } + require.NoError(t, a.Save(w)) - // Verify it was persisted total, err := a.GetTotalWithdrawn(tokenA, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) - if err != nil { - t.Fatalf("GetTotalWithdrawn failed: %v", err) - } - if total.Cmp(big.NewInt(1000)) != 0 { - t.Fatalf("expected total 1000, got %s", total) - } + require.NoError(t, err) + require.Equal(t, "1000", total.String()) } func TestSave_DuplicateWithdrawalID(t *testing.T) { a := newTestAdapter(t) - w := &nw.Withdrawal{ + w := &custody.Withdrawal{ WithdrawalID: [32]byte{1}, User: user, Token: tokenA, @@ -72,24 +62,16 @@ func TestSave_DuplicateWithdrawalID(t *testing.T) { Timestamp: time.Now(), } - if err := a.Save(w); err != nil { - t.Fatalf("first Save failed: %v", err) - } - if err := a.Save(w); err == nil { - t.Fatal("expected error on duplicate withdrawal ID") - } + require.NoError(t, a.Save(w)) + require.Error(t, a.Save(w)) } func TestGetTotalWithdrawn_Empty(t *testing.T) { a := newTestAdapter(t) total, err := a.GetTotalWithdrawn(tokenA, time.Time{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if total.Sign() != 0 { - t.Fatalf("expected zero, got %s", total) - } + require.NoError(t, err) + require.Equal(t, 0, total.Sign()) } func TestGetTotalWithdrawn_TimeFilter(t *testing.T) { @@ -97,46 +79,26 @@ func TestGetTotalWithdrawn_TimeFilter(t *testing.T) { base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - withdrawals := []*nw.Withdrawal{ + withdrawals := []*custody.Withdrawal{ {WithdrawalID: [32]byte{1}, User: user, Token: tokenA, Amount: big.NewInt(100), Timestamp: base.Add(-2 * time.Hour)}, {WithdrawalID: [32]byte{2}, User: user, Token: tokenA, Amount: big.NewInt(200), Timestamp: base.Add(-30 * time.Minute)}, {WithdrawalID: [32]byte{3}, User: user, Token: tokenA, Amount: big.NewInt(300), Timestamp: base.Add(10 * time.Minute)}, } for _, w := range withdrawals { - if err := a.Save(w); err != nil { - t.Fatalf("Save failed: %v", err) - } + require.NoError(t, a.Save(w)) } - // Since base: should include w2 (at base-30m? No, base-30m < base) and w3 - // Actually base-30m is before base, so only w3 is >= base - // Let me check: since = base = 12:00 - // w1 = 10:00 (before), w2 = 11:30 (before), w3 = 12:10 (after) total, err := a.GetTotalWithdrawn(tokenA, base) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if total.Cmp(big.NewInt(300)) != 0 { - t.Fatalf("expected 300, got %s", total) - } + require.NoError(t, err) + require.Equal(t, "300", total.String()) - // Since 11:00: should include w2 and w3 total, err = a.GetTotalWithdrawn(tokenA, base.Add(-1*time.Hour)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if total.Cmp(big.NewInt(500)) != 0 { - t.Fatalf("expected 500, got %s", total) - } + require.NoError(t, err) + require.Equal(t, "500", total.String()) - // Since beginning: all three total, err = a.GetTotalWithdrawn(tokenA, base.Add(-3*time.Hour)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if total.Cmp(big.NewInt(600)) != 0 { - t.Fatalf("expected 600, got %s", total) - } + require.NoError(t, err) + require.Equal(t, "600", total.String()) } func TestGetTotalWithdrawn_TokenFilter(t *testing.T) { @@ -144,57 +106,77 @@ func TestGetTotalWithdrawn_TokenFilter(t *testing.T) { base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - withdrawals := []*nw.Withdrawal{ + withdrawals := []*custody.Withdrawal{ {WithdrawalID: [32]byte{1}, User: user, Token: tokenA, Amount: big.NewInt(100), Timestamp: base}, {WithdrawalID: [32]byte{2}, User: user, Token: tokenB, Amount: big.NewInt(200), Timestamp: base}, {WithdrawalID: [32]byte{3}, User: user, Token: tokenA, Amount: big.NewInt(300), Timestamp: base}, } for _, w := range withdrawals { - if err := a.Save(w); err != nil { - t.Fatalf("Save failed: %v", err) - } + require.NoError(t, a.Save(w)) } totalA, err := a.GetTotalWithdrawn(tokenA, base.Add(-time.Hour)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if totalA.Cmp(big.NewInt(400)) != 0 { - t.Fatalf("expected 400 for tokenA, got %s", totalA) - } + require.NoError(t, err) + require.Equal(t, "400", totalA.String()) totalB, err := a.GetTotalWithdrawn(tokenB, base.Add(-time.Hour)) - if err != nil { - t.Fatalf("unexpected error: %v", err) + require.NoError(t, err) + require.Equal(t, "200", totalB.String()) +} + +func TestGetTotalWithdrawnByUser(t *testing.T) { + a := newTestAdapter(t) + base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + + userB := common.HexToAddress("0x2222222222222222222222222222222222222222") + + withdrawals := []*custody.Withdrawal{ + {WithdrawalID: [32]byte{1}, User: user, Token: tokenA, Amount: big.NewInt(100), Timestamp: base}, + {WithdrawalID: [32]byte{2}, User: user, Token: tokenA, Amount: big.NewInt(200), Timestamp: base}, + {WithdrawalID: [32]byte{3}, User: userB, Token: tokenA, Amount: big.NewInt(300), Timestamp: base}, + {WithdrawalID: [32]byte{4}, User: user, Token: tokenB, Amount: big.NewInt(400), Timestamp: base}, } - if totalB.Cmp(big.NewInt(200)) != 0 { - t.Fatalf("expected 200 for tokenB, got %s", totalB) + for _, w := range withdrawals { + require.NoError(t, a.Save(w)) } + + // user + tokenA = 100 + 200 = 300 + total, err := a.GetTotalWithdrawnByUser(user, tokenA, base.Add(-time.Hour)) + require.NoError(t, err) + require.Equal(t, "300", total.String()) + + // userB + tokenA = 300 + total, err = a.GetTotalWithdrawnByUser(userB, tokenA, base.Add(-time.Hour)) + require.NoError(t, err) + require.Equal(t, "300", total.String()) + + // user + tokenB = 400 + total, err = a.GetTotalWithdrawnByUser(user, tokenB, base.Add(-time.Hour)) + require.NoError(t, err) + require.Equal(t, "400", total.String()) + + // userB + tokenB = 0 (no withdrawals) + total, err = a.GetTotalWithdrawnByUser(userB, tokenB, base.Add(-time.Hour)) + require.NoError(t, err) + require.Equal(t, 0, total.Sign()) } func TestGetTotalWithdrawn_LargeAmounts(t *testing.T) { a := newTestAdapter(t) base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - // Use amounts larger than uint64 bigAmount, _ := new(big.Int).SetString("999999999999999999999999999999", 10) - w := &nw.Withdrawal{ + w := &custody.Withdrawal{ WithdrawalID: [32]byte{1}, User: user, Token: tokenA, Amount: bigAmount, Timestamp: base, } - if err := a.Save(w); err != nil { - t.Fatalf("Save failed: %v", err) - } + require.NoError(t, a.Save(w)) total, err := a.GetTotalWithdrawn(tokenA, base.Add(-time.Hour)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if total.Cmp(bigAmount) != 0 { - t.Fatalf("expected %s, got %s", bigAmount, total) - } + require.NoError(t, err) + require.Equal(t, bigAmount.String(), total.String()) } diff --git a/service/integration_test.go b/service/integration_test.go new file mode 100644 index 0000000..91ecaef --- /dev/null +++ b/service/integration_test.go @@ -0,0 +1,301 @@ +//go:build !short + +package service_test + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + "math/big" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient/simulated" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitewatch/config" + "github.com/layer-3/nitewatch/custody" + "github.com/layer-3/nitewatch/service" +) + +const ( + nativeToken = "0x0000000000000000000000000000000000000000" + // Simulated backend always uses chainID 1337. + simChainID = 1337 +) + +type testEnv struct { + sim *simulated.Backend + client custody.EthBackend + keys [4]*ecdsa.PrivateKey + addrs [4]common.Address + auths [4]*bind.TransactOpts + contract *custody.SimpleCustody + addr common.Address +} + +func (e *testEnv) adminAuth() *bind.TransactOpts { return e.auths[0] } +func (e *testEnv) neodaxAuth() *bind.TransactOpts { return e.auths[1] } +func (e *testEnv) nitewatchKey() *ecdsa.PrivateKey { return e.keys[2] } +func (e *testEnv) nitewatchAddr() common.Address { return e.addrs[2] } +func (e *testEnv) userAddr() common.Address { return e.addrs[3] } + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + + keys := [4]*ecdsa.PrivateKey{} + addrs := [4]common.Address{} + auths := [4]*bind.TransactOpts{} + alloc := make(types.GenesisAlloc) + balance := new(big.Int).Mul(big.NewInt(1000), big.NewInt(1e18)) + + for i := range 4 { + key, err := crypto.GenerateKey() + require.NoError(t, err) + keys[i] = key + addrs[i] = crypto.PubkeyToAddress(key.PublicKey) + auth, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(simChainID)) + require.NoError(t, err) + auths[i] = auth + alloc[addrs[i]] = types.Account{Balance: balance} + } + + sim := simulated.NewBackend(alloc) + t.Cleanup(func() { sim.Close() }) + + client := simBackendClient{Client: sim.Client(), backend: sim} + + contractAddr, tx, contract, err := custody.DeploySimpleCustody( + auths[0], // admin deploys + client, // backend + addrs[0], // admin + addrs[1], // neodax + addrs[2], // nitewatch + ) + require.NoError(t, err) + sim.Commit() + + receipt, err := client.TransactionReceipt(context.Background(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Status, "contract deployment failed") + + return &testEnv{ + sim: sim, + client: client, + keys: keys, + addrs: addrs, + auths: auths, + contract: contract, + addr: contractAddr, + } +} + +// simBackendClient wraps simulated.Client to add Close(), satisfying custody.EthBackend. +type simBackendClient struct { + simulated.Client + backend *simulated.Backend +} + +func (c simBackendClient) Close() { c.backend.Close() } + +// autoCommit mines blocks periodically so that bind.WaitMined can return. +func autoCommit(ctx context.Context, sim *simulated.Backend, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sim.Commit() + } + } +} + +func createNitewatchService(t *testing.T, env *testEnv, limitWei string) *service.Service { + t.Helper() + + conf := config.Config{ + Blockchain: config.BlockchainConfig{ + ContractAddr: env.addr.Hex(), + PrivateKey: fmt.Sprintf("%x", crypto.FromECDSA(env.nitewatchKey())), + }, + Limits: config.LimitsConfig{ + nativeToken: config.LimitConfig{ + Hourly: limitWei, + Daily: limitWei, + }, + }, + DBPath: filepath.Join(t.TempDir(), "nitewatch.db"), + ListenAddr: ":0", + } + + svc, err := service.NewWithBackend(conf, env.client) + require.NoError(t, err) + return svc +} + +func runNitewatchService(t *testing.T, svc *service.Service) { + t.Helper() + + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { errCh <- svc.RunWorkerWithContext(ctx) }() + + require.Eventually(t, svc.IsWorkerReady, 30*time.Second, 100*time.Millisecond, + "nitewatch worker did not become ready") + + t.Cleanup(func() { + cancel() + select { + case err := <-errCh: + if err != nil && !errors.Is(err, context.Canceled) { + t.Logf("nitewatch worker stopped with error: %v", err) + } + case <-time.After(10 * time.Second): + t.Log("nitewatch worker cleanup timeout") + } + }) +} + +// waitForWithdrawFinalized polls for a WithdrawFinalized event with the given success value. +func waitForWithdrawFinalized(t *testing.T, env *testEnv, timeout time.Duration) *custody.SimpleCustodyWithdrawFinalized { + t.Helper() + + deadline := time.After(timeout) + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-deadline: + t.Fatal("timed out waiting for WithdrawFinalized event") + return nil + case <-ticker.C: + iter, err := env.contract.FilterWithdrawFinalized(&bind.FilterOpts{ + Start: 0, + Context: context.Background(), + }, nil) + require.NoError(t, err) + if iter.Next() { + ev := iter.Event + iter.Close() + return ev + } + iter.Close() + } + } +} + +func TestWithdrawalFinalized(t *testing.T) { + env := newTestEnv(t) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go autoCommit(ctx, env.sim, 100*time.Millisecond) + + // 100 ETH limit — well above our 0.5 ETH withdrawal. + svc := createNitewatchService(t, env, "100000000000000000000") + runNitewatchService(t, svc) + + // User deposits 1 ETH. + depositAmount := big.NewInt(1e18) + userAuth := copyAuth(env.auths[3]) + userAuth.Value = depositAmount + tx, err := env.contract.Deposit(userAuth, common.Address{}, depositAmount) + require.NoError(t, err) + env.sim.Commit() + receipt, err := env.client.TransactionReceipt(context.Background(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Status, "deposit tx failed") + + // Record user balance before withdrawal. + userBalBefore, err := env.client.(ethereum.ChainStateReader).BalanceAt( + context.Background(), env.userAddr(), nil) + require.NoError(t, err) + + // NeoDAX starts a 0.5 ETH withdrawal for user. + withdrawAmount := new(big.Int).Div(depositAmount, big.NewInt(2)) + neodaxAuth := copyAuth(env.neodaxAuth()) + tx, err = env.contract.StartWithdraw(neodaxAuth, env.userAddr(), common.Address{}, withdrawAmount, big.NewInt(1)) + require.NoError(t, err) + env.sim.Commit() + receipt, err = env.client.TransactionReceipt(context.Background(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Status, "startWithdraw tx failed") + + // Wait for nitewatch to finalize the withdrawal. + ev := waitForWithdrawFinalized(t, env, 30*time.Second) + assert.True(t, ev.Success, "expected withdrawal to be finalized successfully") + + // Verify user received funds. + userBalAfter, err := env.client.(ethereum.ChainStateReader).BalanceAt( + context.Background(), env.userAddr(), nil) + require.NoError(t, err) + expected := new(big.Int).Add(userBalBefore, withdrawAmount) + assert.Equal(t, expected.String(), userBalAfter.String(), + "user balance should increase by the withdrawn amount") +} + +func TestWithdrawalRejected(t *testing.T) { + env := newTestEnv(t) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go autoCommit(ctx, env.sim, 100*time.Millisecond) + + // 0.1 ETH limit — below our 0.5 ETH withdrawal. + svc := createNitewatchService(t, env, "100000000000000000") + runNitewatchService(t, svc) + + // User deposits 1 ETH. + depositAmount := big.NewInt(1e18) + userAuth := copyAuth(env.auths[3]) + userAuth.Value = depositAmount + tx, err := env.contract.Deposit(userAuth, common.Address{}, depositAmount) + require.NoError(t, err) + env.sim.Commit() + receipt, err := env.client.TransactionReceipt(context.Background(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Status, "deposit tx failed") + + // Record user balance before withdrawal. + userBalBefore, err := env.client.(ethereum.ChainStateReader).BalanceAt( + context.Background(), env.userAddr(), nil) + require.NoError(t, err) + + // NeoDAX starts a 0.5 ETH withdrawal (exceeds 0.1 ETH limit). + withdrawAmount := new(big.Int).Div(depositAmount, big.NewInt(2)) + neodaxAuth := copyAuth(env.neodaxAuth()) + tx, err = env.contract.StartWithdraw(neodaxAuth, env.userAddr(), common.Address{}, withdrawAmount, big.NewInt(1)) + require.NoError(t, err) + env.sim.Commit() + receipt, err = env.client.TransactionReceipt(context.Background(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Status, "startWithdraw tx failed") + + // Wait for nitewatch to reject the withdrawal. + ev := waitForWithdrawFinalized(t, env, 30*time.Second) + assert.False(t, ev.Success, "expected withdrawal to be rejected") + + // Verify user balance unchanged. + userBalAfter, err := env.client.(ethereum.ChainStateReader).BalanceAt( + context.Background(), env.userAddr(), nil) + require.NoError(t, err) + assert.Equal(t, userBalBefore.String(), userBalAfter.String(), + "user balance should not change after rejection") +} + +// copyAuth creates a shallow copy of TransactOpts so concurrent uses don't race. +func copyAuth(auth *bind.TransactOpts) *bind.TransactOpts { + cp := *auth + return &cp +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..5adb0f0 --- /dev/null +++ b/service/service.go @@ -0,0 +1,350 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "net/http" + "os" + "os/signal" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/layer-3/nitewatch/config" + "github.com/layer-3/nitewatch/custody" + "github.com/layer-3/nitewatch/internal/checker" + "github.com/layer-3/nitewatch/internal/store" +) + +type httpServer struct { + Engine *gin.Engine + server *http.Server +} + +func newHTTPServer(addr string) *httpServer { + engine := gin.New() + engine.Use(gin.Recovery()) + return &httpServer{ + Engine: engine, + server: &http.Server{Addr: addr, Handler: engine}, + } +} + +func (s *httpServer) Run() error { return s.server.ListenAndServe() } +func (s *httpServer) Shutdown(ctx context.Context) error { return s.server.Shutdown(ctx) } + +type Service struct { + Config config.Config + Logger *slog.Logger + + web *httpServer + ethClient custody.EthBackend + contract *custody.IWithdraw + listener *custody.Listener + auth *bind.TransactOpts + checker *checker.Checker + store *store.Adapter + + workerReady int32 +} + +// New creates a Service that dials an Ethereum node via the configured RPC URL. +func New(conf config.Config) (*Service, error) { + client, err := ethclient.Dial(conf.Blockchain.RPCURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to Ethereum RPC: %w", err) + } + + svc, err := NewWithBackend(conf, client) + if err != nil { + client.Close() + return nil, err + } + return svc, nil +} + +// NewWithBackend creates a Service using a pre-existing Ethereum backend. +// The caller is responsible for closing the backend when done. +func NewWithBackend(conf config.Config, client custody.EthBackend) (*Service, error) { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)).With("service", "nitewatch") + + srv := newHTTPServer(conf.ListenAddr) + + gormDB, err := gorm.Open(sqlite.Open(conf.DBPath), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + db, err := store.NewAdapter(gormDB) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %w", err) + } + + globalLimits, err := parseLimitsConfig(conf.Limits) + if err != nil { + return nil, fmt.Errorf("failed to parse global limits: %w", err) + } + + userOverrides, err := parseUserOverrides(conf.PerUserOverrides) + if err != nil { + return nil, fmt.Errorf("failed to parse per-user overrides: %w", err) + } + + chk := checker.New(globalLimits, userOverrides, db) + + chainID, err := client.ChainID(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get chain ID: %w", err) + } + + key, err := crypto.HexToECDSA(conf.Blockchain.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + auth, err := bind.NewKeyedTransactorWithChainID(key, chainID) + if err != nil { + return nil, fmt.Errorf("failed to create transactor: %w", err) + } + + addr := common.HexToAddress(conf.Blockchain.ContractAddr) + withdrawContract, err := custody.NewIWithdraw(addr, client) + if err != nil { + return nil, fmt.Errorf("failed to bind IWithdraw contract: %w", err) + } + + listener := custody.NewListener(client, addr, withdrawContract, nil) + + return &Service{ + Config: conf, + Logger: logger, + web: srv, + ethClient: client, + contract: withdrawContract, + listener: listener, + auth: auth, + checker: chk, + store: db, + }, nil +} + +func (svc *Service) IsWorkerReady() bool { + return atomic.LoadInt32(&svc.workerReady) == 1 +} + +func (svc *Service) setWorkerReady() { + atomic.StoreInt32(&svc.workerReady, 1) +} + +func (svc *Service) RunWorker() error { + return svc.RunWorkerWithContext(context.Background()) +} + +func (svc *Service) RunWorkerWithContext(ctx context.Context) error { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + svc.Logger.Info("Starting health endpoint server") + return svc.web.Run() + }) + + g.Go(func() error { + fromBlock, fromLogIdx, err := svc.store.GetCursor("withdraw_started") + if err != nil { + svc.Logger.Warn("Failed to read withdraw_started cursor, starting from head", "error", err) + } + svc.Logger.Info("Starting WithdrawStarted event watcher", "from_block", fromBlock, "from_log_index", fromLogIdx) + withdrawals := make(chan *custody.WithdrawStartedEvent) + go svc.listener.WatchWithdrawStarted(ctx, withdrawals, fromBlock, fromLogIdx) + for event := range withdrawals { + svc.processWithdrawal(ctx, event) + } + return nil + }) + + g.Go(func() error { + <-ctx.Done() + svc.Logger.Info("Shutting down health endpoint server") + ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShutdown() + return svc.web.Shutdown(ctxShutdown) + }) + + g.Go(func() error { + <-ctx.Done() + svc.Logger.Info("Closing Ethereum client") + svc.ethClient.Close() + return nil + }) + + svc.setWorkerReady() + svc.Logger.Info("Worker started") + + return g.Wait() +} + +func (svc *Service) processWithdrawal(ctx context.Context, event *custody.WithdrawStartedEvent) { + wID := common.Hash(event.WithdrawalID).Hex() + logger := svc.Logger.With( + "withdrawal_id", wID, + "user", event.User.Hex(), + "token", event.Token.Hex(), + "amount", event.Amount, + ) + + if svc.store.HasWithdrawEvent(wID) { + logger.Info("Event already processed, skipping") + return + } + + logger.Info("Processing withdrawal request") + + baseModel := store.WithdrawEventModel{ + WithdrawalID: wID, + UserAddress: event.User.Hex(), + TokenAddress: event.Token.Hex(), + Amount: event.Amount.String(), + BlockNumber: event.BlockNumber, + TxHash: event.TxHash.Hex(), + LogIndex: uint(event.LogIndex), + } + + if err := svc.checker.Check(event.User, event.Token, event.Amount); err != nil { + logger.Warn("Withdrawal blocked by policy, rejecting", "reason", err) + + txAuth := *svc.auth + txAuth.Context = ctx + tx, txErr := svc.contract.RejectWithdraw(&txAuth, event.WithdrawalID) + if txErr != nil { + logger.Error("Failed to reject withdrawal", "error", txErr) + baseModel.Decision = "error" + baseModel.Reason = fmt.Sprintf("reject tx failed: %v", txErr) + svc.recordEvent(logger, &baseModel) + return + } + logger.Info("Sent reject transaction", "tx_hash", tx.Hash().Hex()) + if _, txErr = bind.WaitMined(ctx, svc.ethClient, tx); txErr != nil { + logger.Error("Failed waiting for reject tx to be mined", "error", txErr) + baseModel.Decision = "error" + baseModel.Reason = fmt.Sprintf("reject tx mining failed: %v", txErr) + svc.recordEvent(logger, &baseModel) + return + } + + baseModel.Decision = "rejected" + baseModel.Reason = err.Error() + svc.recordEvent(logger, &baseModel) + return + } + + txAuth := *svc.auth + txAuth.Context = ctx + + tx, err := svc.contract.FinalizeWithdraw(&txAuth, event.WithdrawalID) + if err != nil { + logger.Error("Failed to finalize withdrawal", "error", err) + baseModel.Decision = "error" + baseModel.Reason = fmt.Sprintf("finalize tx failed: %v", err) + svc.recordEvent(logger, &baseModel) + return + } + + logger.Info("Sent finalize transaction", "tx_hash", tx.Hash().Hex()) + + receipt, err := bind.WaitMined(ctx, svc.ethClient, tx) + if err != nil { + logger.Error("Transaction mining failed", "error", err) + baseModel.Decision = "error" + baseModel.Reason = fmt.Sprintf("finalize tx mining failed: %v", err) + svc.recordEvent(logger, &baseModel) + return + } + + if receipt.Status == 1 { + logger.Info("Withdrawal finalized successfully on-chain") + + record := &custody.Withdrawal{ + WithdrawalID: event.WithdrawalID, + User: event.User, + Token: event.Token, + Amount: event.Amount, + BlockNumber: receipt.BlockNumber.Uint64(), + TxHash: tx.Hash(), + Timestamp: time.Now(), + } + if err := svc.checker.Record(record); err != nil { + logger.Error("Failed to record withdrawal in DB", "error", err) + } + + baseModel.Decision = "approved" + svc.recordEvent(logger, &baseModel) + } else { + logger.Error("Withdrawal finalization tx reverted") + baseModel.Decision = "error" + baseModel.Reason = "finalize tx reverted on-chain" + svc.recordEvent(logger, &baseModel) + } +} + +func (svc *Service) recordEvent(logger *slog.Logger, ev *store.WithdrawEventModel) { + if err := svc.store.RecordWithdrawEvent(ev); err != nil { + logger.Error("Failed to record withdraw event", "error", err) + } +} + +func parseLimitsConfig(lc config.LimitsConfig) (map[common.Address]checker.Limit, error) { + limits := make(map[common.Address]checker.Limit) + for addrStr, conf := range lc { + if !common.IsHexAddress(addrStr) { + return nil, fmt.Errorf("invalid address: %s", addrStr) + } + addr := common.HexToAddress(addrStr) + + l := checker.Limit{} + if conf.Hourly != "" { + val, ok := new(big.Int).SetString(conf.Hourly, 10) + if !ok { + return nil, fmt.Errorf("invalid hourly limit for %s: %s", addrStr, conf.Hourly) + } + l.Hourly = val + } + if conf.Daily != "" { + val, ok := new(big.Int).SetString(conf.Daily, 10) + if !ok { + return nil, fmt.Errorf("invalid daily limit for %s: %s", addrStr, conf.Daily) + } + l.Daily = val + } + limits[addr] = l + } + return limits, nil +} + +func parseUserOverrides(overrides map[string]config.LimitsConfig) (map[common.Address]map[common.Address]checker.Limit, error) { + result := make(map[common.Address]map[common.Address]checker.Limit) + for userAddrStr, tokenLimits := range overrides { + if !common.IsHexAddress(userAddrStr) { + return nil, fmt.Errorf("invalid user address in per_user_overrides: %s", userAddrStr) + } + userAddr := common.HexToAddress(userAddrStr) + parsed, err := parseLimitsConfig(tokenLimits) + if err != nil { + return nil, fmt.Errorf("per-user overrides for %s: %w", userAddrStr, err) + } + result[userAddr] = parsed + } + return result, nil +} diff --git a/store/adapter.go b/store/adapter.go deleted file mode 100644 index 9aa01b3..0000000 --- a/store/adapter.go +++ /dev/null @@ -1,72 +0,0 @@ -package store - -import ( - "fmt" - "math/big" - "time" - - "github.com/ethereum/go-ethereum/common" - "gorm.io/gorm" - - nw "github.com/layer-3/nitewatch" -) - -// WithdrawalModel is the GORM model for persisting withdrawal events. -type WithdrawalModel struct { - gorm.Model - WithdrawalID string `gorm:"uniqueIndex;type:varchar(66)"` // 0x + 64 hex chars - User string `gorm:"index;type:varchar(42)"` // 0x + 40 hex chars - Token string `gorm:"index;type:varchar(42)"` // 0x + 40 hex chars - Amount string `gorm:"type:text"` // big.Int as string - BlockNumber uint64 - TxHash string `gorm:"type:varchar(66)"` - Timestamp time.Time `gorm:"index"` -} - -// Adapter implements the WithdrawalStore interface using GORM. -type Adapter struct { - db *gorm.DB -} - -// NewAdapter initializes a new GORM adapter and runs migrations. -func NewAdapter(db *gorm.DB) (*Adapter, error) { - if err := db.AutoMigrate(&WithdrawalModel{}); err != nil { - return nil, err - } - return &Adapter{db: db}, nil -} - -// Save persists a withdrawal event to the database. -func (a *Adapter) Save(w *nw.Withdrawal) error { - model := &WithdrawalModel{ - WithdrawalID: common.Hash(w.WithdrawalID).Hex(), - User: w.User.Hex(), - Token: w.Token.Hex(), - Amount: w.Amount.String(), - BlockNumber: w.BlockNumber, - TxHash: w.TxHash.Hex(), - Timestamp: w.Timestamp, - } - return a.db.Create(model).Error -} - -// GetTotalWithdrawn calculates the total amount withdrawn for a token since a given time. -// Summation is performed in Go to preserve big.Int precision (SQLite stores amounts as strings). -func (a *Adapter) GetTotalWithdrawn(token common.Address, since time.Time) (*big.Int, error) { - var withdrawals []WithdrawalModel - - if err := a.db.Where("token = ? AND timestamp >= ?", token.Hex(), since).Find(&withdrawals).Error; err != nil { - return nil, err - } - - total := new(big.Int) - for _, w := range withdrawals { - amount, ok := new(big.Int).SetString(w.Amount, 10) - if !ok { - return nil, fmt.Errorf("corrupted amount in withdrawal %s: %q", w.WithdrawalID, w.Amount) - } - total.Add(total, amount) - } - - return total, nil -}