diff --git a/.changeset/clever-melons-sell.md b/.changeset/clever-melons-sell.md new file mode 100644 index 0000000000..f84c5c2ecb --- /dev/null +++ b/.changeset/clever-melons-sell.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Require Sealevel native transfers to cover the rent of the recipient diff --git a/typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts b/typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts index e472133bd8..61e999deed 100644 --- a/typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts @@ -64,6 +64,10 @@ export class CwNativeTokenAdapter throw new Error('Metadata not available to native tokens'); } + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + async isApproveRequired(): Promise { return false; } @@ -146,6 +150,10 @@ export class CwTokenAdapter }; } + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + async isApproveRequired(): Promise { return false; } diff --git a/typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts b/typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts index 06ea3dde10..0983e2b45d 100644 --- a/typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts @@ -46,6 +46,10 @@ export class CosmNativeTokenAdapter throw new Error('Metadata not available to native tokens'); } + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + async isApproveRequired(): Promise { return false; } diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts index cd9910a5a2..72026be69d 100644 --- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts @@ -57,6 +57,10 @@ export class EvmNativeTokenAdapter throw new Error('Metadata not available to native tokens'); } + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + async isApproveRequired( _owner: Address, _spender: Address, diff --git a/typescript/sdk/src/token/adapters/ITokenAdapter.ts b/typescript/sdk/src/token/adapters/ITokenAdapter.ts index d989fb7091..eabd3e7b31 100644 --- a/typescript/sdk/src/token/adapters/ITokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/ITokenAdapter.ts @@ -25,6 +25,7 @@ export interface ITokenAdapter { getBalance(address: Address): Promise; getTotalSupply(): Promise; getMetadata(isNft?: boolean): Promise; + getMinimumTransferAmount(recipient: Address): Promise; isApproveRequired( owner: Address, spender: Address, diff --git a/typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts b/typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts index a9539cab98..4f7853a97c 100644 --- a/typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts @@ -90,6 +90,24 @@ export class SealevelNativeTokenAdapter throw new Error('Metadata not available to native tokens'); } + // Require a minimum transfer amount to cover rent for the recipient. + async getMinimumTransferAmount(recipient: Address): Promise { + const recipientPubkey = new PublicKey(recipient); + const provider = this.getProvider(); + const recipientAccount = await provider.getAccountInfo(recipientPubkey); + const recipientDataLength = recipientAccount?.data.length ?? 0; + const recipientLamports = recipientAccount?.lamports ?? 0; + + const minRequiredLamports = + await provider.getMinimumBalanceForRentExemption(recipientDataLength); + + if (recipientLamports < minRequiredLamports) { + return BigInt(minRequiredLamports - recipientLamports); + } + + return 0n; + } + async isApproveRequired(): Promise { return false; } @@ -155,6 +173,10 @@ export class SealevelTokenAdapter return { decimals: 9, symbol: 'SPL', name: 'SPL Token', totalSupply: '' }; } + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + async isApproveRequired(): Promise { return false; } @@ -614,6 +636,10 @@ export class SealevelHypNativeAdapter extends SealevelHypTokenAdapter { return this.wrappedNative.getMetadata(); } + override async getMinimumTransferAmount(recipient: Address): Promise { + return this.wrappedNative.getMinimumTransferAmount(recipient); + } + override async getMedianPriorityFee(): Promise { // Native tokens don't have a collateral address, so we don't fetch // prioritization fee history diff --git a/typescript/sdk/src/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index 1f0681ee74..f3325a4340 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -189,11 +189,13 @@ describe('WarpCore', () => { const balanceStubs = warpCore.tokens.map((t) => sinon.stub(t, 'getBalance').resolves({ amount: MOCK_BALANCE } as any), ); + const minimumTransferAmount = 10n; const quoteStubs = warpCore.tokens.map((t) => sinon.stub(t, 'getHypAdapter').returns({ quoteTransferRemoteGas: () => Promise.resolve(MOCK_INTERCHAIN_QUOTE), isApproveRequired: () => Promise.resolve(false), populateTransferRemoteTx: () => Promise.resolve({}), + getMinimumTransferAmount: () => Promise.resolve(minimumTransferAmount), } as any), ); @@ -229,6 +231,14 @@ describe('WarpCore', () => { }); expect(Object.keys(invalidAmount || {})[0]).to.equal('amount'); + const insufficientAmount = await warpCore.validateTransfer({ + originTokenAmount: evmHypNative.amount(minimumTransferAmount - 1n), + destination: test2.name, + recipient: MOCK_ADDRESS, + sender: MOCK_ADDRESS, + }); + expect(Object.keys(insufficientAmount || {})[0]).to.equal('amount'); + const insufficientBalance = await warpCore.validateTransfer({ originTokenAmount: evmHypNative.amount(BIG_TRANSFER_AMOUNT), destination: test2.name, diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index 16903b5fa2..8fa702e735 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -561,7 +561,11 @@ export class WarpCore { const recipientError = this.validateRecipient(recipient, destination); if (recipientError) return recipientError; - const amountError = this.validateAmount(originTokenAmount); + const amountError = await this.validateAmount( + originTokenAmount, + destination, + recipient, + ); if (amountError) return amountError; const destinationCollateralError = await this.validateDestinationCollateral( @@ -643,13 +647,47 @@ export class WarpCore { /** * Ensure token amount is valid */ - protected validateAmount( + protected async validateAmount( originTokenAmount: TokenAmount, - ): Record | null { + destination: ChainNameOrId, + recipient: Address, + ): Promise | null> { if (!originTokenAmount.amount || originTokenAmount.amount < 0n) { const isNft = originTokenAmount.token.isNft(); return { amount: isNft ? 'Invalid Token Id' : 'Invalid amount' }; } + + // Check the transfer amount is sufficient on the destination side + + const originToken = originTokenAmount.token; + + const destinationName = this.multiProvider.getChainName(destination); + const destinationToken = + originToken.getConnectionForChain(destinationName)?.token; + assert(destinationToken, `No connection found for ${destinationName}`); + const destinationAdapter = destinationToken.getAdapter(this.multiProvider); + + // Get the min required destination amount + const minDestinationTransferAmount = + await destinationAdapter.getMinimumTransferAmount(recipient); + + // Convert the minDestinationTransferAmount to an origin amount + const minOriginTransferAmount = destinationToken.amount( + convertDecimals( + originToken.decimals, + destinationToken.decimals, + minDestinationTransferAmount.toString(), + ), + ); + + if (minOriginTransferAmount.amount > originTokenAmount.amount) { + return { + amount: `Minimum transfer amount is ${minOriginTransferAmount.getDecimalFormattedAmount()} ${ + originToken.symbol + }`, + }; + } + return null; }