@stable-io/cctp-sdk-cctpr-evm
Version:
EVM support for the CCTPR corridor of the CCTP SDK
411 lines • 22.7 kB
JavaScript
// 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