Skip to content

Commit b9dd893

Browse files
authored
fix[l-01]: allow partial rent debt repayment (#76)
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
1 parent d2413d7 commit b9dd893

File tree

5 files changed

+135
-21
lines changed

5 files changed

+135
-21
lines changed

programs/sponsored-cctp-src-periphery/src/error.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ pub enum SvmError {
3434
MissingRentClaimAccount,
3535
#[msg("Rent claim amount overflow")]
3636
RentClaimOverflow,
37-
#[msg("Insufficient rent fund balance")]
38-
InsufficientRentFundBalance,
3937
#[msg("Invalid recipient key")]
4038
InvalidRecipientKey,
4139
}

programs/sponsored-cctp-src-periphery/src/event.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,5 @@ pub struct AccruedRentFundLiability {
5757
pub struct RepaidRentFundDebt {
5858
pub user: Pubkey,
5959
pub amount: u64,
60+
pub remaining_user_claim: u64,
6061
}

programs/sponsored-cctp-src-periphery/src/instructions/deposit.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ pub fn deposit_for_burn(mut ctx: Context<DepositForBurn>, params: &DepositForBur
186186

187187
emit_cpi!(CreatedEventAccount { message_sent_event_data: ctx.accounts.message_sent_event_data.key() });
188188

189+
// Close the claim account if the user passed Some rent_claim account without accruing any rent_fund debt.
190+
if let Some(rent_claim) = &ctx.accounts.rent_claim {
191+
if rent_claim.amount == 0 {
192+
rent_claim.close(ctx.accounts.signer.to_account_info())?;
193+
}
194+
}
195+
189196
Ok(())
190197
}
191198

programs/sponsored-cctp-src-periphery/src/instructions/maintenance.rs

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,7 @@ pub struct RepayRentFundDebt<'info> {
1919
#[account(mut)]
2020
pub recipient: UncheckedAccount<'info>,
2121

22-
#[account(
23-
mut,
24-
close = recipient,
25-
seeds = [b"rent_claim", recipient.key().as_ref()],
26-
bump
27-
)]
22+
#[account(mut, seeds = [b"rent_claim", recipient.key().as_ref()], bump)]
2823
pub rent_claim: Account<'info, RentClaim>,
2924

3025
pub system_program: Program<'info, System>,
@@ -33,21 +28,18 @@ pub struct RepayRentFundDebt<'info> {
3328
pub fn repay_rent_fund_debt(ctx: Context<RepayRentFundDebt>) -> Result<()> {
3429
let anchor_rent = Rent::get()?;
3530

36-
// Debt amount might be zero if user had passed Some rent_claim account without accruing any rent_fund debt in the
37-
// deposit. Exit early in this case so that rent_claim account can be closed.
38-
let amount = ctx.accounts.rent_claim.amount;
39-
if amount == 0 {
40-
return Ok(());
41-
}
31+
let rent_claim = &mut ctx.accounts.rent_claim;
4232

43-
// Check if rent fund has enough balance to repay the debt and remain rent-exempt.
33+
// Check if rent fund has enough balance to repay any non-zero debt and remain rent-exempt.
4434
let max_repay = ctx
4535
.accounts
4636
.rent_fund
4737
.lamports()
4838
.saturating_sub(anchor_rent.minimum_balance(0));
49-
if max_repay < amount {
50-
return err!(SvmError::InsufficientRentFundBalance);
39+
let repay_amount = rent_claim.amount.min(max_repay);
40+
if repay_amount == 0 {
41+
// Deposit instruction closes rent_claim account with zero debt, so return early if cannot repay any part of it.
42+
return Ok(());
5143
}
5244

5345
let cpi_accounts = system_program::Transfer {
@@ -57,9 +49,21 @@ pub fn repay_rent_fund_debt(ctx: Context<RepayRentFundDebt>) -> Result<()> {
5749
let rent_fund_seeds: &[&[&[u8]]] = &[&[b"rent_fund", &[ctx.bumps.rent_fund]]];
5850
let cpi_context =
5951
CpiContext::new_with_signer(ctx.accounts.system_program.to_account_info(), cpi_accounts, rent_fund_seeds);
60-
system_program::transfer(cpi_context, amount)?;
52+
system_program::transfer(cpi_context, repay_amount)?;
6153

62-
emit_cpi!(RepaidRentFundDebt { user: ctx.accounts.recipient.key(), amount });
54+
// Update the remaining debt, safe to subtract repay_amount as it is guaranteed to be <= rent_claim.amount.
55+
rent_claim.amount -= repay_amount;
56+
57+
emit_cpi!(RepaidRentFundDebt {
58+
user: ctx.accounts.recipient.key(),
59+
amount: repay_amount,
60+
remaining_user_claim: rent_claim.amount,
61+
});
62+
63+
// Close the claim account if the debt is fully repaid.
64+
if rent_claim.amount == 0 {
65+
rent_claim.close(ctx.accounts.recipient.to_account_info())?;
66+
}
6367

6468
Ok(())
6569
}

test/svm/SponsoredCctpSrc.Deposit.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -800,8 +800,8 @@ describe("sponsored_cctp_src_periphery.deposit", () => {
800800
messageSentEventData,
801801
]);
802802

803-
let rentClaimAccount = await program.account.rentClaim.fetch(rentClaim);
804-
assert.isTrue(rentClaimAccount.amount.eq(new BN(0)), "No debt should be accrued when rent_fund had funding");
803+
let rentClaimAccount = await program.account.rentClaim.fetchNullable(rentClaim);
804+
assert.isNull(rentClaimAccount, "No debt should be accrued and account closed when rent_fund had funding");
805805

806806
// Withdraw all rent_fund balance to test debt accrual.
807807
let rentFundBalance = await connection.getBalance(rentFund);
@@ -849,4 +849,108 @@ describe("sponsored_cctp_src_periphery.deposit", () => {
849849
);
850850
assert.isNull(await program.account.rentClaim.fetchNullable(rentClaim), "Rent claim account should be closed");
851851
});
852+
853+
it("Accrue and partially repay rent_fund debt", async () => {
854+
// Withdraw all rent_fund balance to test debt accrual.
855+
let rentFundBalance = await connection.getBalance(rentFund);
856+
await program.methods
857+
.withdrawRentFund({ amount: new BN(rentFundBalance.toString()) })
858+
.accounts({
859+
recipient: owner,
860+
programData,
861+
})
862+
.rpc();
863+
864+
[rentClaim] = PublicKey.findProgramAddressSync(
865+
[Buffer.from("rent_claim"), depositor.publicKey.toBuffer()],
866+
program.programId
867+
);
868+
let nonce = crypto.randomBytes(32);
869+
const deadline = ethers.BigNumber.from(Math.floor(Date.now() / 1000) + 3600);
870+
871+
const quoteData: SponsoredCCTPQuote = {
872+
sourceDomain,
873+
destinationDomain: remoteDomain.toNumber(),
874+
mintRecipient: ethers.utils.hexlify(mintRecipient),
875+
amount: burnAmount,
876+
burnToken: ethers.utils.hexlify(burnToken.toBuffer()),
877+
destinationCaller: ethers.utils.hexlify(destinationCaller),
878+
maxFee,
879+
minFinalityThreshold,
880+
nonce: ethers.utils.hexlify(nonce),
881+
deadline,
882+
maxBpsToSponsor,
883+
maxUserSlippageBps,
884+
finalRecipient: ethers.utils.hexlify(finalRecipient),
885+
finalToken: ethers.utils.hexlify(finalToken),
886+
executionMode,
887+
actionData,
888+
};
889+
let { quote, signature } = getEncodedQuoteWithSignature(quoteSigner, quoteData);
890+
891+
const depositAccounts = {
892+
signer: depositor.publicKey,
893+
payer: depositor.publicKey,
894+
state,
895+
rentFund,
896+
usedNonce: getUsedNonce(nonce),
897+
rentClaim,
898+
depositorTokenAccount,
899+
burnToken,
900+
denylistAccount,
901+
tokenMessengerMinterSenderAuthority,
902+
messageTransmitter,
903+
tokenMessenger,
904+
remoteTokenMessenger,
905+
tokenMinter,
906+
localToken,
907+
cctpEventAuthority,
908+
tokenProgram,
909+
messageSentEventData: messageSentEventData.publicKey,
910+
program: program.programId,
911+
};
912+
913+
let depositIx = await program.methods.depositForBurn({ quote, signature }).accounts(depositAccounts).instruction();
914+
await sendTransactionWithExistingLookupTable(connection, [depositIx], lookupTableAccount, depositor, [
915+
messageSentEventData,
916+
]);
917+
918+
let rentClaimAccount = await program.account.rentClaim.fetch(rentClaim);
919+
const usedNonceBalance = await connection.getBalance(depositAccounts.usedNonce);
920+
const messageSentEventDataBalance = await connection.getBalance(depositAccounts.messageSentEventData);
921+
rentFundBalance = await connection.getBalance(rentFund);
922+
const fullClaimAmount = new BN(usedNonceBalance + messageSentEventDataBalance + rentFundBalance);
923+
assert.isTrue(
924+
rentClaimAccount.amount.eq(fullClaimAmount),
925+
"Rent claim should have accrued debt for account creation"
926+
);
927+
928+
// Without funding rent claim account should keep the debt.
929+
await program.methods
930+
.repayRentFundDebt()
931+
.accounts({ recipient: depositor.publicKey, program: program.programId })
932+
.rpc();
933+
rentClaimAccount = await program.account.rentClaim.fetch(rentClaim);
934+
assert.isTrue(rentClaimAccount.amount.eq(fullClaimAmount), "Rent claim should not been repaid");
935+
936+
// Test partial repayment of 1 lamport (rent_fund should already hold its minimum rent-free balance)
937+
const partialRepayment = 1;
938+
await requestAndConfirmAirdrop(connection, rentFund, partialRepayment);
939+
const userBalanceBefore = await connection.getBalance(depositor.publicKey);
940+
await program.methods
941+
.repayRentFundDebt()
942+
.accounts({ recipient: depositor.publicKey, program: program.programId })
943+
.rpc();
944+
const userBalanceAfter = await connection.getBalance(depositor.publicKey);
945+
assert.strictEqual(
946+
userBalanceAfter - userBalanceBefore,
947+
partialRepayment,
948+
"User should have been refunded only part of the claim"
949+
);
950+
rentClaimAccount = await program.account.rentClaim.fetch(rentClaim);
951+
assert.isTrue(
952+
rentClaimAccount.amount.eq(fullClaimAmount.sub(new BN(partialRepayment))),
953+
"Rent claim should have been partially repaid"
954+
);
955+
});
852956
});

0 commit comments

Comments
 (0)