UNPKG

@wormhole-foundation/sdk-connect

Version:

The core package for the Connect SDK, used in conjunction with 1 or more of the chain packages

624 lines 31.3 kB
import { amount, encoding, finality, guardians, time, toChain as toChainName, } from "@wormhole-foundation/sdk-base"; import { TokenBridge, UniversalAddress, canonicalAddress, deserialize, isNative, isSameToken, isTokenId, isTokenTransferDetails, isTransactionIdentifier, isWormholeMessageId, serialize, toNative, toUniversal, } from "@wormhole-foundation/sdk-definitions"; import { signSendWait } from "../../common.js"; import { DEFAULT_TASK_TIMEOUT } from "../../config.js"; import { chainToPlatform } from "@wormhole-foundation/sdk-base"; import { TransferState, isAttested, isInReview, isRedeemed, isSourceFinalized, isSourceInitiated, } from "../../types.js"; import { getGovernedTokens, getGovernorLimits } from "../../whscan-api.js"; import { Wormhole } from "../../wormhole.js"; export class TokenTransfer { wh; fromChain; toChain; // state machine tracker _state; // transfer details transfer; // txids, populated once transactions are submitted txids = []; // The corresponding vaa representing the TokenTransfer // on the source chain (if its been completed and finalized) attestations; constructor(wh, transfer, fromChain, toChain) { this._state = TransferState.Created; this.wh = wh; this.transfer = transfer; this.fromChain = fromChain ?? wh.getChain(transfer.from.chain); this.toChain = toChain ?? wh.getChain(transfer.to.chain); } getTransferState() { return this._state; } static async from(wh, from, timeout = 6000, fromChain, toChain) { if (isTokenTransferDetails(from)) { fromChain = fromChain ?? wh.getChain(from.from.chain); toChain = toChain ?? wh.getChain(from.to.chain); // throws if invalid TokenTransfer.validateTransferDetails(wh, from, fromChain, toChain); // Apply hackery from = { ...from, ...(await TokenTransfer.destinationOverrides(fromChain, toChain, from)), }; return new TokenTransfer(wh, from, fromChain, toChain); } let tt; if (isWormholeMessageId(from)) { tt = await TokenTransfer.fromIdentifier(wh, from, timeout); } else if (isTransactionIdentifier(from)) { tt = await TokenTransfer.fromTransaction(wh, from, timeout, fromChain); } else { throw new Error("Invalid `from` parameter for TokenTransfer"); } tt.fromChain = fromChain ?? wh.getChain(tt.transfer.from.chain); tt.toChain = toChain ?? wh.getChain(tt.transfer.to.chain); await tt.fetchAttestation(timeout); return tt; } // init from the seq id static async fromIdentifier(wh, id, timeout) { const vaa = await TokenTransfer.getTransferVaa(wh, id, timeout); if (!vaa) throw new Error("VAA not found"); const automatic = vaa.protocolName === "AutomaticTokenBridge"; // TODO: the `from.address` here is a lie, but we don't // immediately have enough info to get the _correct_ one let from = { chain: vaa.emitterChain, address: vaa.emitterAddress }; let { token, to } = vaa.payload; let nativeAddress; if (token.chain === from.chain) { nativeAddress = await wh.getTokenNativeAddress(from.chain, token.chain, token.address); } else { const fromChain = await wh.getChain(from.chain); const tb = await fromChain.getTokenBridge(); const wrapped = await tb.getWrappedAsset(token); nativeAddress = toNative(from.chain, wrapped.toString()); } const decimals = await wh.getDecimals(from.chain, nativeAddress); const scaledAmount = amount.scale(amount.fromBaseUnits(token.amount, Math.min(decimals, TokenTransfer.MAX_DECIMALS)), decimals); let nativeGasAmount = 0n; if (automatic) { nativeGasAmount = vaa.payload.payload.toNativeTokenAmount; from = { chain: vaa.emitterChain, address: vaa.payload.from }; to = { chain: vaa.payload.to.chain, address: vaa.payload.payload.targetRecipient }; } const details = { token: token, amount: amount.units(scaledAmount), from, to, automatic, nativeGas: nativeGasAmount, }; // TODO: grab at least the init tx from the api const tt = new TokenTransfer(wh, details); tt.attestations = [{ id: id, attestation: vaa }]; tt._state = TransferState.Attested; return tt; } static async fromTransaction(wh, from, timeout, fromChain) { fromChain = fromChain ?? wh.getChain(from.chain); const msg = await TokenTransfer.getTransferMessage(fromChain, from.txid, timeout); const tt = await TokenTransfer.fromIdentifier(wh, msg, timeout); tt.txids = [from]; return tt; } // start the WormholeTransfer by submitting transactions to the source chain // returns a transaction hash async initiateTransfer(signer) { if (this._state !== TransferState.Created) throw new Error("Invalid state transition in `initiateTransfer`"); this.txids = await TokenTransfer.transfer(this.fromChain, this.transfer, signer); this._state = TransferState.SourceInitiated; return this.txids.map(({ txid }) => txid); } // wait for the VAA to be ready // returns the sequence number async fetchAttestation(timeout) { if (this._state < TransferState.SourceInitiated || this._state > TransferState.Attested) throw new Error("Invalid state transition in `fetchAttestation`, expected at least `SourceInitiated`"); if (!this.attestations || this.attestations.length === 0) { if (this.txids.length === 0) throw new Error("No VAAs set and txids available to look them up"); // TODO: assuming the _last_ transaction in the list will contain the msg id const txid = this.txids[this.txids.length - 1]; const msgId = await TokenTransfer.getTransferMessage(this.fromChain, txid.txid, timeout); this.attestations = [{ id: msgId }]; } for (const idx in this.attestations) { // Check if we already have the VAA if (this.attestations[idx].attestation) continue; const vaa = await TokenTransfer.getTransferVaa(this.wh, this.attestations[idx].id, timeout); if (!vaa) throw new Error("VAA not found"); this.attestations[idx].attestation = vaa; } this._state = TransferState.Attested; if (this.attestations.length > 0) { // Check if the transfer has been completed const { attestation } = this.attestations[0]; const completed = await TokenTransfer.isTransferComplete(this.toChain, attestation); if (completed) this._state = TransferState.DestinationFinalized; } return this.attestations.map((vaa) => vaa.id); } // finish the WormholeTransfer by submitting transactions to the destination chain // returns a transaction hash async completeTransfer(signer) { if (this._state < TransferState.Attested) throw new Error("Invalid state transition, must be attested prior to calling `completeTransfer`."); if (!this.attestations) throw new Error("No VAA details available"); const { attestation } = this.attestations[0]; if (!attestation) throw new Error(`No VAA found for ${this.attestations[0].id.sequence}`); const redeemTxids = await TokenTransfer.redeem(this.toChain, attestation, signer); this.txids.push(...redeemTxids); this._state = TransferState.DestinationInitiated; return redeemTxids.map(({ txid }) => txid); } } (function (TokenTransfer) { /** 8 is maximum precision supported by the token bridge VAA */ TokenTransfer.MAX_DECIMALS = 8; // Static method to perform the transfer so a custom RPC may be used // Note: this assumes the transfer has already been validated with `validateTransfer` async function transfer(fromChain, transfer, signer) { const senderAddress = toNative(signer.chain(), signer.address()); const token = isTokenId(transfer.token) ? transfer.token.address : transfer.token; let xfer; if (transfer.automatic) { const tb = await fromChain.getAutomaticTokenBridge(); xfer = tb.transfer(senderAddress, transfer.to, token, transfer.amount, transfer.nativeGas); } else { const tb = await fromChain.getTokenBridge(); xfer = tb.transfer(senderAddress, transfer.to, token, transfer.amount, transfer.payload); } return signSendWait(fromChain, xfer, signer); } TokenTransfer.transfer = transfer; // Static method to allow passing a custom RPC async function redeem(toChain, vaa, signer) { const signerAddress = toNative(signer.chain(), signer.address()); const xfer = vaa.protocolName === "AutomaticTokenBridge" ? (await toChain.getAutomaticTokenBridge()).redeem(signerAddress, vaa) : (await toChain.getTokenBridge()).redeem(signerAddress, vaa); return signSendWait(toChain, xfer, signer); } TokenTransfer.redeem = redeem; // AsyncGenerator fn that produces status updates through an async generator // eventually producing a receipt // can be called repeatedly so the receipt is updated as it moves through the // steps of the transfer async function* track(wh, receipt, timeout = DEFAULT_TASK_TIMEOUT, fromChain, toChain) { const start = Date.now(); const leftover = (start, max) => Math.max(max - (Date.now() - start), 0); fromChain = fromChain ?? wh.getChain(receipt.from); // Check the source chain for initiation transaction // and capture the message id if (isSourceInitiated(receipt)) { if (receipt.originTxs.length === 0) throw "Origin transactions required to fetch message id"; const { txid } = receipt.originTxs[receipt.originTxs.length - 1]; const msg = await TokenTransfer.getTransferMessage(fromChain, txid, leftover(start, timeout)); receipt = { ...receipt, state: TransferState.SourceFinalized, attestation: { id: msg }, }; yield receipt; } // If the source is finalized or in review (governor held), we need to fetch the signed attestation // (once it's available) so that we may deliver it to the destination chain // or at least track the transfer through its progress if (isSourceFinalized(receipt) || isInReview(receipt)) { if (!receipt.attestation.id) throw "Attestation id required to fetch attestation"; const { id } = receipt.attestation; const attestation = await TokenTransfer.getTransferVaa(wh, id, leftover(start, timeout)); if (attestation) { receipt = { ...receipt, attestation: { id, attestation }, state: TransferState.Attested, }; yield receipt; } else { // If the attestation is not found, check if the transfer is held by the governor const isEnqueued = await TokenTransfer.isTransferEnqueued(wh, id); if (isEnqueued) { receipt = { ...receipt, state: TransferState.InReview, }; yield receipt; } } throw new Error("Attestation not found"); } // First try to grab the tx status from the API // Note: this requires a subsequent async step on the backend // to have the dest txid populated, so it may be delayed by some time if (isAttested(receipt) || isSourceFinalized(receipt) || isInReview(receipt)) { if (!receipt.attestation.id) throw "Attestation id required to fetch redeem tx"; const { id } = receipt.attestation; const txStatus = await wh.getTransactionStatus(id, leftover(start, timeout)); if (txStatus && txStatus.globalTx?.destinationTx?.txHash) { const { chainId, txHash } = txStatus.globalTx.destinationTx; receipt = { ...receipt, destinationTxs: [{ chain: toChainName(chainId), txid: txHash }], state: TransferState.DestinationInitiated, }; } yield receipt; } // Fall back to asking the destination chain if this VAA has been redeemed // Note: We do not get any destinationTxs with this method if (isAttested(receipt) || isRedeemed(receipt)) { if (!receipt.attestation.attestation) throw "Signed Attestation required to check for redeem"; if (receipt.attestation.attestation.payloadName === "AttestMeta") { throw new Error("Unable to track an AttestMeta receipt"); } let isComplete = await TokenTransfer.isTransferComplete(toChain ?? wh.getChain(receipt.attestation.attestation.payload.to.chain), receipt.attestation.attestation); if (isComplete) { receipt = { ...receipt, state: TransferState.DestinationFinalized, }; } yield receipt; } yield receipt; } TokenTransfer.track = track; function getReceipt(xfer) { const { transfer } = xfer; const from = transfer.from.chain; const to = transfer.to.chain; let receipt = { from: from, to: to, state: TransferState.Created, }; const originTxs = xfer.txids.filter((txid) => txid.chain === transfer.from.chain); if (originTxs.length > 0) { receipt = { ...receipt, state: TransferState.SourceInitiated, originTxs: originTxs, }; } const att = xfer.attestations && xfer.attestations.length > 0 ? xfer.attestations[0] : undefined; const attestation = att && att.id ? { id: att.id, attestation: att.attestation } : undefined; if (attestation) { if (attestation.id) { receipt = { ...receipt, state: TransferState.SourceFinalized, attestation: { id: attestation.id }, }; if (attestation.attestation) { receipt = { ...receipt, state: TransferState.Attested, attestation: { id: attestation.id, attestation: attestation.attestation }, }; } } } const destinationTxs = xfer.txids.filter((txid) => txid.chain === transfer.to.chain); if (destinationTxs.length > 0) { receipt = { ...receipt, state: TransferState.DestinationFinalized, destinationTxs: destinationTxs, }; } return receipt; } TokenTransfer.getReceipt = getReceipt; // Lookup the token id for the destination chain given the source chain // and token id async function lookupDestinationToken(srcChain, dstChain, token) { let lookup; const tb = await srcChain.getTokenBridge(); if (isNative(token.address)) { // if native, get the wrapped asset id const wrappedNative = await tb.getWrappedNative(); lookup = { chain: token.chain, address: await tb.getTokenUniversalAddress(wrappedNative), }; } else { try { // otherwise, check to see if it is a wrapped token locally let address; if (UniversalAddress.instanceof(token.address)) { address = (await tb.getWrappedAsset(token)); } else { address = token.address; } lookup = await tb.getOriginalAsset(address); } catch (e) { if (!e.message.includes("not a wrapped asset")) throw e; // not a from-chain native wormhole-wrapped one let address; if (UniversalAddress.instanceof(token.address)) { address = await tb.getTokenNativeAddress(srcChain.chain, token.address); } else { address = token.address; } lookup = { chain: token.chain, address: await tb.getTokenUniversalAddress(address) }; } } // if the token id is actually native to the destination, return it const dstTb = await dstChain.getTokenBridge(); if (lookup.chain === dstChain.chain) { const nativeAddress = await dstTb.getTokenNativeAddress(lookup.chain, lookup.address); const destWrappedNative = await dstTb.getWrappedNative(); if (canonicalAddress({ chain: dstChain.chain, address: destWrappedNative }) === canonicalAddress({ chain: dstChain.chain, address: nativeAddress })) { return { chain: dstChain.chain, address: "native" }; } return { chain: dstChain.chain, address: nativeAddress }; } // otherwise, figure out what the token address representing the wormhole-wrapped token we're transferring const dstAddress = await dstTb.getWrappedAsset(lookup); return { chain: dstChain.chain, address: dstAddress }; } TokenTransfer.lookupDestinationToken = lookupDestinationToken; async function isTransferComplete(toChain, vaa) { if (vaa.protocolName === "AutomaticTokenBridge") vaa = deserialize("TokenBridge:TransferWithPayload", serialize(vaa)); const tb = await toChain.getTokenBridge(); return tb.isTransferCompleted(vaa); } TokenTransfer.isTransferComplete = isTransferComplete; async function getTransferMessage(chain, txid, timeout) { // A Single wormhole message will be returned for a standard token transfer const whm = await Wormhole.parseMessageFromTx(chain, txid, timeout); if (whm.length !== 1) throw new Error("Expected a single Wormhole Message, got: " + whm.length); return whm[0]; } TokenTransfer.getTransferMessage = getTransferMessage; async function getTransferVaa(wh, key, timeout) { const vaa = await wh.getVaa(key, TokenBridge.getTransferDiscriminator(), timeout); if (!vaa) return null; // Check if its automatic and re-de-serialize if (vaa.payloadName === "TransferWithPayload") { const { chain, address } = vaa.payload.to; const { tokenBridgeRelayer } = wh.config.chains[chain].contracts; const relayerAddress = tokenBridgeRelayer ? toUniversal(chain, tokenBridgeRelayer) : null; // If the target address is the relayer address, expect its an automatic token bridge vaa if (!!relayerAddress && address.equals(relayerAddress)) { return deserialize("AutomaticTokenBridge:TransferWithRelay", serialize(vaa)); } } return vaa; } TokenTransfer.getTransferVaa = getTransferVaa; async function isTransferEnqueued(wh, key) { return await wh.getIsVaaEnqueued(key); } TokenTransfer.isTransferEnqueued = isTransferEnqueued; function validateTransferDetails(wh, transfer, fromChain, toChain) { if (transfer.amount === 0n) throw new Error("Amount cannot be 0"); if (transfer.from.chain === transfer.to.chain) throw new Error("Cannot transfer to the same chain"); fromChain = fromChain ?? wh.getChain(transfer.from.chain); toChain = toChain ?? wh.getChain(transfer.to.chain); if (transfer.automatic) { if (transfer.payload) throw new Error("Payload with automatic delivery is not supported"); if (!fromChain.supportsAutomaticTokenBridge()) throw new Error(`Automatic Token Bridge not supported on ${transfer.from.chain}`); if (!toChain.supportsAutomaticTokenBridge()) throw new Error(`Automatic Token Bridge not supported on ${transfer.to.chain}`); const nativeGas = transfer.nativeGas ?? 0n; if (nativeGas > transfer.amount) throw new Error(`Native gas amount > amount (${nativeGas} > ${transfer.amount})`); } else { if (transfer.nativeGas) throw new Error("Gas Dropoff is only supported for automatic transfers"); if (!fromChain.supportsTokenBridge()) throw new Error(`Token Bridge not supported on ${transfer.from.chain}`); if (!toChain.supportsTokenBridge()) throw new Error(`Token Bridge not supported on ${transfer.to.chain}`); } } TokenTransfer.validateTransferDetails = validateTransferDetails; async function quoteTransfer(wh, srcChain, dstChain, transfer) { const srcTb = await srcChain.getTokenBridge(); let srcToken; if (isNative(transfer.token.address)) { srcToken = await srcTb.getWrappedNative(); } else if (UniversalAddress.instanceof(transfer.token.address)) { try { srcToken = (await srcTb.getWrappedAsset(transfer.token)); } catch (e) { if (!e.message.includes("not a wrapped asset")) throw e; srcToken = await srcTb.getTokenNativeAddress(srcChain.chain, transfer.token.address); } } else { srcToken = transfer.token.address; } // @ts-ignore: TS2339 const srcTokenId = Wormhole.tokenId(srcChain.chain, srcToken.toString()); const srcDecimals = await srcChain.getDecimals(srcToken); const srcAmount = amount.fromBaseUnits(transfer.amount, srcDecimals); const srcAmountTruncated = amount.truncate(srcAmount, TokenTransfer.MAX_DECIMALS); // Ensure the transfer would not violate governor transfer limits const [tokens, limits] = await Promise.all([ getGovernedTokens(wh.config.api), getGovernorLimits(wh.config.api), ]); const warnings = []; if (limits !== null && srcChain.chain in limits && tokens !== null) { let origAsset; if (isNative(transfer.token.address)) { origAsset = { chain: srcChain.chain, address: await srcTb.getTokenUniversalAddress(srcToken), }; } else { try { origAsset = await srcTb.getOriginalAsset(transfer.token.address); } catch (e) { if (!e.message.includes("not a wrapped asset")) throw e; origAsset = { chain: srcChain.chain, address: await srcTb.getTokenUniversalAddress(srcToken), }; } } if (origAsset.chain in tokens && origAsset.address.toString() in tokens[origAsset.chain]) { const limit = limits[srcChain.chain]; const tokenPrice = tokens[origAsset.chain][origAsset.address.toString()]; const notionalTransferAmt = tokenPrice * amount.whole(srcAmountTruncated); if (limit.maxSize && notionalTransferAmt > limit.maxSize) { warnings.push({ type: "GovernorLimitWarning", reason: "ExceedsLargeTransferLimit", }); } if (notionalTransferAmt > limit.available) { warnings.push({ type: "GovernorLimitWarning", reason: "ExceedsRemainingNotional", }); } } } const dstToken = await TokenTransfer.lookupDestinationToken(srcChain, dstChain, transfer.token); const dstDecimals = await dstChain.getDecimals(dstToken.address); const dstAmountReceivable = amount.scale(srcAmountTruncated, dstDecimals); const eta = finality.estimateFinalityTime(srcChain.chain) + guardians.guardianAttestationEta; if (!transfer.automatic) { return { sourceToken: { token: transfer.token, amount: amount.units(srcAmountTruncated), }, destinationToken: { token: dstToken, amount: amount.units(dstAmountReceivable) }, warnings: warnings.length > 0 ? warnings : undefined, eta, expires: time.expiration(24, 0, 0), // manual transfer quote is good for 24 hours }; } // Otherwise automatic // The fee is removed from the amount transferred // quoted on the source chain const stb = await srcChain.getAutomaticTokenBridge(); const fee = await stb.getRelayerFee(dstChain.chain, srcToken); const feeAmountDest = amount.scale(amount.truncate(amount.fromBaseUnits(fee, srcDecimals), TokenTransfer.MAX_DECIMALS), dstDecimals); // nativeGas is in source chain decimals const srcNativeGasAmountRequested = transfer.nativeGas ?? 0n; // convert to destination chain decimals const dstNativeGasAmountRequested = amount.units(amount.scale(amount.truncate(amount.fromBaseUnits(srcNativeGasAmountRequested, srcDecimals), TokenTransfer.MAX_DECIMALS), dstDecimals)); // TODO: consider moving these solana specific checks to its protocol implementation const solanaMinBalanceForRentExemptAccount = 890880n; let destinationNativeGas = 0n; if (transfer.nativeGas) { const dtb = await dstChain.getAutomaticTokenBridge(); // There is a limit applied to the amount of the source // token that may be swapped for native gas on the destination const [maxNativeAmountIn, _destinationNativeGas] = await Promise.all([ dtb.maxSwapAmount(dstToken.address), // Get the actual amount we should receive dtb.nativeTokenAmount(dstToken.address, dstNativeGasAmountRequested), ]); if (dstNativeGasAmountRequested > maxNativeAmountIn) throw new Error(`Native gas amount exceeds maximum swap amount: ${amount.fmt(dstNativeGasAmountRequested, dstDecimals)}>${amount.fmt(maxNativeAmountIn, dstDecimals)}`); // when native gas is requested on solana, the amount must be at least the rent-exempt amount // or the transaction could fail if the account does not have enough lamports if (chainToPlatform(dstChain.chain) === "Solana" && _destinationNativeGas < solanaMinBalanceForRentExemptAccount) { throw new Error(`Native gas amount must be at least ${solanaMinBalanceForRentExemptAccount} lamports`); } destinationNativeGas = _destinationNativeGas; } const destAmountLessFee = amount.units(dstAmountReceivable) - dstNativeGasAmountRequested - amount.units(feeAmountDest); // when sending wsol to solana, the amount must be at least the rent-exempt amount // or the transaction could fail if the account does not have enough lamports if (chainToPlatform(dstToken.chain) === "Solana") { const nativeWrappedTokenId = await dstChain.getNativeWrappedTokenId(); const isNativeSol = isNative(dstToken.address) || isSameToken(dstToken, nativeWrappedTokenId); if (isNativeSol && destAmountLessFee < solanaMinBalanceForRentExemptAccount) { throw new Error(`Destination amount must be at least ${solanaMinBalanceForRentExemptAccount} lamports`); } } return { sourceToken: { token: transfer.token, amount: amount.units(srcAmountTruncated), }, destinationToken: { token: dstToken, amount: destAmountLessFee }, relayFee: { token: dstToken, amount: amount.units(feeAmountDest) }, destinationNativeGas, warnings: warnings.length > 0 ? warnings : undefined, eta, expires: time.expiration(0, 5, 0), // automatic transfer quote is good for 5 minutes }; } TokenTransfer.quoteTransfer = quoteTransfer; async function destinationOverrides(srcChain, dstChain, transfer) { const _transfer = { ...transfer }; // Bit of (temporary) hackery until solana contracts support being // sent a VAA with the primary address // Note: Do _not_ override if automatic or if the destination token is native // gas token if (chainToPlatform(transfer.to.chain) === "Solana" && !_transfer.automatic) { const destinationToken = await TokenTransfer.lookupDestinationToken(srcChain, dstChain, _transfer.token); if (isNative(destinationToken.address)) { const nativeWrappedTokenId = await dstChain.getNativeWrappedTokenId(); _transfer.to = await dstChain.getTokenAccount(_transfer.to.address, nativeWrappedTokenId.address); } else { _transfer.to = await dstChain.getTokenAccount(_transfer.to.address, destinationToken.address); } } if (_transfer.to.chain === "Sei") { if (_transfer.to.chain === "Sei" && _transfer.payload) throw new Error("Arbitrary payloads unsupported for Sei"); // For sei, we reserve the payload for a token transfer through the sei bridge. _transfer.payload = encoding.bytes.encode(JSON.stringify({ basic_recipient: { recipient: encoding.b64.encode(_transfer.to.address.toString()), }, })); const translator = dstChain.config.contracts.translator; if (translator === undefined || translator === "") throw new Error("Unexpected empty translator address"); _transfer.to = Wormhole.chainAddress(_transfer.to.chain, translator); } return _transfer; } TokenTransfer.destinationOverrides = destinationOverrides; })(TokenTransfer || (TokenTransfer = {})); //# sourceMappingURL=tokenTransfer.js.map