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

Fast Transfers #147

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open

Fast Transfers #147

wants to merge 18 commits into from

Conversation

kiseln
Copy link
Contributor

@kiseln kiseln commented Nov 28, 2024

Description

Fast transfer is initiated by sending tokens to the bridge contract with a proper message for ft_on_transfer.

Recipient on Near

  • Tokens are transferred directly to the recipient
  • During finalization tokens and fees are released back to the relayer

Recipient on other chain

  • Tokens are burned/locked and a new transfer is created for a 3rd-party chain.
  • During finalization tokens are released to the relayer
  • Fees are paid only after finalizing on 3rd-party chain

Implementation

  • msg has been changed for ft_on_transfer to resolve between init_transfer and fast_fin_transfer.
  • Fast transfers are stored in a lookup with a key being a hash of its struct. Relayer must provide correct transfer_id, token_id, recipient, amount, fee, and msg.
  • Storage deposit is only performed if recipient is on Near in order to be able to transfer tokens to the recipient. Storage deposit is done using relayer balance instead of attached NEAR.
  • Any transfers requiring native NEAR are done using wNEAR. If recipient is on Near, the tokens are unwrapped before sending.
  • Only relayer that performed fast transfer can then finalize the transfer

Concerns

  • For other chain transfers sender is changed to be the relayer. I don't think this parameter is important but keeping in mind that it is different from normal transfers.
  • Storage deposits need to be double checked and tested

@kiseln kiseln requested review from karim-en and olga24912 November 29, 2024 08:13
near/omni-bridge/src/lib.rs Outdated Show resolved Hide resolved
@kiseln kiseln marked this pull request as ready for review December 5, 2024 13:46
@kiseln kiseln requested a review from karim-en December 5, 2024 13:46
@kiseln kiseln changed the title Implement simple fast transfer Fast Transfers Dec 5, 2024
near/omni-bridge/src/lib.rs Outdated Show resolved Hide resolved
near/omni-bridge/src/lib.rs Outdated Show resolved Hide resolved
near/omni-bridge/src/lib.rs Show resolved Hide resolved
@kiseln kiseln requested a review from karim-en December 6, 2024 10:28
near/omni-bridge/src/lib.rs Outdated Show resolved Hide resolved
near/omni-types/src/lib.rs Outdated Show resolved Hide resolved
pub recipient: OmniAddress,
pub fee: Fee,
pub msg: String,
pub storage_deposit_amount: Option<u128>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And could you add comment, when the storage_deposit should be provided and for what.

near/omni-bridge/src/lib.rs Outdated Show resolved Hide resolved

let OmniAddress::Near(recipient) = fast_transfer.recipient.clone() else {
env::panic_str("ERR_INVALID_STATE")
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the ft_on_transfer function, we burned the tokens. Since an error occurred here, we need to roll back the token burn.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would never happen because this method is only called if recipient is on Near

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In any case, the error could have occurred during the previous check. In that case, the tokens would already be burned, and we wouldn’t roll back the operation. The balance wouldn’t match.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually if it's a bridged token (the only one we burn) bridge balance would be zero. Meaning if relayer sends an incorrect transaction the tokens will be burned and the refund to the relayer will fail. There will be no loss of funds from the bridge perspective, right? @karim-en

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests and a fix here 88a9781
Not sure if more elegant solution is possible

near/omni-bridge/src/lib.rs Show resolved Hide resolved
.add_transfer_message(transfer_message, relayer_id.clone())
.saturating_add(required_balance);

self.update_storage_balance(relayer_id, required_balance, env::attached_deposit());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env::attached_deposit() -- this value isn’t always zero, is it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to 0

near/omni-bridge/src/lib.rs Outdated Show resolved Hide resolved
token_id: token_id.clone(),
recipient: fast_fin_transfer_msg.recipient.clone(),
amount: U128(amount.0 + fast_fin_transfer_msg.fee.fee.0),
fee: fast_fin_transfer_msg.fee,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who will ultimately receive the native fee? The Fast Transfer Relayer or the General Relayer? Are we at risk of spending the same funds twice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If recipient is on Near, the fast relayer will get the fee after finalizing the original transfer.
If recipient is on another chain, it's a bit tricky. The fee is claimed only after transfer is finalized on the 3rd chain. And right now it can be another relayer. Perhaps we need to split the fee between the two relayers in this scenario @karim-en ?

Copy link
Contributor Author

@kiseln kiseln Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can allow sign_transfer only for the fast relayer in this case

Edit: but then fast relayer will be able to finalize the original transfer while keeping the 3rd-chain message unsigned. Need to think more

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, in case recipient is on another chain we need a solution for both normal and fast transfers. So perhaps it's not quite in scope of this PR. As of now, the relayer that finalizes the transfer on the destination chain will receive the fees

near/omni-bridge/src/lib.rs Show resolved Hide resolved
@olga24912
Copy link
Contributor

I might also consider adding an option to opt out of fast-transfer.

)
.then(
Self::ext(env::current_account_id())
.with_static_gas(VERIFY_PROOF_CALLBACK_GAS)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The const VERIFY_PROOF_CALLBACK_GAS isn't correct

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 1f38b04

@karim-en
Copy link
Collaborator

karim-en commented Dec 11, 2024

@kiseln Please add integration tests to this new feature

@kiseln
Copy link
Contributor Author

kiseln commented Dec 12, 2024

@kiseln Please add integration tests to this new feature

118a333

@kiseln kiseln requested a review from karim-en December 12, 2024 07:39
@@ -875,6 +1022,14 @@ impl Contract {
pub fn get_current_destination_nonce(&self, chain_kind: ChainKind) -> Nonce {
self.destination_nonces.get(&chain_kind).unwrap_or_default()
}

#[private]
pub fn ft_resolve_transfer(&mut self, amount: U128) -> U128 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming could be confusing (in the explorer) because the nep141 implementation also has a ft_resolve_transfer

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed 1f38b04

pub fn ft_resolve_transfer(&mut self, amount: U128) -> U128 {
match env::promise_result(0) {
PromiseResult::Successful(_) => U128(0),
PromiseResult::Failed => amount,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refund is dangerous due to the undetermined behavior of the ft_trnasfer_call, so let's always return zero.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, just to confirm, if ft_transfer_call reverts it's sender who will lose money and not the relayer, right? So senders will be responsible for the target of ft_transfer_call

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to 0 1f38b04

Copy link
Collaborator

@karim-en karim-en Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.
In the follow-up PR, we can try to solve the refund issue, which was mentioned multiple times in the audits.
What we should take into account is:

  • The ft_transfer_call can fail only if the storage wasn't deposited or due to malicious contract, otherwise it returns value.
  • The mint call ft_transfer_call if there is a message, so it has the same behaviour.
  • The ft_transfer can fail only if the storage wasn't deposited.

Since we already verify the storage deposit, then we can do the refund only if the ft_transfer_call returned a refund value.

@kiseln kiseln requested review from olga24912 and karim-en December 13, 2024 13:33
)
.then(
Self::ext(env::current_account_id())
.with_static_gas(FAST_TRANSFER_CALLBACK_GAS)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.with_static_gas(FAST_TRANSFER_CALLBACK_GAS)
.with_static_gas(FAST_TRANSFER_CALLBACK_GAS + FT_TRANSFER_CALL_GAS)

This could be not enough to do the ft_transfer_call in the callback

Can you please also add tests for this flow (failed and success)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 88a9781

@@ -55,6 +56,8 @@ const DEPLOY_TOKEN_GAS: Gas = Gas::from_tgas(50);
const BURN_TOKEN_GAS: Gas = Gas::from_tgas(10);
const MINT_TOKEN_GAS: Gas = Gas::from_tgas(5);
const SET_METADATA_GAS: Gas = Gas::from_tgas(10);
const RESOLVE_TRANSFER_GAS: Gas = Gas::from_tgas(3);
const FAST_TRANSFER_CALLBACK_GAS: Gas = Gas::from_tgas(40);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be reduced if the FT_TRANSFER_CALL_GAS has been added

.then(
Self::ext(env::current_account_id())
.with_static_gas(RESOLVE_TRANSFER_GAS)
.resolve_transfer(fast_transfer.amount),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.resolve_transfer(fast_transfer.amount),
.resolve_transfer(fast_transfer.amount.0 - fast_transfer.fee.fee.0),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we ever use the amount in resolve_transfer it will be for the purpose of rolling back ft_transfer_call so full amount is more appropriate

token: OmniAddress::Near(fast_transfer.token_id.clone()),
amount: fast_transfer.amount.clone(),
recipient: fast_transfer.recipient.clone(),
fee: fast_transfer.fee.clone(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about creating an invalid TransferMessage with a very very large fee? Finalizing it on the destination chain, and then claiming our excessively large fee. In that case, a lot of Ether, for example, would be minted for the relayer without reason.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic find - this is a severe problem that we need to address. Also, replacing sender to the relayer's Near address we accidentally change the native fee from another token to NEAR.

First, we need to allow claiming fee only after the original message is finalized and fees validated.
Second, we either need to preserve original sender when creating message or look it up in some other way when claiming the fee.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added restriction on claiming fee in 333be17

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants