Skip to content

Commit

Permalink
Merge pull request #37 from polywrap/nerfzael/multi-send
Browse files Browse the repository at this point in the history
Multisend with disperse contract
  • Loading branch information
dOrgJelli authored Jan 26, 2024
2 parents 0f62e8d + 1c3c5ef commit 8ddabfa
Show file tree
Hide file tree
Showing 13 changed files with 431 additions and 34 deletions.
1 change: 0 additions & 1 deletion web/app/WalletProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client"

import Onboard from "@web3-onboard/core"
import { Web3OnboardProvider, init } from "@web3-onboard/react";
import injectedModule from "@web3-onboard/injected-wallets";

Expand Down
84 changes: 77 additions & 7 deletions web/components/Strategy.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import Button from "./Button";
import { StrategyTable, StrategyWithProjects } from "./StrategyTable";
import TextField from "./TextField";
import { useConnectWallet } from "@web3-onboard/react";
import Dropdown from "./Dropdown";
import { pluralize } from "@/app/lib/utils/pluralize";
import { ethers } from "ethers";
import { distributeWeights } from "@/utils/distributeWeights";
import { NetworkName, TokenInformation, getSupportedNetworkFromWallet, getTokensForNetwork, splitTransferFunds } from "@/utils/ethereum";

function Information(props: {
title: string;
Expand Down Expand Up @@ -36,8 +39,23 @@ export default function Strategy(props: {
props.strategy
);
const [currentPromp, setCurrentPrompt] = useState<string>(props.prompt);
const [amount, setAmount] = useState<number>(0);
const [token, setToken] = useState<TokenInformation | undefined>(undefined);
const [amount, setAmount] = useState<string>("0");
const [{ wallet }, connectWallet] = useConnectWallet();
const [isTransferPending, setIsTransferPending] = useState(false);
const network: NetworkName | undefined = getSupportedNetworkFromWallet(wallet);

console.log("network", network)

const tokens = network
? getTokensForNetwork(network)
: [];

useEffect(() => {
if (tokens.length) {
setToken(tokens[0]);
}
}, [tokens]);

const selectedStrategiesLength = currentStrategy.filter(
({ selected }) => selected
Expand All @@ -51,6 +69,46 @@ export default function Strategy(props: {
// TODO: Attach current prompt with regenerate action
}

async function transferFunds() {
if (!wallet || isTransferPending || !token) return;

const ethersProvider = new ethers.providers.Web3Provider(wallet.provider, "any");

const signer = ethersProvider.getSigner()

const projects = currentStrategy
.filter(({ selected }) => selected)
.filter(({ weight }) => weight)
.map(({ weight }) => ({
//TODO: Use real addresses
address: "0xB1B7586656116D546033e3bAFF69BFcD6592225E",
weight: weight as number,
}));

const amounts = distributeWeights(
projects.map(project => project.weight),
+amount,
token.decimals
);

setIsTransferPending(true);

console.log(projects, amounts, signer, token)
try {
await splitTransferFunds(
projects.map((project) => project.address),
amounts,
signer,
token.address,
token.decimals
);
} catch (e) {
throw e;
} finally {
setIsTransferPending(false);
}
}

return (
<div className='flex justify-center py-10 flex-grow flex-column'>
<div className='flex flex-col gap-4 mx-auto max-w-wrapper space-y-4'>
Expand All @@ -69,14 +127,26 @@ export default function Strategy(props: {
</p>
</div>
<div className='flex flex-col gap-4 bg-indigo-50 shadow-xl shadow-primary-shadow/10 rounded-3xl border-2 border-indigo-200 p-4'>
{!!wallet && (
{!!wallet && token && (
<TextField
label='Total Funding Amount'
rightAdornment={
<Dropdown items={["USDC"]} field={{ value: "USDC" }} />
<Dropdown items={tokens.map(x => x.name)} field={{ value: token.name }} onChange={val => setToken(tokens.find(x => x.name === val) as TokenInformation)} />
}
value={amount}
onChange={(e) => setAmount(+e.target.value)}
onChange={(e) => {
const newValue = e.target.value;
// Allow only numbers with optional single leading zero, and only one decimal point
if (/^(0|[1-9]\d*)?(\.\d*)?$/.test(newValue)) {
setAmount(newValue);
} else {
// Fix the value to remove the invalid characters, maintaining only one leading zero if present
const fixedValue = newValue.replace(/[^0-9.]/g, "")
.replace(/^0+(?=\d)/, "")
.replace(/(\..*)\./g, '$1');
setAmount(fixedValue);
}
}}
/>
)}
<StrategyTable
Expand Down Expand Up @@ -105,8 +175,8 @@ export default function Strategy(props: {
)}`}
subtitle="Please provide an amount you'd like to fund"
action='Next →'
onClick={() => {}}
disabled={selectedStrategiesLength === 0 || amount === 0}
onClick={transferFunds}
disabled={selectedStrategiesLength === 0 || amount === "0" || isTransferPending}
/>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@web3-onboard/injected-wallets": "^2.10.11",
"@web3-onboard/react": "^2.8.13",
"clsx": "^2.1.0",
"ethers": "5.7.2",
"next": "14.0.4",
"react": "18.2.0",
"react-dom": "18.2.0"
Expand Down
36 changes: 36 additions & 0 deletions web/utils/distributeWeights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export function distributeWeights(weights: number[], total: number, decimals: number): number[] {
// Calculate initial amounts
let amounts = weights.map(weight => weight * total);

// Round amounts to two decimals and calculate the sum of these amounts
let roundedAmounts = amounts.map(amount => parseFloat(amount.toFixed(decimals)));
let sumOfRoundedAmounts = roundedAmounts.reduce((a, b) => a + b, 0);

// Calculate the remainder
let remainder = total - sumOfRoundedAmounts;

// Distribute the remainder
// The idea here is to distribute the remainder starting from the largest fraction part
while (Math.abs(remainder) >= 0.01) {
let index = amounts.findIndex((amount, idx) =>
roundedAmounts[idx] < amount &&
(remainder > 0 || roundedAmounts[idx] > 0)
);

if (index === -1) {
break; // Break if no suitable item is found
}

if (remainder > 0) {
roundedAmounts[index] += 0.01;
remainder -= 0.01;
} else {
roundedAmounts[index] -= 0.01;
remainder += 0.01;
}

roundedAmounts[index] = parseFloat(roundedAmounts[index].toFixed(decimals));
}

return roundedAmounts;
}
14 changes: 14 additions & 0 deletions web/utils/ethereum/getSupportedNetworkFromWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { WalletState } from "@web3-onboard/core";
import { SUPPORTED_NETWORKS, NetworkName } from ".";

export function getSupportedNetworkFromWallet(wallet: WalletState | null) {
return wallet
? wallet.chains.length
? Object.values(SUPPORTED_NETWORKS).includes(+wallet.chains[0].id as any)
? Object.keys(SUPPORTED_NETWORKS).find((key) =>
SUPPORTED_NETWORKS[key as NetworkName] === +wallet.chains[0].id
) as NetworkName
: undefined
: undefined
: undefined;
}
13 changes: 13 additions & 0 deletions web/utils/ethereum/getTokensForNetwork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NetworkName, TokenInformation, supportedErc20TokensByNetwork } from ".";

export function getTokensForNetwork(network: NetworkName): TokenInformation[] {
if (!network || !supportedErc20TokensByNetwork[network]) return [];

const tokensForNetwork = supportedErc20TokensByNetwork[network];

if (!tokensForNetwork) return [];

const tokens: TokenInformation[] = Object.values(tokensForNetwork) as TokenInformation[];

return tokens.filter(token => token);
}
6 changes: 6 additions & 0 deletions web/utils/ethereum/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './getSupportedNetworkFromWallet';
export * from './getTokensForNetwork';
export * from './splitTransferFunds';
export * from './supportedErc20Tokens';
export * from './supportedErc20TokensByNetwork';
export * from './supportedNetworks';
57 changes: 57 additions & 0 deletions web/utils/ethereum/splitTransferFunds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ethers, BigNumber } from "ethers";

const ERC20_ABI = [
"function transfer(address to, uint256 value) external returns (bool)",
"function transferFrom(address from, address to, uint256 value) external returns (bool)",
"function approve(address spender, uint256 value) external returns (bool)",
"function allowance(address owner, address spender) external view returns (uint256)",
];

const DISPERSE_ABI = [
"function disperseEther(address[] recipients, uint256[] values) external payable",
"function disperseToken(address token, address[] recipients, uint256[] values) external",
"function disperseTokenSimple(address token, address[] recipients, uint256[] values) external"
];

const DISPERSE_CONTRACT_ADDRESS = "0xD152f549545093347A162Dce210e7293f1452150";

// Use address(0) or 'undefined' for ETH
export async function splitTransferFunds(
addresses: string[],
amounts: number[],
signer: ethers.Signer,
tokenAddress?: string,
tokenDecimals?: number
) {
const disperseContract = new ethers.Contract(DISPERSE_CONTRACT_ADDRESS, DISPERSE_ABI, signer);

const validAddresses = addresses.filter((address) => ethers.utils.getAddress(address));
const values = amounts.map((amount) => ethers.utils.parseUnits(amount.toString(), tokenDecimals));
const totalValue = values.reduce((acc, value) => acc.add(value), ethers.constants.Zero);

if (!tokenAddress || tokenAddress === ethers.constants.AddressZero) {
// Ether transfer
console.log("ether transfer");
await disperseContract.disperseEther(validAddresses, values, {
value: totalValue,
});
} else {
// ERC20 token transfer
console.log("tokenAddress", tokenAddress);
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, signer);

const currentAllowance: BigNumber = await tokenContract.allowance(
await signer.getAddress(),
DISPERSE_CONTRACT_ADDRESS
);
console.log("currentAllowance", currentAllowance);

if (currentAllowance.lt(totalValue)) {
const approveTx = await tokenContract.approve(DISPERSE_CONTRACT_ADDRESS, totalValue);
await approveTx.wait(1);
}

const transferTx = await disperseContract.disperseTokenSimple(tokenAddress, validAddresses, values);
await transferTx.wait(1);
}
}
20 changes: 20 additions & 0 deletions web/utils/ethereum/supportedErc20Tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NetworkName } from "./supportedNetworks";

export const supportedErc20Tokens = ["USDC", "USDT", "DAI", "WETH"] as const;
export type SupportedERC20Tokens = (typeof supportedErc20Tokens)[number];

export interface TokenInformation {
address: string;
decimals: number;
name: string;
}

export type SupportedTokensInformation = Partial<Record<
NetworkName,
Partial<Record<SupportedERC20Tokens, TokenInformation>>
>>;

export const isValidToken = (token: string): token is SupportedERC20Tokens => {
const tokens = supportedErc20Tokens as unknown as string[];
return tokens.includes(token);
};
40 changes: 40 additions & 0 deletions web/utils/ethereum/supportedErc20TokensByNetwork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NetworkName } from ".";
import { SupportedTokensInformation } from "./supportedErc20Tokens";

export const supportedErc20TokensByNetwork: SupportedTokensInformation = {
Sepolia: {
WETH: {
address: "0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
decimals: 18,
name: "WETH",
},
},
Mainnet: {
USDC: {
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
decimals: 6,
name: "USDC",
},
},
Polygon: {
USDC: {
address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
decimals: 6,
name: "USDC",
},
},
ArbitrumOne: {
USDC: {
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
decimals: 6,
name: "USDC",
},
},
Optimism: {
USDC: {
address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
decimals: 6,
name: "USDC",
},
},
};
21 changes: 21 additions & 0 deletions web/utils/ethereum/supportedNetworks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const SUPPORTED_NETWORKS = {
Mainnet: 1,
Polygon: 137,
ArbitrumOne: 42161,
Optimism: 10,
Base: 8453,
FantomOpera: 250,
Sepolia: 11155111,
// TODO: Disperse contract is not deployed on these networks
// zkSync: 324,
// Avalanche: 43114,
// PGN: 424,
} as const

export type NetworkName = keyof typeof SUPPORTED_NETWORKS;
export type NetworkId = (typeof SUPPORTED_NETWORKS)[NetworkName]

export const isValidNetworkName = (network: string): network is NetworkName => {
const values = Object.keys(SUPPORTED_NETWORKS) as unknown as string[];
return values.includes(network);
};
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async def create_strategy(
lambda: logs.insert(
supabase,
run_id,
"Assessing impact of each project realted to the users interest",
"Assessing impact of each project related to the users interest",
),
)

Expand Down
Loading

0 comments on commit 8ddabfa

Please sign in to comment.