Skip to content

Conversation

@Reinis-FRP
Copy link
Contributor

@Reinis-FRP Reinis-FRP commented Oct 7, 2024

This fix protects against a relayer passing invalid refund token account to block the whole relayer leaf execution. If a relayer had provided invalid ATA for refunds, the leaf executor can replace it with a claim account PDA that is derived from mint and relayer's token account to only accrue given relayer's refund claims. If this was a valid token address, but was closed at the time of leaf execution, all accrued claims can be refunded separately whenever the given refund account is created.

For the sake of code simplicity, this does not attempt to initialize a claim account when executing the relayer refund leaf. The operator is expected to initialize it in advance, but this is required only when a given relayer's token account is invalid.

Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP Reinis-FRP marked this pull request as ready for review October 8, 2024 13:14
@Reinis-FRP Reinis-FRP requested review from chrismaree and md0x October 8, 2024 13:14
pub amount: u64,
}

// When executing relayer refund leaf, refund accounts are passed as remaining accounts and can hold either a regular
Copy link
Member

Choose a reason for hiding this comment

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

does this mean that, in theory, a relayer could close their ATA right before the bundle execution tx lands on-chain, thereby making the execution of the bundle in-valid as the remaining account type is wrong? so when the executor looks at the tx to send they build TokenAccount, expecting that the relayer has an ATA but before the tx lands on-chain the relayer can front run the tx and delete their ATA, which then requires, for this method to work, that the executor passes in the ClaimAccount, not token account, which breaks the bundle execution and the whole thing reverts?

In theory, this means that a relayer who can front run the execution can always "toggle" the status of their ATA (opening it or closing it), thereby blocking the bundle execution?

if the executor sends these execution transactions via private mempool they might be able to protect themselves (you cant close/open the account before the execution) but this is not going to be failsafe?

thoughts on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In theory that's possible, but might be a bigger effort as there is no public mempool, so the attacker has to run Solana validator co-located close to our bot. We can reduce attack surface by sending the transaction to trusted validator. And if this is still an issue, we can fall-back using claim accounts after couple of reverts.

Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP Reinis-FRP requested a review from chrismaree October 18, 2024 10:20
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Comment on lines +112 to +114
where
'c: 'info,
{
Copy link
Member

Choose a reason for hiding this comment

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

can you help me understand this bit of syntax a bit better? I also see it in the lib.rs.

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 the constraint that lifetime for remaining accounts ('c) is at least as long as 'info for accounts that are referenced by this account array. I think its needed because of manual remaining account processing in try_from_remaining_account method. Without these lifetime constraints the compiler errors.

Copy link
Member

Choose a reason for hiding this comment

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

o that's interesting. ye, I've had other lifetime issues while implementing other things and was able to address it through other syntax but this is more complex here.

@@ -0,0 +1,132 @@
use anchor_lang::prelude::*;
Copy link
Member

Choose a reason for hiding this comment

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

nit on this file naming: we call the instruction refund_claims and the instruction refund_accounts. we should think about it makes sense to re-name this state to claim_account? equally, for other files between instruction & state having a consistent name between the two.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

RefundAccount can hold either TokenAccount or ClaimAccount and state/refund_account.rs has methods for handling both, so would not want to name it as claim_account. Also the wrapped RefundAccount is used in bundle instruction while individual ClaimAccount is used in refund_claims instruction, so there is no 1-1 relationship between state and instructions like we have in other places.

Comment on lines +101 to +106
// #[account(
// mut,
// seeds = [b"claim_account", mint.key().as_ref(), token_account.key().as_ref()],
// bump
// )]
// pub claim_account: Account<'info, ClaimAccount>,
Copy link
Member

Choose a reason for hiding this comment

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

is this meant to be commented out?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it is meant to document what Anchor macros this method is reimplementing, so its easier to audit knowing the intention of the implementation.

describe("Execute Max Refunds", () => {
const executeMaxRefunds = async (refundType: RefundType) => {
const relayerRefundLeaves: RelayerRefundLeafType[] = [];
// Higher refund count hits inner instruction size limit when doing `emit_cpi` on public devnet. On localnet this is
Copy link
Member

Choose a reason for hiding this comment

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

to test this on testnet did you use the script?

Copy link
Member

Choose a reason for hiding this comment

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

it would be wise I think for us to run this same test on mainnet to ensure that there are no other memory issues that we don't know about on this. does mean deploying the whole program, but that's ok as we can always close it afterwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, here is the refund execution for 28 relayers. At 29 count would get this error: Instruction passed to inner instruction is too large (1306 > 1280) when doing emit_cpi.

Though, we might need to improve the script as in some runs it closed the instruction parameter account before leaf execution tx got mined.

});
};

it("Execute Max Refunds to Token Accounts", async () => {
Copy link
Member

Choose a reason for hiding this comment

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

this is cool. nice structure within the describe. these tests are now...complex, but that's ok.

Comment on lines +59 to +65
// #[account(
// mut,
// address = expected_token_account @ CustomError::InvalidRefund,
// token::mint = expected_mint,
// token::token_program = token_program
// )]
// pub token_account: InterfaceAccount<'info, TokenAccount>,
Copy link
Member

Choose a reason for hiding this comment

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

is this mean to be commented out?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, left them for auditing purposes as we reimplement what Anchor macro would have done on static account.

// Emit the DeferredRelayerRefunds event if any claim accounts were passed instead of token accounts. We cannot emit
// individual accounts and amounts as that would run out of memory for larger leaves.
if deferred_refunds {
emit_cpi!(DeferredRelayerRefunds {
Copy link
Member

Choose a reason for hiding this comment

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

I think it's ok to emit all this but it's not really needed, right? we really just want to know that it happened, but care less about all the other props give we have ExecutedRelayerRefundRoot.

an alternative structure could simply be to add a bool to ExecutedRelayerRefundRoot that indicates if deferred_refunds is set so we don't need a. new event?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, if we can change interface of ExecutedRelayerRefundRoot this would be much easier to avoid separate emit_cpi

Copy link
Contributor Author

Choose a reason for hiding this comment

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

pushed the commit to include the bool in ExecutedRelayerRefundRoot

}

// Checks if the token account is associated with the expected mint.
if &token_account.mint != expected_mint {
Copy link
Member

Choose a reason for hiding this comment

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

this is a meta-comment but this check has a & (borrowing) and the others don't. expected_token_account is passed in & but is not checked against the account_info with borrowing.

point is: is there away to do this more consistently in this file and beyond?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

not sure. owner and key in AccountInfo are borrowed references, while InterfaceAccount::try_from returns token_account with all of its properties owned. That's why we need to handle them differently. And since compiler automatically dereferences I think its more common pattern not to do explicit dereferencing, instead pass borrowed variables to comparison and let the compiler dereference them where needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but you're right that its not fully consistent with comparison in try_claim_account_from_account_info below, so I just pushed commit to use account_info.key != &pda_address instead of account_info.key() != pda_address on line 121.

Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP Reinis-FRP requested a review from chrismaree October 18, 2024 15:53
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP Reinis-FRP merged commit 503332f into master Oct 21, 2024
8 of 9 checks passed
@Reinis-FRP Reinis-FRP deleted the reinis-frp/refund-claims branch October 21, 2024 08:52
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