UNPKG

@stable-io/cctp-sdk-cctpr-evm

Version:

EVM support for the CCTPR corridor of the CCTP SDK

411 lines 22.7 kB
// Copyright (c) 2025 Stable Technologies Inc // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { serialize, deserialize } from "binary-layout"; import { range } from "@stable-io/map-utils"; import { keccak256, encoding, assertDistinct } from "@stable-io/utils"; import { domains, domainOf, gasTokenOf, usdc, genericGasToken, evmGasToken, usdcContracts, v1, v2, chainIdOf, domainIdOf, wormholeChainIdOf, } from "@stable-io/cctp-sdk-definitions"; import { wordSize, selectorOf, selectorLength, EvmAddress, permit2Address, dateToUnixTimestamp, paddedSlotItem, evmAddressItem, } from "@stable-io/cctp-sdk-evm"; import { avaxRouterContractAddress, contractAddressOf, } from "@stable-io/cctp-sdk-cctpr-definitions"; import { quoteRelayArrayLayout, quoteRelayResultLayout, offChainQuoteLayout, transferLayout, governanceCommandArrayLayout, feeAdjustmentsPerSlot, chainIdsSlotItem, chainIdsPerSlot, feeAdjustmentsSlotItem, feeAdjustmentTypes, constructorLayout, corridors, } from "./layouts/index.js"; import { extraDomains } from "./layouts/common.js"; import { Rational } from "@stable-io/amount"; //external consumers shouldn't really need these but exporting them just in case export * as layouts from "./layouts/index.js"; export { extraDomains } from "./layouts/common.js"; //sign this with the offChainQuoter to produce a valid off-chain quote for a transfer export const offChainQuoteData = (network) => (params) => serialize(offChainQuoteLayout(network), params); export const execSelector = selectorOf("exec768()"); const get1959Selector = selectorOf("get1959()"); const parseExecCalldata = (calldata, layout) => { const selector = calldata.subarray(0, selectorLength); if (!encoding.bytes.equals(selector, execSelector)) throw new Error(`Invalid selector: expected ${execSelector}, got ${selector}`); return deserialize(layout, calldata.subarray(selectorLength)); }; export const parseTransferTxCalldata = (network) => (calldata) => parseExecCalldata(calldata, transferLayout(network)); export const parseGovernanceTxCalldata = (network) => (calldata) => parseExecCalldata(calldata, governanceCommandArrayLayout(network)); export function quoteIsInUsdc(quote) { return ((quote.type === "offChain" && quote.relayFee.kind.name === "Usdc") || (quote.type === "onChain" && quote.maxRelayFee.kind.name === "Usdc")); } const definedOrZero = (maybeAddress) => maybeAddress ? new EvmAddress(maybeAddress) : EvmAddress.zeroAddress; export class CctpRBase { client; address; constructor(client) { this.client = client; this.address = new EvmAddress(contractAddressOf(this.client.network, this.client.domain)); } execTx(value, commandData) { return { to: this.address, value, data: encoding.bytes.concat(execSelector, commandData), }; } } export class CctpR extends CctpRBase { //On-chain quotes should always allow for a safety margin of at least a few percent to make sure a // submitted transfer tx does not fail if fees in the oracle get updated while the tx is pending. async quoteOnChainRelay(queries) { if (queries.length === 0) return []; const encodedBytesResults = await this.client.ethCall({ to: this.address, data: encoding.bytes.concat(get1959Selector, serialize(quoteRelayArrayLayout(this.client.network), queries)), }); if (encodedBytesResults.length === 0) throw new Error("Empty result returned by the client. Please check your config params."); if (encodedBytesResults.length < 2 * wordSize || encodedBytesResults.length % wordSize !== 0) throw new Error("Unexpected result encoding"); const encodedResults = encodedBytesResults.subarray(2 * wordSize); if (encodedResults.length / wordSize !== queries.length) throw new Error("Result to query length mismatch"); return deserialize(quoteRelayResultLayout, encodedResults).map((v, i) => (queries[i].quoteRelay === "inUsdc" ? usdc : gasTokenOf(this.client.domain))(v, "atomic")); } checkCostAndCalcRequiredAllowance(inOrOut, quote, corridor, gaslessFee) { gaslessFee = gaslessFee ?? usdc(0); const totalFeesUsdc = gaslessFee.add(quoteIsInUsdc(quote) ? (quote.type === "offChain" ? quote.relayFee : quote.maxRelayFee) : usdc(0)); return inOrOut.type === "in" ? (() => { if (totalFeesUsdc.ge(inOrOut.amount)) throw new Error(`Costs of ${totalFeesUsdc} exceed input amount of ${inOrOut.amount}`); return inOrOut.amount; })() : totalFeesUsdc.add(this.calcBurnAmount(inOrOut, corridor, quote, gaslessFee)); } transferWithRelay(destination, inOrOut, //meaningless distinction, if relay fee is paid in gas and corridor is v1 mintRecipient, gasDropoff, corridor, quote, permit) { this.checkCorridorDestinationCoherence(destination, corridor.type); const value = evmGasToken(quoteIsInUsdc(quote) ? 0n : (quote.type === "offChain" ? quote.relayFee : quote.maxRelayFee).toUnit("human")); const quoteVariant = (quote.type === "offChain" ? { type: "offChain", expirationTime: quote.expirationTime, quoterSignature: quote.quoterSignature, feePaymentVariant: quoteIsInUsdc(quote) ? { payIn: "usdc", relayFeeUsdc: quote.relayFee } : { payIn: "gasToken", relayFeeGasToken: value }, } : quoteIsInUsdc(quote) ? { type: "onChainUsdc", takeRelayFeeFromInput: inOrOut.type === "in", maxRelayFeeUsdc: quote.maxRelayFee, } : { type: "onChainGas" }); const burnAmount = this.calcBurnAmount(inOrOut, corridor, quote, usdc(0)); const inputAmountUsdc = inOrOut.type === "in" ? inOrOut.amount : burnAmount.add(quoteIsInUsdc(quote) && quote.type === "offChain" ? quote.relayFee : usdc(0)); const transfer = { ...(permit ? { approvalType: "Permit", permit } : { approvalType: "Preapproval" }), inputAmountUsdc, destinationDomain: destination, //TODO brrr mintRecipient, gasDropoff: genericGasToken(gasDropoff.toUnit("human")), corridorVariant: CctpR.toCorridorVariant(corridor, burnAmount), quoteVariant, }; return this.execTx(value, serialize(transferLayout(this.client.network), transfer)); } composeGaslessTransferMessage(destination, cctprAddress, //FIXME eliminate and use this.address instead inOrOut, mintRecipient, gasDropoff, corridor, quote, nonce, //TODO better type deadline, gaslessFee) { this.checkCorridorDestinationCoherence(destination, corridor.type); if (nonce.length !== wordSize) throw new Error(`Nonce must be ${wordSize} bytes`); const erasedCorridor = corridor; const [network, domain] = [this.client.network, this.client.domain]; const [amount, baseAmount, burnAmount] = this.calcGaslessAmounts(inOrOut, corridor, quote, gaslessFee); return { types: { EIP712Domain: [ { name: "name", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ], PermitWitnessTransferFrom: [ { name: "permitted", type: "TokenPermissions" }, { name: "spender", type: "address" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, { name: "parameters", type: "TransferWithRelayWitness" }, ], TokenPermissions: [ { name: "token", type: "address" }, { name: "amount", type: "uint256" }, ], TransferWithRelayWitness: [ { name: "baseAmount", type: "uint64" }, { name: "destinationDomain", type: "uint8" }, { name: "mintRecipient", type: "bytes32" }, { name: "microGasDropoff", type: "uint32" }, { name: "corridor", type: "string" }, { name: "maxFastFee", type: "uint64" }, { name: "gaslessFee", type: "uint64" }, { name: "maxRelayFee", type: "uint64" }, { name: "quoteSource", type: "string" }, ], }, primaryType: "PermitWitnessTransferFrom", domain: { name: "Permit2", chainId: chainIdOf(network, domain), verifyingContract: permit2Address, }, message: { permitted: { token: usdcContracts.contractAddressOf[network][domain], amount: amount.toUnit("atomic"), }, spender: cctprAddress.unwrap(), //FIXME eliminate and use this.address instead nonce: encoding.bignum.decode(nonce), deadline: dateToUnixTimestamp(deadline), parameters: { baseAmount: baseAmount.toUnit("atomic"), destinationDomain: domainIdOf(destination), mintRecipient: mintRecipient.toString(), microGasDropoff: gasDropoff.toUnit("human").mul(1000000).floor(), corridor: { v1: "CCTPv1", v2Direct: "CCTPv2", avaxHop: "CCTPv2->Avalanche->CCTPv1", }[corridor.type], maxFastFee: erasedCorridor.type === "v1" ? 0n : CctpR.calcFastFee(burnAmount, erasedCorridor.fastFeeRate).toUnit("atomic"), gaslessFee: gaslessFee.toUnit("atomic"), maxRelayFee: (quote.type === "offChain" ? quote.relayFee : quote.maxRelayFee).toUnit("atomic"), quoteSource: quote.type === "offChain" ? "OffChain" : "OnChain", }, }, }; } transferGasless(destination, inOrOut, mintRecipient, gasDropoff, corridor, quote, nonce, //TODO better type deadline, gaslessFee, user, permit2Signature) { const [amount, baseAmount, burnAmount] = this.calcGaslessAmounts(inOrOut, corridor, quote, gaslessFee); const transfer = { approvalType: "Gasless", permit2Data: { owner: user, amount, nonce, deadline, signature: permit2Signature, }, gaslessFeeUsdc: gaslessFee, inputAmountUsdc: baseAmount, destinationDomain: destination, //TODO brrr mintRecipient, gasDropoff: genericGasToken(gasDropoff.toUnit("human")), corridorVariant: CctpR.toCorridorVariant(corridor, burnAmount), quoteVariant: (quote.type === "offChain" ? { type: "offChain", expirationTime: quote.expirationTime, feePaymentVariant: { payIn: "usdc", relayFeeUsdc: quote.relayFee }, quoterSignature: quote.quoterSignature, } : { type: "onChainUsdc", maxRelayFeeUsdc: quote.maxRelayFee, takeRelayFeeFromInput: inOrOut.type === "in", }), }; return this.execTx(evmGasToken(0), serialize(transferLayout(this.client.network), transfer)); } checkCorridorDestinationCoherence(destination, corridorType) { assertDistinct(this.client.domain, destination); const isSupportedV2Domain = v2.isSupportedDomain(this.client.network); if (corridorType === "avaxHop") { if ([this.client.domain, destination].includes("Avalanche")) throw new Error("Can't use avaxHop corridor with Avalanche being source or destination"); if (!isSupportedV2Domain(this.client.domain)) throw new Error("Can't use avaxHop corridor with non-v2 source domain"); if (isSupportedV2Domain(destination)) throw new Error("Don't use avaxHop corridor when destination is also a v2 domain"); } if (corridorType === "v2Direct" && (!isSupportedV2Domain(this.client.domain) || !isSupportedV2Domain(destination))) throw new Error("Can't use v2 corridor for non-v2 domains"); } static toCorridorVariant(corridor, burnAmount) { return corridor.type === "v1" ? corridor : { type: corridor.type, maxFastFeeUsdc: CctpR.calcFastFee(burnAmount, corridor.fastFeeRate), }; } calcGaslessAmounts(inOrOut, corridor, quote, gaslessFee) { const burnAmount = this.calcBurnAmount(inOrOut, corridor, quote, gaslessFee); const [amount, baseAmount] = inOrOut.type === "in" ? [inOrOut.amount, inOrOut.amount .sub(gaslessFee) .sub(quote.type === "offChain" ? quote.relayFee : usdc(0)), ] : [burnAmount .add(gaslessFee) .add(quote.type === "offChain" ? quote.relayFee : quote.maxRelayFee), burnAmount, ]; if (baseAmount.le(usdc(0))) throw new Error("Base Amount Less or Equal to 0"); return [amount, baseAmount, burnAmount]; } calcBurnAmount(inOrOut, corridor, quote, gaslessFee) { //example for inOrOut.type === "out": //say desired output on the target chain is 99 µUSDC and the fastFeeRate is 2 % //-> need to burn 101.020408163 µUSDC -> ceil to 102 µUSDC //this in turn gives a fast fee of: //-> 102 µUSDC * 0.02 = 2.04 µUSDC -> ceil to 3 µUSDC => (102 - 3 = 99) let burnAmount = inOrOut.amount; if (inOrOut.type === "in") { burnAmount = burnAmount.sub(gaslessFee); //we don't sub the maxRelayFee, because the actual fee might be lower and so we have to assume // the most extreme case, where the actual fee is 0 and hence the burnAmount is maximized if (quoteIsInUsdc(quote) && quote.type === "offChain") burnAmount = burnAmount.sub(quote.relayFee); } else if (corridor.type !== "v1") burnAmount = CctpR.ceilToMicroUsdc(burnAmount.div(Rational.from(1).sub(corridor.fastFeeRate.toUnit("scalar")))); if (burnAmount.le(usdc(0))) throw new Error("Transfer Amount Less or Equal to 0 After Fees"); return burnAmount; } static calcFastFee(burnAmount, fastFeeRate) { return CctpR.ceilToMicroUsdc(burnAmount.mul(fastFeeRate.toUnit("scalar"))); } static ceilToMicroUsdc(amount) { return usdc(amount.toUnit("µUSDC").ceil(), "µUSDC"); } } export class CctpRGovernance extends CctpRBase { static adjustmentSlots = Math.ceil(domains.length / feeAdjustmentsPerSlot); static feeAdjustmentsAtIndex(feeAdjustments, mappingIndex) { const atCost = CctpRGovernance.relayAtCostFeeAdjustment; return range(feeAdjustmentsPerSlot).map((subIndex) => { const maybeDomain = domainOf.get(mappingIndex * feeAdjustmentsPerSlot + subIndex); return maybeDomain ? feeAdjustments[maybeDomain] ?? atCost : atCost; }); } static feeAdjustmentsArray(feeAdjustments) { return range(CctpRGovernance.adjustmentSlots).map(mappingIndex => feeAdjustmentTypes.map(feeType => CctpRGovernance.feeAdjustmentsAtIndex(feeAdjustments[feeType], mappingIndex))); } static extraChainsArray(network) { return range(Math.ceil(extraDomains.length / chainIdsPerSlot)) .map(slotIndex => range(chainIdsPerSlot).map((subIndex) => { const maybeDomain = domainOf.get((slotIndex + 1) * chainIdsPerSlot + subIndex); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return maybeDomain ? wormholeChainIdOf(network, maybeDomain) ?? 0 : 0; })); } static constructorCalldata(network, domain, owner, feeAdjuster, feeRecipient, offChainQuoter, priceOracle, feeAdjustments) { const arrayFeeAdjustments = CctpRGovernance.feeAdjustmentsArray(feeAdjustments); const arrayExtraChains = CctpRGovernance.extraChainsArray(network); const tokenMessengerV1 = v1.contractAddressOf( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion network, domain, "tokenMessenger"); const tokenMessengerV2 = v2.contractAddressOf( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion network, domain, "tokenMessenger"); const serialized = serialize(constructorLayout(network), { owner, feeAdjuster, feeRecipient, offChainQuoter, usdc: new EvmAddress(usdcContracts.contractAddressOf[network][domain]), tokenMessengerV1: definedOrZero(tokenMessengerV1), tokenMessengerV2: definedOrZero(tokenMessengerV2), avaxRouter: definedOrZero(avaxRouterContractAddress[network]), priceOracle, permit2: new EvmAddress(permit2Address), chainData: { extraChains: arrayExtraChains, feeAdjustments: arrayFeeAdjustments, }, }); // This padding should be added by serialize but it is not implemented in the layout yet const chainDataPostPadding = new Uint8Array(30); return encoding.bytes.concat(serialized, chainDataPostPadding); } static avaxRouterConstructorCalldata(network) { const tokenMessengerV1 = v1.contractAddressOf(network, "Avalanche", "tokenMessenger"); const messageTransmitterV2 = v2.contractAddressOf(network, "Avalanche", "messageTransmitter"); const usdc = usdcContracts.contractAddressOf[network]["Avalanche"]; return serialize([ { name: "messageTransmitterV2", ...paddedSlotItem(evmAddressItem) }, { name: "tokenMessengerV1", ...paddedSlotItem(evmAddressItem) }, { name: "usdc", ...paddedSlotItem(evmAddressItem) }, ], { messageTransmitterV2: new EvmAddress(messageTransmitterV2), tokenMessengerV1: new EvmAddress(tokenMessengerV1), usdc: new EvmAddress(usdc), }); } static gasDropoffConstructorCalldata(network, domain) { const messageTransmitterV1 = definedOrZero(v1.contractAddressOf(network, domain, "messageTransmitter")); const messageTransmitterV2 = definedOrZero(v2.contractAddressOf(network, domain, "messageTransmitter")); return serialize([ { name: "messageTransmitterV1", ...paddedSlotItem(evmAddressItem) }, { name: "messageTransmitterV2", ...paddedSlotItem(evmAddressItem) }, ], { messageTransmitterV1: new EvmAddress(messageTransmitterV1), messageTransmitterV2: new EvmAddress(messageTransmitterV2), }); } static mappings = ["extraChainIds", ...corridors, "gasDropoff"]; static roles = ["feeRecipient", "offChainQuoter", "owner", "pendingOwner", "feeAdjuster"]; //sensible default for fee adjustments on deployment static relayAtCostFeeAdjustment = { absoluteUsdc: usdc(0), relativePercent: 100 }; execGovernance(commands) { return this.execTx(evmGasToken(0), serialize(governanceCommandArrayLayout(this.client.network), commands)); } async getRole(role) { //initial slots are the mappings const rolesSlotOffset = CctpRGovernance.mappings.length; const slot = CctpRGovernance.roles.indexOf(role) + rolesSlotOffset; return deserialize(paddedSlotItem(evmAddressItem), await this.getStorageAt(slot)); } async getRegisteredChainId() { //This entire implementation is overkill, seeing how we'll almost certainly never have more // than 12 extra chains but there's not really a reason to start cutting corners here. const extraChainIdsSlot = CctpRGovernance.mappings.indexOf("extraChainIds"); //Since the implementation of CctpR has the assumption baked into the contract that new domain // ids will continue to be handed out incrementally (i.e. as last domainId + 1), we make the // same assumption here via: const maxDomainId = domains.length - 1; const maxMappingIndex = Math.floor(maxDomainId / chainIdsPerSlot); const chainIdChunks = await Promise.all(range(maxMappingIndex).map(i => this.getStorageAt(CctpRGovernance.slotOfKeyInMapping(extraChainIdsSlot, i + 1)).then(raw => deserialize(chainIdsSlotItem, raw)))); return Object.fromEntries(chainIdChunks .flat() .slice(0, domains.length - chainIdsPerSlot) .map((chainId, idx) => [domainOf((idx + chainIdsPerSlot)), chainId])); } async getFeeAdjustments(type) { const feeTypeMappingSlot = CctpRGovernance.mappings.indexOf(type); const maxDomainId = domains.length - 1; const maxMappingIndex = Math.floor(maxDomainId / feeAdjustmentsPerSlot); const feeAdjustmentChunks = await Promise.all(range(maxMappingIndex + 1).map(i => this.getStorageAt(CctpRGovernance.slotOfKeyInMapping(feeTypeMappingSlot, i)).then(raw => deserialize(feeAdjustmentsSlotItem, raw)))); return Object.fromEntries(feeAdjustmentChunks .flat() .slice(0, domains.length) .map((feeAdjustment, idx) => [domainOf(idx), feeAdjustment])); } getStorageAt(slot) { return this.client.getStorageAt(this.address, BigInt(slot)); } static slotOfKeyInMapping(slotOfMapping, key) { return deserialize({ binary: "uint", size: wordSize }, keccak256(serialize([ { name: "key", binary: "uint", size: wordSize }, { name: "slot", binary: "uint", size: wordSize }, ], { key: BigInt(key), slot: BigInt(slotOfMapping) }))); } } //# sourceMappingURL=index.js.map