Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 11 commits into from
Dec 4, 2024
5 changes: 5 additions & 0 deletions .changeset/clever-melons-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---

Require Sealevel native transfers to cover the rent of the recipient
8 changes: 8 additions & 0 deletions typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -146,6 +150,10 @@ export class CwTokenAdapter
};
}

async getMinimumTransferAmount(_recipient: Address): Promise<bigint> {
return 0n;
}

async isApproveRequired(): Promise<boolean> {
return false;
}
Expand Down
4 changes: 4 additions & 0 deletions typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions typescript/sdk/src/token/adapters/ITokenAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;
}
Expand Down Expand Up @@ -155,6 +173,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;
}
Expand Down Expand Up @@ -614,6 +636,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
Expand Down
10 changes: 10 additions & 0 deletions typescript/sdk/src/warp/WarpCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);

Expand Down Expand Up @@ -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,
Expand Down
44 changes: 41 additions & 3 deletions typescript/sdk/src/warp/WarpCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -643,13 +647,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(
tkporter marked this conversation as resolved.
Show resolved Hide resolved
originToken.decimals,
destinationToken.decimals,
minDestinationTransferAmount.toString(),
),
);

if (minOriginTransferAmount.amount > originTokenAmount.amount) {
return {
amount: `Minimum transfer amount is ${minOriginTransferAmount.getDecimalFormattedAmount()} ${
originToken.symbol
}`,
};
}

return null;
}

Expand Down
Loading