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

Add support for cip 66 transactions #23

Merged
merged 17 commits into from
Jun 12, 2024
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 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"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed and means that calling yarn test does not work

},
"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
Loading