diff --git a/actions/onNotifyDebtors.ts b/actions/onNotifyDebtors.ts new file mode 100644 index 0000000..e0ed2c0 --- /dev/null +++ b/actions/onNotifyDebtors.ts @@ -0,0 +1,248 @@ +import { StaticJsonRpcProvider } from '@ethersproject/providers'; +import { ActionFn, Network, PeriodicEvent } from '@tenderly/actions'; +import { channels, payloads } from '@pushprotocol/restapi'; +import { formatUnits } from '@ethersproject/units'; +import { BigNumber } from '@ethersproject/bignumber'; +import { Wallet } from '@ethersproject/wallet'; +import { WebClient } from '@slack/web-api'; +import { Interface } from '@ethersproject/abi'; +import getSecret from './utils/getSecret'; +import multicall from './utils/multicall'; + +import type { Previewer, PreviewerInterface } from './types/Previewer'; +import previewerABI from './abi/Previewer.json'; + +export const DELAY_KEY = 'notificationsDelay'; +export const NOTIFICATIONS_KEY = 'notificationsResults'; + +const NETWORKS = { + // TODO: uncomment when push ready on mainnet + // 1: { + // channel: '0x', + // env: 'prod', + // app: 'app', + // }, + 5: { + channel: '0xB51210b372D50c290BA36bD464F903eDe8939A1B', + env: 'staging', + app: 'goerli', + }, +}; + +export type SentNotification = { + totalDebt: string; + chainId: number; + error?: unknown; + maturityISO: string; + subscriber: string; + successfullySent: boolean; + symbol: string; +}; + +enum NotificationType { + broadcast = 1, + target = 3, + subset = 4, +} + +const titleMsg = (daysLeft: number, symbol: string) => { + if (daysLeft > 1) { + return `Your ${symbol} fixed borrow expires in ${daysLeft} days`; + } + if (daysLeft === 1) return `Your ${symbol} fixed borrow expires tomorrow`; + if (daysLeft === 0) return `Your ${symbol} fixed borrow expires today`; + if (daysLeft === -1) return `Your ${symbol} fixed borrow expired yesterday`; + return `Your ${symbol} fixed borrow expired ${-daysLeft} days ago`; +}; + +const bodyIntro = (daysLeft: number, decimals: number, principal: BigNumber, symbol: string) => { + const yourBorrow = `Your ${formatUnits(principal, decimals)} ${symbol} fixed borrow`; + + if (daysLeft > 1) { + return `${yourBorrow} expires in ${daysLeft} days`; + } + if (daysLeft === 1) return `${yourBorrow} expires tomorrow`; + if (daysLeft === 0) return `${yourBorrow} expires today`; + if (daysLeft === -1) return `${yourBorrow} expired yesterday`; + return `${yourBorrow} expired ${-daysLeft} days ago`; +}; + +const bodyMsg = ( + daysLeft: number, + decimals: number, + principal: BigNumber, + symbol: string, + totalDebt: string, + penaltyRate: BigNumber, +) => { + const intro = bodyIntro(daysLeft, decimals, principal, symbol); + const debtOf = `debt of ${totalDebt} ${symbol}`; + const thePenalty = `The penalty for not repaying on time is ${Number(formatUnits(penaltyRate.mul(8_640_000), 18)).toFixed(2)}% per day.`; + + if (daysLeft >= 0) { + return `${intro}. Please, remember to repay your ${debtOf} on time. ${thePenalty}`; + } + return `${intro}. Please, repay your ${debtOf} ASAP. ${thePenalty}`; +}; + +const previewer = new Interface(previewerABI) as PreviewerInterface; + +export default (async ({ storage, secrets, gateways }, { time }: PeriodicEvent) => { + try { + const delay = (await storage.getNumber(DELAY_KEY)) ?? 60 * 60 * 24; + const now = Math.floor(time.getTime() / 1_000); + + const results = await Promise.all( + Object.entries(NETWORKS).map(async ([chainId, { channel, env, app }]) => { + const signer = new Wallet(`0x${await secrets.get(`PUSH_CHANNEL_PK@${chainId}`)}`); + const network = { 5: Network.GOERLI }[chainId] ?? Network.MAINNET; + const provider = new StaticJsonRpcProvider(gateways.getGateway(network)); + + // eslint-disable-next-line no-underscore-dangle + const subscribers = await (channels._getSubscribers({ channel, env }) as Promise) + .catch((e) => { + const msg = `Error getting subscribers for chainId ${chainId}`; + console.error(msg, e); + throw new Error(msg); + }); + + const previewerAddress = (await import(`@exactly-protocol/protocol/deployments/${network}/Previewer.json`)) as ({ address: string }); + const [, exactlyData] = await multicall.connect(provider).callStatic.aggregate( + subscribers.map((account) => ({ + target: previewerAddress.address, + callData: previewer.encodeFunctionData('exactly', [account]), + })), + ); + + const chainResult = await Promise.all( + subscribers.map((subscriber: string, index) => { + const [exactly] = previewer.decodeFunctionResult('exactly', exactlyData[index]) as [Previewer.MarketAccountStructOutput[]]; + return exactly.map(({ + assetSymbol: symbol, decimals, penaltyRate, fixedBorrowPositions, + }) => fixedBorrowPositions.map( + async ({ maturity, position: { principal }, previewValue }) => { + if (previewValue.isZero() || Number(maturity) - now > delay) return null; + const days = Math.floor((Number(maturity) - now) / 86_400); + const title = titleMsg(days, symbol); + const totalDebt = formatUnits(previewValue, decimals); + + const body = bodyMsg(days, decimals, principal, symbol, totalDebt, penaltyRate); + const maturityISO = new Date(Number(maturity) * 1_000).toISOString().slice(0, 10); + try { + const response = await payloads.sendNotification({ + channel, + env, + signer, + type: NotificationType.target, + recipients: subscriber, + notification: { title, body }, + payload: { + title, body, cta: `https://${app}.exact.ly/dashboard`, img: '', // TODO: exa asset img? + }, + identityType: 0, // minimal payload + }); + return { + symbol, + maturityISO, + subscriber, + successfullySent: response?.status === 204, + totalDebt: formatUnits(previewValue, decimals), + chainId: Number(chainId), + }; + } catch (error) { + return { + symbol, + maturityISO, + subscriber, + successfullySent: false, + error, + totalDebt: formatUnits(previewValue, decimals), + chainId: Number(chainId), + }; + } + }, + )).flat(); + }).flat(), + ) as SentNotification[]; + + const [slack, monitoring, receipts] = await Promise.all([ + getSecret(secrets, 'SLACK_TOKEN').then((token) => new WebClient(token)), + getSecret(secrets, `SLACK_MONITORING@${chainId}`), + getSecret(secrets, `SLACK_RECEIPTS@${chainId}`), + ]); + + if (!receipts) console.error(`No slack receipts channel found for chainId ${chainId}`); + else { + const sent = chainResult.filter(({ successfullySent }) => successfullySent); + await slack.chat.postMessage({ + channel: receipts, + attachments: [ + { + color: 'good', + title: `Sent ${sent.length} notifications successfully for ${network} network.`, + fields: sent.flatMap( + ({ + symbol, maturityISO, subscriber, totalDebt, + }) => [ + { title: 'symbol', value: symbol, short: true }, + { title: 'maturity', value: maturityISO, short: true }, + { title: 'total debt', value: totalDebt }, + { title: 'account', value: subscriber }, + ], + ), + footer: network, + ts: now.toString(), + }, + ], + }); + } + + if (!monitoring) { + console.error(`No slack monitoring channel found for chainId ${chainId}`); + return chainResult; + } + const failed = chainResult.filter(({ successfullySent }) => !successfullySent); + if (failed.length) { + await slack.chat.postMessage({ + channel: monitoring, + attachments: [ + { + color: 'danger', + title: `${failed.length} notifications failed for chain ${chainId} subscribers.`, + fields: failed.flatMap( + ({ + symbol, maturityISO, subscriber, error, totalDebt, + }) => [ + { short: true, title: 'symbol', value: symbol }, + { short: true, title: 'maturity', value: maturityISO }, + { short: true, title: 'total debt', value: totalDebt }, + { + short: true, + title: 'error', + value: error instanceof Error ? error?.message : 'Unknown error.', + }, + { title: 'account', value: subscriber }, + ], + ), + footer: network, + ts: now.toString(), + }, + ], + }); + } + return chainResult; + }), + ); + + console.log('***********************************'); + console.log('Sent notifications: '); + console.table(results.flat(2)); + await storage.putJson(NOTIFICATIONS_KEY, { + lastRun: now, + notifications: results.flat(2), + }); + console.log('***********************************'); + } catch (error) { + console.error(error); + } +}) as ActionFn; diff --git a/actions/package-lock.json b/actions/package-lock.json index 9c27be9..d914a2c 100644 --- a/actions/package-lock.json +++ b/actions/package-lock.json @@ -12,7 +12,10 @@ "@ethersproject/hash": "^5.7.0", "@ethersproject/logger": "^5.7.0", "@ethersproject/providers": "^5.7.2", + "@ethersproject/units": "^5.7.0", + "@ethersproject/wallet": "^5.7.0", "@exactly-protocol/protocol": "^0.2.7", + "@pushprotocol/restapi": "^0.7.5", "@slack/web-api": "^6.8.1", "@tenderly/actions": "^0.1.1", "no-case": "^3.0.4" @@ -21,6 +24,53 @@ "node": ">=16" } }, + "node_modules/@chainsafe/as-sha256": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@chainsafe/as-sha256/-/as-sha256-0.3.1.tgz", + "integrity": "sha512-hldFFYuf49ed7DAakWVXSJODuq3pzJEguD8tQ7h+sGkM18vja+OFoJI9krnGmgzyuZC2ETX0NOIcCTy31v2Mtg==" + }, + "node_modules/@chainsafe/persistent-merkle-tree": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@chainsafe/persistent-merkle-tree/-/persistent-merkle-tree-0.4.2.tgz", + "integrity": "sha512-lLO3ihKPngXLTus/L7WHKaw9PnNJWizlOF1H9NNzHP6Xvh82vzg9F2bzkXhYIFshMZ2gTCEz8tq6STe7r5NDfQ==", + "dependencies": { + "@chainsafe/as-sha256": "^0.3.1" + } + }, + "node_modules/@chainsafe/ssz": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@chainsafe/ssz/-/ssz-0.9.4.tgz", + "integrity": "sha512-77Qtg2N1ayqs4Bg/wvnWfg5Bta7iy7IRh8XqXh7oNMeP2HBbBwx8m6yTpA8p0EHItWPEBkgZd5S5/LSlp3GXuQ==", + "dependencies": { + "@chainsafe/as-sha256": "^0.3.1", + "@chainsafe/persistent-merkle-tree": "^0.4.2", + "case": "^1.6.3" + } + }, + "node_modules/@ethereumjs/rlp": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-4.0.1.tgz", + "integrity": "sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==", + "bin": { + "rlp": "bin/rlp" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@ethereumjs/util": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-8.0.5.tgz", + "integrity": "sha512-259rXKK3b3D8HRVdRmlOEi6QFvwxdt304hhrEAmpZhsj7ufXEOTIc9JRZPMnXatKjECokdLNBcDOFBeBSzAIaw==", + "dependencies": { + "@chainsafe/ssz": "0.9.4", + "@ethereumjs/rlp": "^4.0.1", + "ethereum-cryptography": "^1.1.2" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@ethersproject/abi": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", @@ -261,6 +311,65 @@ "@ethersproject/strings": "^5.7.0" } }, + "node_modules/@ethersproject/hdnode": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", + "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/basex": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/pbkdf2": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/sha2": "^5.7.0", + "@ethersproject/signing-key": "^5.7.0", + "@ethersproject/strings": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/wordlists": "^5.7.0" + } + }, + "node_modules/@ethersproject/json-wallets": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", + "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hdnode": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/pbkdf2": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/random": "^5.7.0", + "@ethersproject/strings": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "aes-js": "3.0.0", + "scrypt-js": "3.0.1" + } + }, "node_modules/@ethersproject/keccak256": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", @@ -313,6 +422,25 @@ "@ethersproject/logger": "^5.7.0" } }, + "node_modules/@ethersproject/pbkdf2": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", + "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/sha2": "^5.7.0" + } + }, "node_modules/@ethersproject/properties": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", @@ -449,6 +577,30 @@ "hash.js": "1.1.7" } }, + "node_modules/@ethersproject/solidity": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", + "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "peer": true, + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/sha2": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, "node_modules/@ethersproject/strings": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", @@ -495,6 +647,58 @@ "@ethersproject/signing-key": "^5.7.0" } }, + "node_modules/@ethersproject/units": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", + "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/logger": "^5.7.0" + } + }, + "node_modules/@ethersproject/wallet": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", + "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ethersproject/hdnode": "^5.7.0", + "@ethersproject/json-wallets": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/random": "^5.7.0", + "@ethersproject/signing-key": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ethersproject/wordlists": "^5.7.0" + } + }, "node_modules/@ethersproject/web": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", @@ -517,6 +721,28 @@ "@ethersproject/strings": "^5.7.0" } }, + "node_modules/@ethersproject/wordlists": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", + "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ethersproject/logger": "^5.7.0", + "@ethersproject/properties": "^5.7.0", + "@ethersproject/strings": "^5.7.0" + } + }, "node_modules/@exactly-protocol/protocol": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@exactly-protocol/protocol/-/protocol-0.2.7.tgz", @@ -531,6 +757,49 @@ "node": ">=16" } }, + "node_modules/@metamask/eth-sig-util": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@metamask/eth-sig-util/-/eth-sig-util-5.0.2.tgz", + "integrity": "sha512-RU6fG/H6/UlBol221uBkq5C7w3TwLK611nEZliO2u+kO0vHKGBXnIPlhI0tzKUigjhUeOd9mhCNbNvhh0LKt9Q==", + "dependencies": { + "@ethereumjs/util": "^8.0.0", + "bn.js": "^4.11.8", + "ethereum-cryptography": "^1.1.2", + "ethjs-util": "^0.1.6", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@metamask/eth-sig-util/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, + "node_modules/@noble/hashes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.2.0.tgz", + "integrity": "sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@noble/secp256k1": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/@openzeppelin/contracts": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.8.1.tgz", @@ -541,6 +810,64 @@ "resolved": "https://registry.npmjs.org/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.8.2.tgz", "integrity": "sha512-zIggnBwemUmmt9IS73qxi+tumALxCY4QEs3zLCII78k0Gfse2hAOdAkuAeLUzvWUpneMUfFE5sGHzEUSTvn4Ag==" }, + "node_modules/@pushprotocol/restapi": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@pushprotocol/restapi/-/restapi-0.7.5.tgz", + "integrity": "sha512-Q22UZNAAisUNvX0EPA94QjY6U3XFZUysgDRo5Ii81DBqUNu57jjcVsU5eDrRmQgYCL1a5xrCUV7Io1xtZGsyFQ==", + "dependencies": { + "@metamask/eth-sig-util": "^5.0.0", + "axios": "^0.27.2", + "crypto-js": "^4.1.1", + "openpgp": "^5.5.0", + "tslib": "^2.3.0", + "uuid": "^9.0.0" + }, + "peerDependencies": { + "ethers": "^5.6.8" + } + }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.1.5.tgz", + "integrity": "sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "~1.2.0", + "@noble/secp256k1": "~1.7.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/@scure/bip39": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.1.tgz", + "integrity": "sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "~1.2.0", + "@scure/base": "~1.1.0" + } + }, "node_modules/@slack/logger": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", @@ -607,6 +934,27 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "node_modules/aes-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", + "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -649,6 +997,14 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" }, + "node_modules/case": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/case/-/case-1.6.3.tgz", + "integrity": "sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -660,6 +1016,11 @@ "node": ">= 0.8" } }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -687,6 +1048,78 @@ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, + "node_modules/ethereum-cryptography": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz", + "integrity": "sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw==", + "dependencies": { + "@noble/hashes": "1.2.0", + "@noble/secp256k1": "1.7.1", + "@scure/bip32": "1.1.5", + "@scure/bip39": "1.1.1" + } + }, + "node_modules/ethers": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", + "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "peer": true, + "dependencies": { + "@ethersproject/abi": "5.7.0", + "@ethersproject/abstract-provider": "5.7.0", + "@ethersproject/abstract-signer": "5.7.0", + "@ethersproject/address": "5.7.0", + "@ethersproject/base64": "5.7.0", + "@ethersproject/basex": "5.7.0", + "@ethersproject/bignumber": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/constants": "5.7.0", + "@ethersproject/contracts": "5.7.0", + "@ethersproject/hash": "5.7.0", + "@ethersproject/hdnode": "5.7.0", + "@ethersproject/json-wallets": "5.7.0", + "@ethersproject/keccak256": "5.7.0", + "@ethersproject/logger": "5.7.0", + "@ethersproject/networks": "5.7.1", + "@ethersproject/pbkdf2": "5.7.0", + "@ethersproject/properties": "5.7.0", + "@ethersproject/providers": "5.7.2", + "@ethersproject/random": "5.7.0", + "@ethersproject/rlp": "5.7.0", + "@ethersproject/sha2": "5.7.0", + "@ethersproject/signing-key": "5.7.0", + "@ethersproject/solidity": "5.7.0", + "@ethersproject/strings": "5.7.0", + "@ethersproject/transactions": "5.7.0", + "@ethersproject/units": "5.7.0", + "@ethersproject/wallet": "5.7.0", + "@ethersproject/web": "5.7.1", + "@ethersproject/wordlists": "5.7.0" + } + }, + "node_modules/ethjs-util": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ethjs-util/-/ethjs-util-0.1.6.tgz", + "integrity": "sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==", + "dependencies": { + "is-hex-prefixed": "1.0.0", + "strip-hex-prefix": "1.0.0" + }, + "engines": { + "node": ">=6.5.0", + "npm": ">=3" + } + }, "node_modules/eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", @@ -753,6 +1186,15 @@ "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.0.tgz", "integrity": "sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q==" }, + "node_modules/is-hex-prefixed": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-hex-prefixed/-/is-hex-prefixed-1.0.0.tgz", + "integrity": "sha512-WvtOiug1VFrE9v1Cydwm+FnXd3+w9GaeVUss5W4v/SLy3UW00vP+6iNF2SdnfiBoLy4bTqVdkftNGTUeOFVsbA==", + "engines": { + "node": ">=6.5.0", + "npm": ">=3" + } + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -812,6 +1254,17 @@ "tslib": "^2.0.3" } }, + "node_modules/openpgp": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-5.7.0.tgz", + "integrity": "sha512-wchYJQfFbSaocUvUIYqNrWD+lRSmFSG1d3Ak2CHeXFocDSEsf7Uc1zUzHjSdlZPTvGeeXPQ+MJrwVtalL4QCBg==", + "dependencies": { + "asn1.js": "^5.0.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -871,17 +1324,57 @@ "node": ">= 4" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/scrypt-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", + "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==" + }, "node_modules/solmate": { "name": "@rari-capital/solmate", "version": "7.0.0-alpha.3", "resolved": "git+ssh://git@github.com/transmissions11/solmate.git#ed67feda67b24fdeff8ad1032360f0ee6047ba0a", "license": "MIT" }, + "node_modules/strip-hex-prefix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz", + "integrity": "sha512-q8d4ue7JGEiVcypji1bALTos+0pWtyGlivAWyPuTkHzuTCJqrK9sWxYQZUq6Nq3cuyv3bm734IhHvHtGGURU6A==", + "dependencies": { + "is-hex-prefixed": "1.0.0" + }, + "engines": { + "node": ">=6.5.0", + "npm": ">=3" + } + }, "node_modules/tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" + }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/ws": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", diff --git a/actions/package.json b/actions/package.json index 2a73c31..141ed91 100755 --- a/actions/package.json +++ b/actions/package.json @@ -13,7 +13,10 @@ "@ethersproject/hash": "^5.7.0", "@ethersproject/logger": "^5.7.0", "@ethersproject/providers": "^5.7.2", + "@ethersproject/units": "^5.7.0", + "@ethersproject/wallet": "^5.7.0", "@exactly-protocol/protocol": "^0.2.7", + "@pushprotocol/restapi": "^0.7.5", "@slack/web-api": "^6.8.1", "@tenderly/actions": "^0.1.1", "no-case": "^3.0.4" diff --git a/tenderly.template.yaml b/tenderly.template.yaml index 18efb5c..136cb65 100644 --- a/tenderly.template.yaml +++ b/tenderly.template.yaml @@ -3,6 +3,12 @@ actions: runtime: v2 sources: actions specs: + onNotifyDebtors: + function: onNotifyDebtors:default + trigger: + type: periodic + periodic: + cron: "0 0 * * *" onMarketUpdate: function: onMarketUpdate:default trigger: diff --git a/test/onNotifyDebtors.spec.ts b/test/onNotifyDebtors.spec.ts new file mode 100644 index 0000000..424d520 --- /dev/null +++ b/test/onNotifyDebtors.spec.ts @@ -0,0 +1,58 @@ +import 'dotenv/config'; +import { env } from 'process'; +import { expect, use } from 'chai'; +import { TestRuntime } from '@tenderly/actions-test'; +import chaiAsPromised from 'chai-as-promised'; +import onNotifyDebtors, { + DELAY_KEY, + NOTIFICATIONS_KEY, + SentNotification, +} from '../actions/onNotifyDebtors'; + +use(chaiAsPromised); + +const { + GATEWAY_ACCESS_KEY, SLACK_TOKEN, SLACK_MONITORING, PUSH_CHANNEL_PK, SLACK_RECEIPTS, +} = env; + +// 4 weeks delay so there's always a maturity to notify if subscribers have positions +const delay = 4 * 60 * 60 * 24; + +type Result = { + lastRun: number; + notifications: SentNotification[]; +}; + +describe('on notify debtors', () => { + let runtime: TestRuntime; + const time = new Date(); + + before(async () => { + runtime = new TestRuntime(); + runtime.context.gateways.setConfig('', { accessKey: GATEWAY_ACCESS_KEY }); + await runtime.context.storage.putNumber(DELAY_KEY, delay); + + if (PUSH_CHANNEL_PK) { + runtime.context.secrets.put('PUSH_CHANNEL_PK@5', PUSH_CHANNEL_PK); + } + if (SLACK_TOKEN) runtime.context.secrets.put('SLACK_TOKEN', SLACK_TOKEN); + if (SLACK_MONITORING) { + runtime.context.secrets.put('SLACK_MONITORING@5', SLACK_MONITORING); + } + if (SLACK_RECEIPTS) { + runtime.context.secrets.put('SLACK_RECEIPTS@5', SLACK_RECEIPTS); + } + + await runtime.execute(onNotifyDebtors, { time }); + }); + + it('should store `lastRun` results', async () => { + const { lastRun } = (await runtime.context.storage.getJson(NOTIFICATIONS_KEY)) as Result; + expect(lastRun).to.equal(Math.floor(time.getTime() / 1000)); + }); + + it('should not have failed notifications sent', async () => { + const { notifications } = (await runtime.context.storage.getJson(NOTIFICATIONS_KEY)) as Result; + expect(notifications.some(({ successfullySent }) => !successfullySent)).to.equal(false); + }); +});