@atomiqlabs/chain-evm
Version:
EVM specific base implementation
534 lines (463 loc) • 24.6 kB
text/typescript
import {
BigIntBufferUtils,
BitcoinRpc,
BtcTx,
RelaySynchronizer,
SpvVaultContract,
SpvVaultTokenData,
SpvWithdrawalState,
SpvWithdrawalStateType,
SpvWithdrawalTransactionData,
TransactionConfirmationOptions
} from "@atomiqlabs/base";
import {Buffer} from "buffer";
import { EVMTx } from "../chain/modules/EVMTransactions";
import { EVMContractBase } from "../contract/EVMContractBase";
import { EVMSigner } from "../wallet/EVMSigner";
import {SpvVaultContractAbi} from "./SpvVaultContractAbi";
import {SpvVaultManager, SpvVaultParametersStructOutput} from "./SpvVaultContractTypechain";
import {EVMBtcRelay} from "../btcrelay/EVMBtcRelay";
import {getLogger} from "../../utils/Utils";
import {EVMChainInterface} from "../chain/EVMChainInterface";
import {AbiCoder, getAddress, hexlify, keccak256, TransactionRequest, ZeroAddress, ZeroHash} from "ethers";
import {EVMAddresses} from "../chain/modules/EVMAddresses";
import {EVMSpvVaultData, getVaultParamsCommitment} from "./EVMSpvVaultData";
import {EVMSpvWithdrawalData} from "./EVMSpvWithdrawalData";
import {EVMFees} from "../chain/modules/EVMFees";
import {EVMBtcStoredHeader} from "../btcrelay/headers/EVMBtcStoredHeader";
import {TypedEventLog} from "../typechain/common";
function decodeUtxo(utxo: string): {txHash: string, vout: bigint} {
const [txId, vout] = utxo.split(":");
return {
txHash: "0x"+Buffer.from(txId, "hex").reverse().toString("hex"),
vout: BigInt(vout)
}
}
export function packOwnerAndVaultId(owner: string, vaultId: bigint): string {
if(owner.length!==42) throw new Error("Invalid owner address");
return owner.toLowerCase() + BigIntBufferUtils.toBuffer(vaultId, "be", 12).toString("hex");
}
export function unpackOwnerAndVaultId(data: string): [string, bigint] {
return [getAddress(data.substring(0, 42)), BigInt("0x"+data.substring(42, 66))];
}
export class EVMSpvVaultContract<ChainId extends string>
extends EVMContractBase<SpvVaultManager>
implements SpvVaultContract<
EVMTx,
EVMSigner,
ChainId,
EVMSpvVaultData,
EVMSpvWithdrawalData
>
{
private static readonly GasCosts = {
DEPOSIT: 150_000,
OPEN: 100_000,
FRONT: 250_000,
CLAIM: 250_000
};
readonly chainId: ChainId;
readonly btcRelay: EVMBtcRelay<any>;
readonly bitcoinRpc: BitcoinRpc<any>;
readonly claimTimeout: number = 180;
readonly logger = getLogger("EVMSpvVaultContract: ");
constructor(
chainInterface: EVMChainInterface<ChainId>,
btcRelay: EVMBtcRelay<any>,
bitcoinRpc: BitcoinRpc<any>,
contractAddress: string,
contractDeploymentHeight?: number
) {
super(chainInterface, contractAddress, SpvVaultContractAbi, contractDeploymentHeight);
this.btcRelay = btcRelay;
this.bitcoinRpc = bitcoinRpc;
}
//Transactions
protected async Open(signer: string, vault: EVMSpvVaultData, feeRate: string): Promise<TransactionRequest> {
const {txHash, vout} = decodeUtxo(vault.getUtxo());
const tokens = vault.getTokenData();
if(tokens.length!==2) throw new Error("Must specify exactly 2 tokens for vault!");
const tx = await this.contract.open.populateTransaction(vault.vaultId, vault.getVaultParamsStruct(), txHash, vout);
tx.from = signer;
EVMFees.applyFeeRate(tx, EVMSpvVaultContract.GasCosts.OPEN, feeRate);
return tx;
}
protected async Deposit(signer: string, vault: EVMSpvVaultData, rawAmounts: bigint[], feeRate: string): Promise<TransactionRequest> {
let value = 0n;
if(vault.token0.token.toLowerCase()===this.Chain.getNativeCurrencyAddress().toLowerCase())
value += rawAmounts[0] * vault.token0.multiplier;
if(vault.token1.token.toLowerCase()===this.Chain.getNativeCurrencyAddress().toLowerCase())
value += (rawAmounts[1] ?? 0n) * vault.token1.multiplier;
const tx = await this.contract.deposit.populateTransaction(
vault.owner, vault.vaultId, vault.getVaultParamsStruct(),
rawAmounts[0], rawAmounts[1] ?? 0n, { value }
);
tx.from = signer;
EVMFees.applyFeeRate(tx, EVMSpvVaultContract.GasCosts.DEPOSIT, feeRate);
return tx;
}
protected async Front(
signer: string, vault: EVMSpvVaultData, data: EVMSpvWithdrawalData, withdrawalSequence: number, feeRate: string
): Promise<TransactionRequest> {
let value = 0n;
const frontingAmount = data.getFrontingAmount();
if(vault.token0.token.toLowerCase()===this.Chain.getNativeCurrencyAddress().toLowerCase())
value += frontingAmount[0] * vault.token0.multiplier;
if(vault.token1.token.toLowerCase()===this.Chain.getNativeCurrencyAddress().toLowerCase())
value += (frontingAmount[1] ?? 0n) * vault.token1.multiplier;
const tx = await this.contract.front.populateTransaction(
vault.owner, vault.vaultId, vault.getVaultParamsStruct(),
withdrawalSequence, data.getTxHash(), data.serializeToStruct(),
{ value }
);
tx.from = signer;
EVMFees.applyFeeRate(tx, EVMSpvVaultContract.GasCosts.FRONT, feeRate);
return tx;
}
protected async Claim(
signer: string, vault: EVMSpvVaultData, data: EVMSpvWithdrawalData,
blockheader: EVMBtcStoredHeader, merkle: Buffer[], position: number, feeRate: string
): Promise<TransactionRequest> {
const tx = await this.contract.claim.populateTransaction(
vault.owner, vault.vaultId, vault.getVaultParamsStruct(), "0x"+data.btcTx.hex,
blockheader.serializeToStruct(), merkle, position
)
tx.from = signer;
EVMFees.applyFeeRate(tx, EVMSpvVaultContract.GasCosts.CLAIM, feeRate);
return tx;
}
async checkWithdrawalTx(tx: SpvWithdrawalTransactionData): Promise<void> {
const result = await this.contract.parseBitcoinTx(Buffer.from(tx.btcTx.hex, "hex"));
if(result==null) throw new Error("Failed to parse transaction!");
}
createVaultData(
owner: string, vaultId: bigint, utxo: string, confirmations: number, tokenData: SpvVaultTokenData[]
): Promise<EVMSpvVaultData> {
if(tokenData.length!==2) throw new Error("Must specify 2 tokens in tokenData!");
const vaultParams = {
btcRelayContract: this.btcRelay.contractAddress,
token0: tokenData[0].token,
token1: tokenData[1].token,
token0Multiplier: tokenData[0].multiplier,
token1Multiplier: tokenData[1].multiplier,
confirmations: BigInt(confirmations)
};
const spvVaultParametersCommitment = keccak256(AbiCoder.defaultAbiCoder().encode(
["address", "address", "address", "uint192", "uint192", "uint256"],
[vaultParams.btcRelayContract, vaultParams.token0, vaultParams.token1, vaultParams.token0Multiplier, vaultParams.token1Multiplier, vaultParams.confirmations]
));
return Promise.resolve(new EVMSpvVaultData(owner, vaultId, {
spvVaultParametersCommitment,
utxoTxHash: ZeroHash,
utxoVout: 0n,
openBlockheight: 0n,
withdrawCount: 0n,
depositCount: 0n,
token0Amount: 0n,
token1Amount: 0n
}, vaultParams, utxo));
}
//Getters
async getFronterAddress(owner: string, vaultId: bigint, withdrawal: EVMSpvWithdrawalData): Promise<string> {
const frontingAddress = await this.contract.getFronterById(owner, vaultId, "0x"+withdrawal.getFrontingId());
if(frontingAddress===ZeroAddress) return null;
return frontingAddress;
}
async getVaultData(owner: string, vaultId: bigint): Promise<EVMSpvVaultData> {
const vaultState = await this.contract.getVault(owner, vaultId);
const blockheight = Number(vaultState.openBlockheight);
const events = await this.Events.getContractBlockEvents(
["Opened"],
[
"0x"+owner.substring(2).padStart(64, "0"),
hexlify(BigIntBufferUtils.toBuffer(vaultId, "be", 32))
],
blockheight
);
const foundEvent = events.find(
event => getVaultParamsCommitment(event.args.params)===vaultState.spvVaultParametersCommitment
);
if(foundEvent==null) throw new Error("Valid open event not found!");
const vaultParams = foundEvent.args.params;
if(vaultParams.btcRelayContract.toLowerCase()!==this.btcRelay.contractAddress.toLowerCase()) return null;
return new EVMSpvVaultData(owner, vaultId, vaultState, vaultParams);
}
async getAllVaults(owner?: string): Promise<EVMSpvVaultData[]> {
const openedVaults = new Map<string, SpvVaultParametersStructOutput>();
await this.Events.findInContractEventsForward(
["Opened", "Closed"],
owner==null ? null : [
"0x"+owner.substring(2).padStart(64, "0")
],
(event) => {
const vaultIdentifier = event.args.owner+":"+event.args.vaultId.toString(10);
if(event.eventName==="Opened") {
const _event = event as TypedEventLog<SpvVaultManager["filters"]["Opened"]>;
openedVaults.set(vaultIdentifier, _event.args.params);
} else {
openedVaults.delete(vaultIdentifier);
}
return null;
}
);
const vaults: EVMSpvVaultData[] = [];
for(let [identifier, vaultParams] of openedVaults.entries()) {
const [owner, vaultIdStr] = identifier.split(":");
const vaultState = await this.contract.getVault(owner, BigInt(vaultIdStr));
if(vaultState.spvVaultParametersCommitment === getVaultParamsCommitment(vaultParams)) {
vaults.push(new EVMSpvVaultData(owner, BigInt(vaultIdStr), vaultState, vaultParams))
}
}
return vaults;
}
async getWithdrawalState(btcTxId: string): Promise<SpvWithdrawalState> {
const txHash = Buffer.from(btcTxId, "hex").reverse();
let result: SpvWithdrawalState = await this.Events.findInContractEvents(
["Fronted", "Claimed", "Closed"],
[
null,
null,
hexlify(txHash)
],
async (event) => {
switch(event.eventName) {
case "Fronted":
const frontedEvent = event as TypedEventLog<SpvVaultManager["filters"]["Fronted"]>;
const [ownerFront, vaultIdFront] = unpackOwnerAndVaultId(frontedEvent.args.ownerAndVaultId);
return {
type: SpvWithdrawalStateType.FRONTED,
txId: event.transactionHash,
owner: ownerFront,
vaultId: vaultIdFront,
recipient: frontedEvent.args.recipient,
fronter: frontedEvent.args.caller
};
case "Claimed":
const claimedEvent = event as TypedEventLog<SpvVaultManager["filters"]["Claimed"]>;
const [ownerClaim, vaultIdClaim] = unpackOwnerAndVaultId(claimedEvent.args.ownerAndVaultId);
return {
type: SpvWithdrawalStateType.CLAIMED,
txId: event.transactionHash,
owner: ownerClaim,
vaultId: vaultIdClaim,
recipient: claimedEvent.args.recipient,
claimer: claimedEvent.args.caller,
fronter: claimedEvent.args.frontingAddress
};
case "Closed":
const closedEvent = event as TypedEventLog<SpvVaultManager["filters"]["Closed"]>;
return {
type: SpvWithdrawalStateType.CLOSED,
txId: event.transactionHash,
owner: closedEvent.args.owner,
vaultId: closedEvent.args.vaultId,
error: closedEvent.args.error
};
}
}
);
result ??= {
type: SpvWithdrawalStateType.NOT_FOUND
};
return result;
}
getWithdrawalData(btcTx: BtcTx): Promise<EVMSpvWithdrawalData> {
return Promise.resolve(new EVMSpvWithdrawalData(btcTx));
}
//OP_RETURN data encoding/decoding
fromOpReturnData(data: Buffer): { recipient: string; rawAmounts: bigint[]; executionHash: string } {
return EVMSpvVaultContract.fromOpReturnData(data);
}
static fromOpReturnData(data: Buffer): { recipient: string; rawAmounts: bigint[]; executionHash: string } {
let rawAmount0: bigint = 0n;
let rawAmount1: bigint = 0n;
let executionHash: string = null;
if(data.length===28) {
rawAmount0 = data.readBigInt64BE(20).valueOf();
} else if(data.length===36) {
rawAmount0 = data.readBigInt64BE(20).valueOf();
rawAmount1 = data.readBigInt64BE(28).valueOf();
} else if(data.length===60) {
rawAmount0 = data.readBigInt64BE(20).valueOf();
executionHash = data.slice(28, 60).toString("hex");
} else if(data.length===68) {
rawAmount0 = data.readBigInt64BE(20).valueOf();
rawAmount1 = data.readBigInt64BE(28).valueOf();
executionHash = data.slice(36, 68).toString("hex");
} else {
throw new Error("Invalid OP_RETURN data length!");
}
const recipient = "0x"+data.slice(0, 20).toString("hex");
if(!EVMAddresses.isValidAddress(recipient)) throw new Error("Invalid recipient specified");
return {executionHash, rawAmounts: [rawAmount0, rawAmount1], recipient: getAddress(recipient)};
}
toOpReturnData(recipient: string, rawAmounts: bigint[], executionHash?: string): Buffer {
return EVMSpvVaultContract.toOpReturnData(recipient, rawAmounts, executionHash);
}
static toOpReturnData(recipient: string, rawAmounts: bigint[], executionHash?: string): Buffer {
if(!EVMAddresses.isValidAddress(recipient)) throw new Error("Invalid recipient specified");
if(rawAmounts.length < 1) throw new Error("At least 1 amount needs to be specified");
if(rawAmounts.length > 2) throw new Error("At most 2 amounts need to be specified");
rawAmounts.forEach(val => {
if(val < 0n) throw new Error("Negative raw amount specified");
if(val >= 2n**64n) throw new Error("Raw amount overflow");
});
if(executionHash!=null) {
if(Buffer.from(executionHash, "hex").length !== 32)
throw new Error("Invalid execution hash");
}
const recipientBuffer = Buffer.from(recipient.substring(2).padStart(40, "0"), "hex");
const amount0Buffer = BigIntBufferUtils.toBuffer(rawAmounts[0], "be", 8);
const amount1Buffer = rawAmounts[1]==null || rawAmounts[1]===0n ? Buffer.alloc(0) : BigIntBufferUtils.toBuffer(rawAmounts[1], "be", 8);
const executionHashBuffer = executionHash==null ? Buffer.alloc(0) : Buffer.from(executionHash, "hex");
return Buffer.concat([
recipientBuffer,
amount0Buffer,
amount1Buffer,
executionHashBuffer
]);
}
//Actions
async claim(signer: EVMSigner, vault: EVMSpvVaultData, txs: {tx: EVMSpvWithdrawalData, storedHeader?: EVMBtcStoredHeader}[], synchronizer?: RelaySynchronizer<any, any, any>, initAta?: boolean, txOptions?: TransactionConfirmationOptions): Promise<string> {
const result = await this.txsClaim(signer.getAddress(), vault, txs, synchronizer, initAta, txOptions?.feeRate);
const [signature] = await this.Chain.sendAndConfirm(signer, result, txOptions?.waitForConfirmation, txOptions?.abortSignal);
return signature;
}
async deposit(signer: EVMSigner, vault: EVMSpvVaultData, rawAmounts: bigint[], txOptions?: TransactionConfirmationOptions): Promise<string> {
const result = await this.txsDeposit(signer.getAddress(), vault, rawAmounts, txOptions?.feeRate);
const txHashes = await this.Chain.sendAndConfirm(signer, result, txOptions?.waitForConfirmation, txOptions?.abortSignal);
return txHashes[txHashes.length - 1];
}
async frontLiquidity(signer: EVMSigner, vault: EVMSpvVaultData, realWithdrawalTx: EVMSpvWithdrawalData, withdrawSequence: number, txOptions?: TransactionConfirmationOptions): Promise<string> {
const result = await this.txsFrontLiquidity(signer.getAddress(), vault, realWithdrawalTx, withdrawSequence, txOptions?.feeRate);
const txHashes = await this.Chain.sendAndConfirm(signer, result, txOptions?.waitForConfirmation, txOptions?.abortSignal);
return txHashes[txHashes.length - 1];
}
async open(signer: EVMSigner, vault: EVMSpvVaultData, txOptions?: TransactionConfirmationOptions): Promise<string> {
const result = await this.txsOpen(signer.getAddress(), vault, txOptions?.feeRate);
const [signature] = await this.Chain.sendAndConfirm(signer, result, txOptions?.waitForConfirmation, txOptions?.abortSignal);
return signature;
}
//Transactions
async txsClaim(
signer: string, vault: EVMSpvVaultData, txs: {
tx: EVMSpvWithdrawalData,
storedHeader?: EVMBtcStoredHeader
}[], synchronizer?: RelaySynchronizer<any, any, any>,
initAta?: boolean, feeRate?: string
): Promise<EVMTx[]> {
if(!vault.isOpened()) throw new Error("Cannot claim from a closed vault!");
feeRate ??= await this.Chain.Fees.getFeeRate();
const txsWithMerkleProofs: {
tx: EVMSpvWithdrawalData,
reversedTxId: Buffer,
pos: number,
blockheight: number,
merkle: Buffer[],
storedHeader?: EVMBtcStoredHeader
}[] = [];
for(let tx of txs) {
const merkleProof = await this.bitcoinRpc.getMerkleProof(tx.tx.btcTx.txid, tx.tx.btcTx.blockhash);
this.logger.debug("txsClaim(): merkle proof computed: ", merkleProof);
txsWithMerkleProofs.push({
...merkleProof,
...tx
});
}
const evmTxs: EVMTx[] = [];
const storedHeaders: {[blockhash: string]: EVMBtcStoredHeader} = await EVMBtcRelay.getCommitedHeadersAndSynchronize(
signer, this.btcRelay, txsWithMerkleProofs.filter(tx => tx.storedHeader==null).map(tx => {
return {
blockhash: tx.tx.btcTx.blockhash,
blockheight: tx.blockheight,
requiredConfirmations: vault.getConfirmations()
}
}), evmTxs, synchronizer, feeRate
);
if(storedHeaders==null) throw new Error("Cannot fetch committed header!");
for(let tx of txsWithMerkleProofs) {
evmTxs.push(await this.Claim(signer, vault, tx.tx, tx.storedHeader ?? storedHeaders[tx.tx.btcTx.blockhash], tx.merkle, tx.pos, feeRate));
}
this.logger.debug("txsClaim(): "+evmTxs.length+" claim TXs created claiming "+txs.length+" txs, owner: "+vault.getOwner()+
" vaultId: "+vault.getVaultId().toString(10));
return evmTxs;
}
async txsDeposit(signer: string, vault: EVMSpvVaultData, rawAmounts: bigint[], feeRate?: string): Promise<EVMTx[]> {
if(!vault.isOpened()) throw new Error("Cannot deposit to a closed vault!");
feeRate ??= await this.Chain.Fees.getFeeRate();
const txs: EVMTx[] = [];
let realAmount0: bigint = 0n;
let realAmount1: bigint = 0n;
//Approve first
const requiredApprovals: {[address: string]: bigint} = {};
if(rawAmounts[0]!=null && rawAmounts[0]!==0n) {
if(vault.token0.token.toLowerCase()!==this.Chain.getNativeCurrencyAddress().toLowerCase()) {
realAmount0 = rawAmounts[0] * vault.token0.multiplier;
requiredApprovals[vault.token0.token.toLowerCase()] = realAmount0;
}
}
if(rawAmounts[1]!=null && rawAmounts[1]!==0n) {
if(vault.token1.token.toLowerCase()!==this.Chain.getNativeCurrencyAddress().toLowerCase()) {
realAmount1 = rawAmounts[1] * vault.token1.multiplier;
requiredApprovals[vault.token1.token.toLowerCase()] ??= 0n;
requiredApprovals[vault.token1.token.toLowerCase()] += realAmount1;
}
}
for(let tokenAddress in requiredApprovals) {
txs.push(await this.Chain.Tokens.Approve(signer, tokenAddress, requiredApprovals[tokenAddress], this.contractAddress, feeRate));
}
txs.push(await this.Deposit(signer, vault, rawAmounts, feeRate));
this.logger.debug("txsDeposit(): deposit TX created,"+
" token0: "+vault.token0.token+" rawAmount0: "+rawAmounts[0].toString(10)+" amount0: "+realAmount0.toString(10)+
" token1: "+vault.token1.token+" rawAmount1: "+(rawAmounts[1] ?? 0n).toString(10)+" amount1: "+realAmount1.toString(10));
return txs;
}
async txsFrontLiquidity(signer: string, vault: EVMSpvVaultData, realWithdrawalTx: EVMSpvWithdrawalData, withdrawSequence: number, feeRate?: string): Promise<EVMTx[]> {
if(!vault.isOpened()) throw new Error("Cannot front on a closed vault!");
feeRate ??= await this.Chain.Fees.getFeeRate();
const txs: EVMTx[] = [];
let realAmount0 = 0n;
let realAmount1 = 0n;
//Approve first
const rawAmounts = realWithdrawalTx.getFrontingAmount();
//Approve first
const requiredApprovals: {[address: string]: bigint} = {};
if(rawAmounts[0]!=null && rawAmounts[0]!==0n) {
if(vault.token0.token.toLowerCase()!==this.Chain.getNativeCurrencyAddress().toLowerCase()) {
realAmount0 = rawAmounts[0] * vault.token0.multiplier;
requiredApprovals[vault.token0.token.toLowerCase()] = realAmount0;
}
}
if(rawAmounts[1]!=null && rawAmounts[1]!==0n) {
if(vault.token1.token.toLowerCase()!==this.Chain.getNativeCurrencyAddress().toLowerCase()) {
realAmount1 = rawAmounts[1] * vault.token1.multiplier;
requiredApprovals[vault.token1.token.toLowerCase()] ??= 0n;
requiredApprovals[vault.token1.token.toLowerCase()] += realAmount1;
}
}
for(let tokenAddress in requiredApprovals) {
txs.push(await this.Chain.Tokens.Approve(signer, tokenAddress, requiredApprovals[tokenAddress], this.contractAddress, feeRate));
}
txs.push(await this.Front(signer, vault, realWithdrawalTx, withdrawSequence, feeRate));
this.logger.debug("txsFrontLiquidity(): front TX created,"+
" token0: "+vault.token0.token+" rawAmount0: "+rawAmounts[0].toString(10)+" amount0: "+realAmount0.toString(10)+
" token1: "+vault.token1.token+" rawAmount1: "+(rawAmounts[1] ?? 0n).toString(10)+" amount1: "+realAmount1.toString(10));
return txs;
}
async txsOpen(signer: string, vault: EVMSpvVaultData, feeRate?: string): Promise<EVMTx[]> {
if(vault.isOpened()) throw new Error("Cannot open an already opened vault!");
feeRate ??= await this.Chain.Fees.getFeeRate();
const tx = await this.Open(signer, vault, feeRate);
this.logger.debug("txsOpen(): open TX created, owner: "+vault.getOwner()+
" vaultId: "+vault.getVaultId().toString(10));
return [tx];
}
async getClaimFee(signer: string, withdrawalData: EVMSpvWithdrawalData, feeRate?: string): Promise<bigint> {
feeRate ??= await this.Chain.Fees.getFeeRate();
return EVMFees.getGasFee(EVMSpvVaultContract.GasCosts.CLAIM, feeRate);
}
async getFrontFee(signer: string, withdrawalData: EVMSpvWithdrawalData, feeRate?: string): Promise<bigint> {
feeRate ??= await this.Chain.Fees.getFeeRate();
return EVMFees.getGasFee(EVMSpvVaultContract.GasCosts.FRONT, feeRate);
}
}