Skip to content

Commit

Permalink
Merge pull request #23 from jmrossy/aaronmgdr-66-fill-tx
Browse files Browse the repository at this point in the history
Add support for cip 66 transactions
  • Loading branch information
aaronmgdr authored Jun 12, 2024
2 parents 748ccc9 + 0b981a3 commit 272e4cc
Show file tree
Hide file tree
Showing 11 changed files with 547 additions and 45 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"build": "yarn build:main && yarn build:module",
"build:main": "tsc -p tsconfig.json",
"build:module": "tsc -p tsconfig.module.json",
"test": "jest ./tests"
"test": "jest"
},
"engines": {
"node": ">=18.14.2"
Expand Down
10 changes: 10 additions & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@ export const EIGHT = 8;

// NOTE: Logic stolen from https://github.com/celo-org/celo-monorepo/blob/e7ebc92cb0715dc56c9d7f613dca81e076541cf3/packages/sdk/connect/src/connection.ts#L382-L396
export const GAS_INFLATION_FACTOR = 130n;

/*
* If a contract is deployed to this address then Celo has transitioned to a Layer 2
* https://github.com/celo-org/celo-monorepo/blob/da9b4955c1fdc8631980dc4adf9b05e0524fc228/packages/protocol/contracts-0.8/common/IsL2Check.sol#L17
*/
export const L2_PROXY_ADMIN_ADDRESS =
"0x4200000000000000000000000000000000000018";

export const CELO_REGISTRY_ADDRESS =
"0x000000000000000000000000000000000000ce10";
33 changes: 23 additions & 10 deletions src/lib/CeloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export default class CeloProvider extends JsonRpcProvider {
// If there are no EIP-1559 properties, it might be non-EIP-1559
if (tx.maxFeePerGas == null && tx.maxPriorityFeePerGas == null) {
const feeData = await this.getFeeData();
if (feeData.maxFeePerGas == null && feeData.maxPriorityFeePerGas == null) {
if (
feeData.maxFeePerGas == null &&
feeData.maxPriorityFeePerGas == null
) {
// Network doesn't know about EIP-1559 (and hence type)
req = Object.assign({}, req, {
transaction: Object.assign({}, tx, { type: undefined }),
Expand Down Expand Up @@ -82,22 +85,32 @@ export default class CeloProvider extends JsonRpcProvider {
// @ts-ignore
transaction: _tx,
}),
"%response"
"%response",
);
}

async getFeeData(feeCurrency?: string): Promise<FeeData> {
if (!feeCurrency) {
// for eip1559 and cip66 transactions are denominated in CELO, cip64 fees must be looked up in the fee token
async getFeeData(
feeCurrency?: string,
denominateInCelo?: boolean,
): Promise<FeeData> {
if (!feeCurrency || denominateInCelo) {
return super.getFeeData();
}
// On Celo, `eth_gasPrice` returns the base fee for the given currency multiplied 2
// On Celo, `eth_gasPrice` returns the base fee for the given currency multiplied 2
// and doesn't include tips. Source: https://github.com/jmrossy/celo-ethers-wrapper/pull/20#discussion_r1579179736
const baseFeePerGasInFeeCurrency = getBigInt(await this.send("eth_gasPrice", [feeCurrency]));
const baseFeePerGasInFeeCurrency = getBigInt(
await this.send("eth_gasPrice", [feeCurrency]),
);
const maxPriorityFeePerGasInFeeCurrency = getBigInt(
await this.send("eth_maxPriorityFeePerGas", [feeCurrency])
await this.send("eth_maxPriorityFeePerGas", [feeCurrency]),
);
const maxFeePerGasInFeeCurrency =
baseFeePerGasInFeeCurrency + maxPriorityFeePerGasInFeeCurrency;
return new FeeData(
null,
maxFeePerGasInFeeCurrency,
maxPriorityFeePerGasInFeeCurrency,
);
const maxFeePerGasInFeeCurrency = baseFeePerGasInFeeCurrency + maxPriorityFeePerGasInFeeCurrency;
return new FeeData(null, maxFeePerGasInFeeCurrency, maxPriorityFeePerGasInFeeCurrency);
}

async broadcastTransaction(signedTx: string): Promise<TransactionResponse> {
Expand Down
121 changes: 102 additions & 19 deletions src/lib/CeloWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,18 @@ import {
Wordlist,
} from "ethers";
import CeloProvider from "./CeloProvider";
import { adjustForGasInflation, isEmpty } from "./transaction/utils";
import { CeloTransaction, CeloTransactionRequest, serializeCeloTransaction } from "./transactions";
import {
adjustForGasInflation,
convertFromCeloToToken,
isEmpty,
} from "./transaction/utils";
import {
CeloTransaction,
CeloTransactionRequest,
serializeCeloTransaction,
} from "./transactions";
import { L2_PROXY_ADMIN_ADDRESS } from "../consts";
import { getConversionRateFromCeloToToken } from "./CoreContract";

const forwardErrors = [
"INSUFFICIENT_FUNDS",
Expand All @@ -23,11 +33,21 @@ const forwardErrors = [
] as ErrorCode[];

export default class CeloWallet extends Wallet {
async isCel2() {
const code = await this.provider?.getCode(L2_PROXY_ADMIN_ADDRESS);
if (typeof code === "string") {
return code != "0x" && code.length > 2;
}
return false;
}

/**
* Override to skip checkTransaction step which rejects Celo tx properties
* https://github.com/ethers-io/ethers.js/blob/master/packages/abstract-signer/src.ts/index.ts
*/
async populateTransaction(transaction: CeloTransactionRequest): Promise<CeloTransaction> {
async populateTransaction(
transaction: CeloTransactionRequest,
): Promise<CeloTransaction> {
let tx: any = await resolveProperties(transaction);

if (isEmpty(tx.from)) {
Expand All @@ -52,34 +72,65 @@ export default class CeloWallet extends Wallet {
{
error: error,
tx: tx,
}
},
);
}
}

if (isEmpty(tx.maxPriorityFeePerGas) || isEmpty(tx.maxFeePerGas)) {
const { maxFeePerGas, maxPriorityFeePerGas } = (await (
this.provider as CeloProvider
)?.getFeeData(tx.feeCurrency as string | undefined))!;
tx.maxFeePerGas = maxFeePerGas;
tx.maxPriorityFeePerGas = maxPriorityFeePerGas;
}
await this.populateFees(tx);

if (isEmpty(tx.chainId)) {
tx.chainId = (await this.provider!.getNetwork()).chainId;
} else {
tx.chainId = Promise.all([tx.chainId, (await this.provider!.getNetwork()).chainId]).then(
([txChainId, providerChainId]) => {
if (providerChainId !== 0n && txChainId !== providerChainId) {
assertArgument(false, "chainId address mismatch", "transaction", transaction);
}
return txChainId;
tx.chainId = Promise.all([
tx.chainId,
(await this.provider!.getNetwork()).chainId,
]).then(([txChainId, providerChainId]) => {
if (providerChainId !== 0n && txChainId !== providerChainId) {
assertArgument(
false,
"chainId address mismatch",
"transaction",
transaction,
);
}
);
return txChainId;
});
}
return resolveProperties<CeloTransaction>(tx);
}

// sets feedata for the transaction.
//
async populateFees(tx: CeloTransactionRequest) {
const isCel2 = await this.isCel2();
const noFeeCurrency = !tx.feeCurrency;
const useCIP66ForEasyFeeTransactions = isCel2 && !noFeeCurrency;
// CIP 66 transactions are denominated in CELO not the fee token
const feesAreInCELO = noFeeCurrency || useCIP66ForEasyFeeTransactions;

if (isEmpty(tx.maxPriorityFeePerGas) || isEmpty(tx.maxFeePerGas)) {
const { maxFeePerGas, maxPriorityFeePerGas } = (await (
this.provider as CeloProvider
)?.getFeeData(tx.feeCurrency, feesAreInCELO))!;

tx.maxFeePerGas = maxFeePerGas;
tx.maxPriorityFeePerGas = maxPriorityFeePerGas;

if (useCIP66ForEasyFeeTransactions && isEmpty(tx.maxFeeInFeeCurrency)) {
const gasLimit = BigInt(tx.gasLimit!);
const maxFeeInFeeCurrency = await this.estimateMaxFeeInFeeToken({
feeCurrency: tx.feeCurrency!,
gasLimit,
maxFeePerGas: maxFeePerGas!,
});
tx.maxFeeInFeeCurrency = maxFeeInFeeCurrency;
}
}

return tx;
}

/**
* Override to serialize transaction using custom serialize method
* https://github.com/ethers-io/ethers.js/blob/master/packages/wallet/src.ts/index.ts
Expand All @@ -93,7 +144,7 @@ export default class CeloWallet extends Wallet {
false,
"transaction from address mismatch",
"transaction.from",
transaction.from
transaction.from,
);
}
delete tx.from;
Expand Down Expand Up @@ -123,8 +174,40 @@ export default class CeloWallet extends Wallet {
return this.provider!.estimateGas(transaction).then(adjustForGasInflation);
}

/**
* For cip 66 transactions (the prefered way to pay for gas with fee tokens on Cel2) it is necessary
* to provide the absolute limit one is willing to pay denominated in the token.
* In contrast with earlier tx types for fee currencies (celo legacy, cip42, cip 64).
*
* Calulating Estimation requires the gas, maxfeePerGas and the conversion rate from CELO to feeToken
* https://github.com/celo-org/celo-proposals/blob/master/CIPs/cip-0066.md
*/
async estimateMaxFeeInFeeToken({
gasLimit,
maxFeePerGas,
feeCurrency,
}: {
gasLimit: bigint;
maxFeePerGas: bigint;
feeCurrency: string;
}) {
const maxGasFeesInCELO = gasLimit * maxFeePerGas;
const [numerator, denominator] = await getConversionRateFromCeloToToken(
feeCurrency,
this,
);
const feeDenominatedInToken = convertFromCeloToToken({
amountInCelo: maxGasFeesInCELO,
ratioTOKEN: numerator,
ratioCELO: denominator,
});

return feeDenominatedInToken;
}

/**
* Override to support alternative gas currencies
* @dev (for cip66 txn you want gasPrice in CELO so dont pass in the feeToken)
* https://github.com/celo-tools/ethers.js/blob/master/packages/abstract-signer/src.ts/index.ts
*/
async getGasPrice(feeCurrencyAddress?: string): Promise<bigint> {
Expand Down
79 changes: 79 additions & 0 deletions src/lib/CoreContract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Contract } from "ethers";
import CeloWallet from "./CeloWallet";
import { CELO_REGISTRY_ADDRESS } from "../consts";

const MINIMAL_ORACLE_INTERFACE = [
{
constant: true,
inputs: [
{
internalType: "address",
name: "token",
type: "address",
},
],
name: "medianRate",
outputs: [
{
internalType: "uint256",
name: "",
type: "uint256",
},
{
internalType: "uint256",
name: "",
type: "uint256",
},
],
payable: false,
stateMutability: "view",
type: "function",
},
];

const MINIMAL_REGISTRY_ABI = [
{
constant: true,
inputs: [
{
internalType: "string",
name: "identifier",
type: "string",
},
],
name: "getAddressForString",
outputs: [
{
internalType: "address",
name: "",
type: "address",
},
],
payable: false,
stateMutability: "view",
type: "function",
},
];

export async function getConversionRateFromCeloToToken(
tokenAddress: string,
wallet: CeloWallet,
): Promise<[bigint, bigint]> {
const registry = new Contract(
CELO_REGISTRY_ADDRESS,
MINIMAL_REGISTRY_ABI,
wallet,
);

const oracleAddress = await registry.getAddressForString("SortedOracles");

const oracle = new Contract(oracleAddress, MINIMAL_ORACLE_INTERFACE, wallet);

const [numerator, denominator]: bigint[] =
await oracle.medianRate(tokenAddress);
// The function docs for the Contract are confusing but in ContractKit the Sorted orcles wrapper
// defines numerator as the amount of the token and denominiator as equvalent value in CELO
// https://github.com/celo-org/developer-tooling/blob/master/packages/sdk/contractkit/src/wrappers/SortedOracles.ts#L80
// https://github.com/celo-org/celo-monorepo/blob/master/packages/protocol/contracts/stability/SortedOracles.sol
return [numerator, denominator];
}
40 changes: 38 additions & 2 deletions src/lib/transaction/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { hexlify, BigNumberish, isBytesLike, toBeHex } from "ethers";
import { GAS_INFLATION_FACTOR } from "../../consts";
import {
CeloTransaction,
CeloTransactionCip64,
CeloTransactionCip66,
} from "../transactions";

export function isEmpty(value: string | BigNumberish | undefined | null) {
if (value === undefined || value === null || value === "0" || value === 0n) {
Expand All @@ -15,8 +20,18 @@ export function isPresent(value: string | BigNumberish | undefined | null) {
return !isEmpty(value);
}

export function isCIP64(tx: any) {
return isPresent(tx.feeCurrency);
export function isCIP64(tx: CeloTransaction): tx is CeloTransactionCip64 {
return (
isPresent((tx as CeloTransactionCip64).feeCurrency) &&
isEmpty((tx as CeloTransactionCip66).maxFeeInFeeCurrency)
);
}

export function isCIP66(tx: CeloTransaction): tx is CeloTransactionCip66 {
return (
isPresent((tx as CeloTransactionCip66).feeCurrency) &&
isPresent((tx as CeloTransactionCip66).maxFeeInFeeCurrency)
);
}

export function concatHex(values: string[]): `0x${string}` {
Expand All @@ -41,3 +56,24 @@ export function adjustForGasInflation(gas: bigint): bigint {
// NOTE: prevent floating point math
return (gas * GAS_INFLATION_FACTOR) / 100n;
}

interface ConversionParams {
amountInCelo: bigint;
ratioCELO: bigint;
ratioTOKEN: bigint;
}
/**
*
* @param param0 @ConversionParams
* ratioTOKEN will come from the first position (or numerator) of tuple returned from SortedOracles.medianRate
* ratioCELO will come from the second position (or denominator) of tuple returned from SortedOracles.medianRate
* @returns amount in token equal in value to the amountInCelo given.
*/
export function convertFromCeloToToken({
amountInCelo,
ratioCELO,
ratioTOKEN,
}: ConversionParams) {
return (amountInCelo * ratioCELO) / ratioTOKEN;
}
Loading

0 comments on commit 272e4cc

Please sign in to comment.