feat: require Sealevel native transfers to cover the rent of the recipient (#4936)

### Description

- Happened a few times where someone transferred SOL from Eclipse -> SOL
on Solana, and the recipient did not yet exist (so had no SOL balance to
cover its rent). The amount on these transfers would be insufficient to
cover the rent of the recipient, so the transfer could never be
delivered. See
https://discord.com/channels/935678348330434570/1311371339889639486/1311381212828532858
for context on the first time this happened. There were then a few other
instances the day after
- This introduces `ITokenAdapter.getMinimumTransferAmount`, where impls
generally return a minimum of 0, but native Sealevel and native Sealevel
warp routes have some logic that is aware of required rent exemption
balances
- Atm, in WarpCore this only checks getMinimumTransferAmount on the
destination side and not the origin -- my reasoning here is that:
a. for the specific issue this is addressing, the destination side is
the only one where issues can arise. On the origin, the account that
escrows the SOL is always rent exempt
b. checking on the origin for completeness could be done, but would
require some extra logic to be aware of where tokens are escrowed on the
origin side (i.e. the recipient should be the account that escrows the
SOL), which felt unnecessary / confusing

### Drive-by changes

<!--
Are there any minor or drive-by changes also included?
-->

### Related issues

<!--
- Fixes #[issue number here]
-->

### Backward compatibility

<!--
Are these changes backward compatible? Are there any infrastructure
implications, e.g. changes that would prohibit deploying older commits
using this infra tooling?

Yes/No
-->

### Testing

<!--
What kind of testing have these changes undergone?

None/Manual/Unit Tests
-->
pull/4722/merge
Trevor Porter 15 hours ago committed by GitHub
parent 97c1f80b73
commit 2054f4f5be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/clever-melons-sell.md
  2. 8
      typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts
  3. 4
      typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts
  4. 4
      typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
  5. 1
      typescript/sdk/src/token/adapters/ITokenAdapter.ts
  6. 26
      typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts
  7. 10
      typescript/sdk/src/warp/WarpCore.test.ts
  8. 44
      typescript/sdk/src/warp/WarpCore.ts

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---
Require Sealevel native transfers to cover the rent of the recipient

@ -64,6 +64,10 @@ export class CwNativeTokenAdapter
throw new Error('Metadata not available to native tokens');
}
async getMinimumTransferAmount(_recipient: Address): Promise<bigint> {
return 0n;
}
async isApproveRequired(): Promise<boolean> {
return false;
}
@ -146,6 +150,10 @@ export class CwTokenAdapter
};
}
async getMinimumTransferAmount(_recipient: Address): Promise<bigint> {
return 0n;
}
async isApproveRequired(): Promise<boolean> {
return false;
}

@ -46,6 +46,10 @@ export class CosmNativeTokenAdapter
throw new Error('Metadata not available to native tokens');
}
async getMinimumTransferAmount(_recipient: Address): Promise<bigint> {
return 0n;
}
async isApproveRequired(): Promise<boolean> {
return false;
}

@ -57,6 +57,10 @@ export class EvmNativeTokenAdapter
throw new Error('Metadata not available to native tokens');
}
async getMinimumTransferAmount(_recipient: Address): Promise<bigint> {
return 0n;
}
async isApproveRequired(
_owner: Address,
_spender: Address,

@ -25,6 +25,7 @@ export interface ITokenAdapter<Tx> {
getBalance(address: Address): Promise<bigint>;
getTotalSupply(): Promise<bigint | undefined>;
getMetadata(isNft?: boolean): Promise<TokenMetadata>;
getMinimumTransferAmount(recipient: Address): Promise<bigint>;
isApproveRequired(
owner: Address,
spender: Address,

@ -97,6 +97,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<bigint> {
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<boolean> {
return false;
}
@ -162,6 +180,10 @@ export class SealevelTokenAdapter
return { decimals: 9, symbol: 'SPL', name: 'SPL Token', totalSupply: '' };
}
async getMinimumTransferAmount(_recipient: Address): Promise<bigint> {
return 0n;
}
async isApproveRequired(): Promise<boolean> {
return false;
}
@ -641,6 +663,10 @@ export class SealevelHypNativeAdapter extends SealevelHypTokenAdapter {
return this.wrappedNative.getMetadata();
}
override async getMinimumTransferAmount(recipient: Address): Promise<bigint> {
return this.wrappedNative.getMinimumTransferAmount(recipient);
}
override async getMedianPriorityFee(): Promise<number | undefined> {
// Native tokens don't have a collateral address, so we don't fetch
// prioritization fee history

@ -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,

@ -567,7 +567,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(
@ -649,13 +653,47 @@ export class WarpCore {
/**
* Ensure token amount is valid
*/
protected validateAmount(
protected async validateAmount(
originTokenAmount: TokenAmount,
): Record<string, string> | null {
destination: ChainNameOrId,
recipient: Address,
): Promise<Record<string, string> | 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;
}

Loading…
Cancel
Save