Skip to content

Commit

Permalink
✨ push: notify subscribers with fixed debt
Browse files Browse the repository at this point in the history
🐛 push: fix `penaltyRate` percentage on msg
  • Loading branch information
itofarina committed Apr 20, 2023
1 parent 155fd64 commit 19fdf03
Show file tree
Hide file tree
Showing 2 changed files with 306 additions and 0 deletions.
248 changes: 248 additions & 0 deletions actions/onNotifyDebtors.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>)
.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;
58 changes: 58 additions & 0 deletions test/onNotifyDebtors.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 19fdf03

Please sign in to comment.