Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(magicusd0pp): Harvest USUAL rewards #18

Merged
merged 8 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
| Stargate S\*USDT | [task](https://app.gelato.network/functions/task/0x37ab785e9a1200fb8bac63b431e36da085b177841b24aa0dfab0a4981122da0a:1) |
| GM Strategy Harvester | [task](https://app.gelato.network/functions/task/0x40d7aadde626b52e7df27bcab3f92c42faf3f137d50fc98dffc79d20c9119314:42161) |
| Reward Distributor | [task](https://app.gelato.network/functions/task/0x1b62e611e8e3d87ec8c7ced57230d341802c0ac6c611afb9a9fb4a3c53dc6ac1:42161) |
| MagicUSD0pp Claimer | [task](https://app.gelato.network/functions/task/0x746fd28a88217d74f693dd3ec738ce699a174b01a875bce3c023262020048d3e:1) |
| MagicUSD0pp Handler | [task](https://app.gelato.network/functions/task/0x0e8f9611aaf09ab1b9920064774a44c4e872a161235b0ea7a03ebfb1188b8fe6:1) |
17 changes: 10 additions & 7 deletions scripts/create-magicusd0pp-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { DEVOPS_SAFE } from "../utils/constants";

const { ethers, w3f } = hre;

const THIRTY_SECONDS = 30 * 1000;

const main = async () => {
const magicUsd0pp = w3f.get("magicusd0pp");

Expand All @@ -21,16 +19,21 @@ const main = async () => {

const { tx } = await automate.prepareBatchExecTask(
{
name: "MagicUSD0pp Off-Chain Distribution Claimer",
name: "MagicUSD0pp Handler",
web3FunctionHash: cid,
trigger: {
type: TriggerType.TIME,
interval: THIRTY_SECONDS,
type: TriggerType.BLOCK,
},
web3FunctionArgs: {
execAddress: "0x75cC0C0DDD2Ccafe6EC415bE686267588011E36A",
usualApiEndpoint: "https://app.usual.money/api/rewards",
distributionAddress: "0x75cC0C0DDD2Ccafe6EC415bE686267588011E36A",
harvesterAddress: "0x80014629Ca75441599A1efd2283E3f71A8EC0AAB",
magicUsd0ppAddress: "0x73075fD1522893D9dC922991542f98F08F2c1C99",
usd0ppAddress: "0x35D8949372D46B7a3D5A56006AE77B215fc69bC0",
usualAddress: "0xC4441c2BE5d8fA8126822B9929CA0b81Ea0DE38E",
usualApiEndpoint: "https://app.usual.money/api",
odosApiEndpoint: "https://api.odos.xyz",
slippageLimitBps: 15,
minimumSwapUsd: 1000,
},
},
{},
Expand Down
80 changes: 80 additions & 0 deletions utils/odos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import ky from "ky";
import * as R from "remeda";
import type { Address, Hex } from "viem";

export type OdosQuoteParameters = {
endpoint: string;
chainId: number;
inputTokens: Array<{ tokenAddress: Address; amount: bigint }>;
outputTokens: Array<{ tokenAddress: Address; proportion: number }>;
userAddr: Address;
slippageLimitPercent?: number;
disableRFQs?: boolean;
compact?: boolean;
simple?: boolean;
};

export async function odosQuote(quoteParameters: OdosQuoteParameters) {
const odosApi = ky.extend({
prefixUrl: quoteParameters.endpoint,
});

const { pathId } = await odosApi
.post("sor/quote/v2", {
json: {
...R.omit(quoteParameters, ["endpoint"]),
inputTokens: quoteParameters.inputTokens.map(
({ tokenAddress, amount }) => ({
tokenAddress,
amount: amount.toString(),
}),
),
},
})
.json<{ pathId: string }>();

const assembledQuote = await odosApi
.post("sor/assemble", {
json: {
pathId,
userAddr: quoteParameters.userAddr,
},
})
.json<{
blockNumber: number;
gasEstimate: number;
gasEstimateValue: number;
inputTokens: Array<{ tokenAddress: Address; amount: `${bigint}` }>;
outputTokens: Array<{ tokenAddress: Address; amount: `${bigint}` }>;
netOutValue: number;
outValues: number[];
transaction: {
gas: number;
gasPrice: number;
value: `${bigint}`;
to: Address;
from: Address;
data: Hex;
nonce: number;
chainId: number;
};
}>();

return {
...assembledQuote,
inputTokens: assembledQuote.inputTokens.map(({ tokenAddress, amount }) => ({
tokenAddress,
amount: BigInt(amount),
})),
outputTokens: assembledQuote.outputTokens.map(
({ tokenAddress, amount }) => ({
tokenAddress,
amount: BigInt(amount),
}),
),
transaction: {
...assembledQuote.transaction,
value: BigInt(assembledQuote.transaction.value),
},
};
}
194 changes: 153 additions & 41 deletions web3-functions/magicusd0pp/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
import {
Web3Function,
type Web3FunctionContext,
Web3FunctionResult,
type Web3FunctionResultCallData,
} from "@gelatonetwork/web3-functions-sdk";
import ky from "ky";
import { type Address, type Hex, encodeFunctionData, parseAbi } from "viem";
import {
type Address,
type Hex,
encodeFunctionData,
erc20Abi,
parseAbi,
parseEther,
} from "viem";
import { odosQuote } from "../../utils/odos";
import { createJsonRpcPublicClient } from "../../utils/viem";

const DISTRIBUTION_ABI = parseAbi([
"function getOffChainDistributionData() external view returns (uint256 timestamp, bytes32 merkleRoot)",
"function getOffChainTokensClaimed(address account) external view returns (uint256)",
"struct QueuedOffChainDistribution { uint256 timestamp; bytes32 merkleRoot; }",
"function getOffChainDistributionQueue() external view returns (QueuedOffChainDistribution[] memory)",
"function claimOffChainDistribution(address account, uint256 amount, bytes32[] calldata proof) external",
"function approveUnchallengedOffChainDistribution() external",
]);

const HARVESTER_ABI = parseAbi([
"function run(address router, bytes memory swapData, uint256 minAmountOut) external",
]);

const USUAL_DISTRIBUTION_CHALLENGE_PERIOD = 604800n; // 1 week
const WAD = 10n ** 18n;

type MagicUsd0ppUserArgs = {
execAddress: Address;
usualApiEndpoint: string;
distributionAddress: Address;
harvesterAddress: Address;
magicUsd0ppAddress: Address;
usd0ppAddress: Address;
usualAddress: Address;
usualApiEndpoint: string;
odosApiEndpoint: string;
slippageLimitBps: number;
minimumSwapUsd: number;
};

type OffChainDistribution = {
Expand All @@ -29,65 +53,153 @@ type OffChainDistribution = {

Web3Function.onRun(
async ({ userArgs, multiChainProvider }: Web3FunctionContext) => {
const { execAddress, usualApiEndpoint, magicUsd0ppAddress } =
userArgs as MagicUsd0ppUserArgs;
const {
distributionAddress,
harvesterAddress,
magicUsd0ppAddress,
usd0ppAddress,
usualAddress,
usualApiEndpoint,
odosApiEndpoint,
slippageLimitBps,
minimumSwapUsd,
} = userArgs as MagicUsd0ppUserArgs;

const client = createJsonRpcPublicClient(multiChainProvider.default());
const usualRewardsApi = ky.extend({
prefixUrl: new URL("rewards", new URL(usualApiEndpoint)),
prefixUrl: `${usualApiEndpoint}/rewards`,
});

const [[, currentMerkleRoot], claimedTokens, distributions] =
await Promise.all([
client.readContract({
abi: DISTRIBUTION_ABI,
address: execAddress,
functionName: "getOffChainDistributionData",
}),
client.readContract({
abi: DISTRIBUTION_ABI,
address: execAddress,
functionName: "getOffChainTokensClaimed",
args: [magicUsd0ppAddress],
}),
usualRewardsApi
.get(magicUsd0ppAddress)
.json<Array<OffChainDistribution>>(),
]);
const [
[, currentMerkleRoot],
offChainDistributionQueue,
claimedTokens,
usualBalance,
distributions,
] = await Promise.all([
client.readContract({
abi: DISTRIBUTION_ABI,
address: distributionAddress,
functionName: "getOffChainDistributionData",
}),
client.readContract({
abi: DISTRIBUTION_ABI,
address: distributionAddress,
functionName: "getOffChainDistributionQueue",
}),
client.readContract({
abi: DISTRIBUTION_ABI,
address: distributionAddress,
functionName: "getOffChainTokensClaimed",
args: [magicUsd0ppAddress],
}),
client.readContract({
abi: erc20Abi,
address: usualAddress,
functionName: "balanceOf",
args: [magicUsd0ppAddress],
}),
usualRewardsApi
.get(magicUsd0ppAddress)
.json<Array<OffChainDistribution>>(),
]);

if (distributions.length === 0) {
return { canExec: false, message: "No distributions" };
const currentTimestamp = BigInt(Math.floor(Date.now() / 1000));
let pendingDistribution:
| (typeof offChainDistributionQueue)[number]
| undefined = undefined;
for (const distribution of offChainDistributionQueue) {
if (
currentTimestamp >=
distribution.timestamp + USUAL_DISTRIBUTION_CHALLENGE_PERIOD &&
distribution.timestamp > (pendingDistribution?.timestamp ?? 0n)
) {
pendingDistribution = distribution;
}
}

const distributionMerkleRoot =
pendingDistribution?.merkleRoot ?? currentMerkleRoot;
const currentDistribution = distributions.find(
({ merkleRoot }) =>
merkleRoot.toLowerCase() === currentMerkleRoot.toLowerCase(),
merkleRoot.toLowerCase() === distributionMerkleRoot.toLowerCase(),
);

if (currentDistribution === undefined) {
return { canExec: false, message: "No matching distribution" };
}
let newUsualBalance = usualBalance;
const callData: Web3FunctionResultCallData[] = [];

if (claimedTokens === BigInt(currentDistribution.value)) {
return { canExec: false, message: "Already claimed latest distribution" };
}
if (currentDistribution !== undefined) {
const currentDistributionValue = BigInt(currentDistribution.value);

return {
canExec: true,
callData: [
{
to: execAddress,
if (claimedTokens < currentDistributionValue) {
newUsualBalance += currentDistributionValue - claimedTokens;

if (pendingDistribution !== undefined) {
callData.push({
to: distributionAddress,
data: encodeFunctionData({
abi: DISTRIBUTION_ABI,
functionName: "approveUnchallengedOffChainDistribution",
}),
});
}

callData.push({
to: distributionAddress,
data: encodeFunctionData({
abi: DISTRIBUTION_ABI,
functionName: "claimOffChainDistribution",
args: [
magicUsd0ppAddress,
BigInt(currentDistribution.value),
currentDistributionValue,
currentDistribution.merkleProof,
],
}),
},
],
});
}
}

if (newUsualBalance > 0n) {
const quote = await odosQuote({
endpoint: odosApiEndpoint,
chainId: multiChainProvider.default().network.chainId,
inputTokens: [{ tokenAddress: usualAddress, amount: newUsualBalance }],
outputTokens: [{ tokenAddress: usd0ppAddress, proportion: 1 }],
userAddr: harvesterAddress,
slippageLimitPercent: slippageLimitBps / 100,
disableRFQs: true,
});

if (quote.netOutValue < minimumSwapUsd) {
return {
canExec: false,
message: "Insufficient swap output",
};
}

callData.push({
to: harvesterAddress,
data: encodeFunctionData({
abi: HARVESTER_ABI,
functionName: "run",
args: [
quote.transaction.to,
quote.transaction.data,
(quote.outputTokens[0].amount *
parseEther(`${(10_000 - slippageLimitBps) / 10_000}`)) /
WAD,
],
}),
});
}

if (callData.length === 0) {
return { canExec: false, message: "No action needed" };
}

return {
canExec: true,
callData,
};
},
);
10 changes: 8 additions & 2 deletions web3-functions/magicusd0pp/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
"memory": 128,
"timeout": 30,
"userArgs": {
"execAddress": "string",
"distributionAddress": "string",
"harvesterAddress": "string",
"magicUsd0ppAddress": "string",
"usd0ppAddress": "string",
"usualAddress": "string",
"usualApiEndpoint": "string",
"magicUsd0ppAddress": "string"
"odosApiEndpoint": "string",
"slippageLimitBps": "number",
"minimumSwapUsd": "number"
}
}
12 changes: 9 additions & 3 deletions web3-functions/magicusd0pp/userArgs.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
{
"execAddress": "0x75cC0C0DDD2Ccafe6EC415bE686267588011E36A",
"usualApiEndpoint": "https://app.usual.money/api/rewards",
"magicUsd0ppAddress": "0x73075fD1522893D9dC922991542f98F08F2c1C99"
"distributionAddress": "0x75cC0C0DDD2Ccafe6EC415bE686267588011E36A",
"harvesterAddress": "0x80014629Ca75441599A1efd2283E3f71A8EC0AAB",
"magicUsd0ppAddress": "0x73075fD1522893D9dC922991542f98F08F2c1C99",
"usd0ppAddress": "0x35D8949372D46B7a3D5A56006AE77B215fc69bC0",
"usualAddress": "0xC4441c2BE5d8fA8126822B9929CA0b81Ea0DE38E",
"usualApiEndpoint": "https://app.usual.money/api",
"odosApiEndpoint": "https://api.odos.xyz",
"slippageLimitBps": 15,
"minimumSwapUsd": 1000
}
Loading