diff --git a/chains/solana/programs/train-htlc/src/instructions/redeem.rs b/chains/solana/programs/train-htlc/src/instructions/redeem.rs index 4424821..4b98251 100644 --- a/chains/solana/programs/train-htlc/src/instructions/redeem.rs +++ b/chains/solana/programs/train-htlc/src/instructions/redeem.rs @@ -841,3 +841,192 @@ pub struct RedeemSolverTokenDiffReward<'info> { pub system_program: Program<'info, System>, pub rent: Sysvar<'info, Rent>, } + +// ── RedeemSolver: SPL token principal + native SOL reward ────────────────────── + +pub fn redeem_solver_token_native_reward( + ctx: Context, + hashlock: [u8; 32], + index: u64, + secret: [u8; 32], +) -> Result<()> { + utils::verify_hashlock(&secret, &hashlock)?; + let now = Clock::get()?.unix_timestamp as u64; + + let lock = &mut ctx.accounts.solver_lock; + lock.status = STATUS_REDEEMED; + lock.secret = secret; + let amount = lock.amount; + let reward = lock.reward; + let reward_timelock = lock.reward_timelock; + let payout_curve = lock.payout_curve; + let start_time = lock.start_time; + let curve_data = lock.payout_curve_data.clone(); + + let curve_account = ctx + .accounts + .payout_curve_program + .as_ref() + .map(|a| a.to_account_info()); + let (payout, excess) = utils::compute_payout_checked( + payout_curve, + curve_account.as_ref(), + amount, + start_time, + now, + &curve_data, + )?; + + let index_bytes = index.to_le_bytes(); + let bump = ctx.bumps.solver_lock; + let signer_seeds: &[&[&[u8]]] = + &[&[b"solver_lock", hashlock.as_ref(), index_bytes.as_ref(), &[bump]]]; + + utils::transfer_from_vault( + ctx.accounts.vault.to_account_info(), + ctx.accounts.recipient_token_account.to_account_info(), + ctx.accounts.token_mint.to_account_info(), + ctx.accounts.solver_lock.to_account_info(), + ctx.accounts.token_program.to_account_info(), + signer_seeds, + payout, + ctx.accounts.token_mint.decimals, + )?; + + if excess > 0 { + let refund_to_ata = ctx + .accounts + .refund_to_token_account + .as_ref() + .ok_or(TrainError::WrongRefundTo)?; + utils::transfer_from_vault( + ctx.accounts.vault.to_account_info(), + refund_to_ata.to_account_info(), + ctx.accounts.token_mint.to_account_info(), + ctx.accounts.solver_lock.to_account_info(), + ctx.accounts.token_program.to_account_info(), + signer_seeds, + excess, + ctx.accounts.token_mint.decimals, + )?; + } + + utils::close_vault_if_empty( + &mut ctx.accounts.vault, + ctx.accounts.rent_payer.to_account_info(), + ctx.accounts.solver_lock.to_account_info(), + ctx.accounts.token_program.to_account_info(), + signer_seeds, + )?; + + let reward_to = if reward > 0 { + ctx.accounts.solver_lock.sub_lamports(reward)?; + if now < reward_timelock { + ctx.accounts.reward_recipient.add_lamports(reward)?; + ctx.accounts.reward_recipient.key() + } else { + ctx.accounts.caller.add_lamports(reward)?; + ctx.accounts.caller.key() + } + } else { + Pubkey::default() + }; + + emit!(SolverRedeemed { + hashlock, + index, + redeemer: ctx.accounts.caller.key(), + secret, + payout, + excess, + reward_to, + reward, + }); + Ok(()) +} + +#[derive(Accounts)] +#[instruction(hashlock: [u8; 32], index: u64)] +pub struct RedeemSolverTokenNativeReward<'info> { + #[account(mut)] + pub caller: Signer<'info>, + + #[account( + mut, + seeds = [b"solver_lock", hashlock.as_ref(), &index.to_le_bytes()], + bump, + constraint = solver_lock.status == STATUS_PENDING @ TrainError::NotPending, + constraint = solver_lock.token_mint != Pubkey::default() @ TrainError::WrongToken, + constraint = solver_lock.reward_token_mint == Pubkey::default() @ TrainError::WrongToken, + )] + pub solver_lock: Box>, + + /// CHECK: rent destination for the emptied vault, verified via + /// solver_lock.rent_payer. + #[account( + mut, + constraint = rent_payer.key() == solver_lock.rent_payer @ TrainError::WrongRentPayer, + )] + pub rent_payer: UncheckedAccount<'info>, + + /// CHECK: verified via solver_lock.recipient. + #[account( + constraint = recipient.key() == solver_lock.recipient @ TrainError::WrongRecipient, + )] + pub recipient: UncheckedAccount<'info>, + + /// CHECK: receives native SOL before reward timelock and is verified against + /// the stored reward recipient. + #[account( + mut, + constraint = solver_lock.reward == 0 + || reward_recipient.key() == solver_lock.reward_recipient + @ TrainError::WrongRecipient, + )] + pub reward_recipient: UncheckedAccount<'info>, + + /// CHECK: verified via solver_lock.refund_to (curve excess authority). + #[account( + constraint = refund_to.key() == solver_lock.refund_to @ TrainError::WrongRefundTo, + )] + pub refund_to: UncheckedAccount<'info>, + + #[account( + constraint = token_mint.key() == solver_lock.token_mint @ TrainError::WrongToken, + )] + pub token_mint: Box>, + + #[account( + mut, + seeds = [b"solver_vault", hashlock.as_ref(), &index.to_le_bytes()], + bump, + )] + pub vault: Box>, + + #[account( + init_if_needed, + payer = caller, + associated_token::mint = token_mint, + associated_token::authority = recipient, + associated_token::token_program = token_program, + )] + pub recipient_token_account: Box>, + + /// Required only when the lock has a payout curve (receives the excess). + #[account( + init_if_needed, + payer = caller, + associated_token::mint = token_mint, + associated_token::authority = refund_to, + associated_token::token_program = token_program, + )] + pub refund_to_token_account: Option>>, + + /// CHECK: payout curve program; validated in the handler. + pub payout_curve_program: Option>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, +} diff --git a/chains/solana/programs/train-htlc/src/instructions/refund.rs b/chains/solana/programs/train-htlc/src/instructions/refund.rs index 3651240..3f54230 100644 --- a/chains/solana/programs/train-htlc/src/instructions/refund.rs +++ b/chains/solana/programs/train-htlc/src/instructions/refund.rs @@ -483,3 +483,115 @@ pub struct RefundSolverTokenDiffReward<'info> { pub system_program: Program<'info, System>, pub rent: Sysvar<'info, Rent>, } + +// ── RefundSolver: SPL token principal + native SOL reward ────────────────────── + +pub fn refund_solver_token_native_reward( + ctx: Context, + hashlock: [u8; 32], + index: u64, +) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + let lock = &mut ctx.accounts.solver_lock; + + require!(now >= lock.timelock, TrainError::TimelockNotExpired); + + lock.status = STATUS_REFUNDED; + let amount = lock.amount; + let reward = lock.reward; + let refund_to = lock.refund_to; + let index_bytes = index.to_le_bytes(); + let bump = ctx.bumps.solver_lock; + let signer_seeds: &[&[&[u8]]] = + &[&[b"solver_lock", hashlock.as_ref(), index_bytes.as_ref(), &[bump]]]; + + utils::transfer_from_vault( + ctx.accounts.vault.to_account_info(), + ctx.accounts.refund_to_token_account.to_account_info(), + ctx.accounts.token_mint.to_account_info(), + ctx.accounts.solver_lock.to_account_info(), + ctx.accounts.token_program.to_account_info(), + signer_seeds, + amount, + ctx.accounts.token_mint.decimals, + )?; + utils::close_vault_if_empty( + &mut ctx.accounts.vault, + ctx.accounts.rent_payer.to_account_info(), + ctx.accounts.solver_lock.to_account_info(), + ctx.accounts.token_program.to_account_info(), + signer_seeds, + )?; + + if reward > 0 { + ctx.accounts.solver_lock.sub_lamports(reward)?; + ctx.accounts.refund_to.add_lamports(reward)?; + } + + emit!(SolverRefunded { + hashlock, + index, + refund_to, + amount, + reward, + }); + Ok(()) +} + +#[derive(Accounts)] +#[instruction(hashlock: [u8; 32], index: u64)] +pub struct RefundSolverTokenNativeReward<'info> { + #[account(mut)] + pub caller: Signer<'info>, + + #[account( + mut, + seeds = [b"solver_lock", hashlock.as_ref(), &index.to_le_bytes()], + bump, + constraint = solver_lock.status == STATUS_PENDING @ TrainError::NotPending, + constraint = solver_lock.token_mint != Pubkey::default() @ TrainError::WrongToken, + constraint = solver_lock.reward_token_mint == Pubkey::default() @ TrainError::WrongToken, + )] + pub solver_lock: Box>, + + /// CHECK: rent destination for the emptied vault, verified via + /// solver_lock.rent_payer. + #[account( + mut, + constraint = rent_payer.key() == solver_lock.rent_payer @ TrainError::WrongRentPayer, + )] + pub rent_payer: UncheckedAccount<'info>, + + /// CHECK: verified via solver_lock.refund_to and receives native SOL. + #[account( + mut, + constraint = refund_to.key() == solver_lock.refund_to @ TrainError::WrongRefundTo, + )] + pub refund_to: UncheckedAccount<'info>, + + #[account( + constraint = token_mint.key() == solver_lock.token_mint @ TrainError::WrongToken, + )] + pub token_mint: Box>, + + #[account( + mut, + seeds = [b"solver_vault", hashlock.as_ref(), &index.to_le_bytes()], + bump, + )] + pub vault: Box>, + + #[account( + init_if_needed, + payer = caller, + associated_token::mint = token_mint, + associated_token::authority = refund_to, + associated_token::token_program = token_program, + )] + pub refund_to_token_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, +} diff --git a/chains/solana/programs/train-htlc/src/instructions/solver_lock.rs b/chains/solana/programs/train-htlc/src/instructions/solver_lock.rs index b146f8a..75f3d1c 100644 --- a/chains/solana/programs/train-htlc/src/instructions/solver_lock.rs +++ b/chains/solana/programs/train-htlc/src/instructions/solver_lock.rs @@ -505,3 +505,139 @@ pub struct SolverLockTokenDiffReward<'info> { pub system_program: Program<'info, System>, pub rent: Sysvar<'info, Rent>, } + +// ── SolverLock: SPL token amount + native SOL reward ──────────────────────────── + +pub fn solver_lock_token_native_reward( + ctx: Context, + params: SolverLockParams, + data: Vec, +) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + let (timelock, reward_timelock) = validate_solver_lock_params(¶ms, now)?; + check_index(&ctx.accounts.counter, params.index)?; + + utils::validate_mint_extensions(&ctx.accounts.token_mint.to_account_info())?; + let curve_account = ctx + .accounts + .payout_curve_program + .as_ref() + .map(|a| a.to_account_info()); + utils::validate_payout_curve( + params.payout_curve, + curve_account.as_ref(), + ¶ms.payout_curve_data, + params.amount, + now, + )?; + + let actual_amount = utils::transfer_in_measured( + ctx.accounts.sender_token_account.to_account_info(), + &mut ctx.accounts.vault, + ctx.accounts.token_mint.to_account_info(), + ctx.accounts.sender.to_account_info(), + ctx.accounts.token_program.to_account_info(), + &[], + params.amount, + ctx.accounts.token_mint.decimals, + )?; + + if params.reward > 0 { + let cpi_ctx = CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.sender.to_account_info(), + to: ctx.accounts.solver_lock.to_account_info(), + }, + ); + system_program::transfer(cpi_ctx, params.reward)?; + } + + let sender = ctx.accounts.sender.key(); + let rent_payer = ctx.accounts.payer.key(); + let token_mint_key = ctx.accounts.token_mint.key(); + store_solver_lock( + &mut ctx.accounts.solver_lock, + ¶ms, + sender, + rent_payer, + token_mint_key, + Pubkey::default(), + actual_amount, + params.reward, + timelock, + reward_timelock, + now, + ); + ctx.accounts.counter.count = params.index; + + emit_solver_locked( + params, + sender, + token_mint_key, + Pubkey::default(), + actual_amount, + ctx.accounts.solver_lock.reward, + timelock, + reward_timelock, + data, + ); + Ok(()) +} + +#[derive(Accounts)] +#[instruction(params: SolverLockParams)] +pub struct SolverLockTokenNativeReward<'info> { + /// Pays rent and fees; may differ from `sender` in sponsored flows. + #[account(mut)] + pub payer: Signer<'info>, + + /// Funds authority: SPL principal and native SOL reward leave this signer. + #[account(mut)] + pub sender: Signer<'info>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + SolverLockCounter::INIT_SPACE, + seeds = [b"solver_count", params.hashlock.as_ref()], + bump, + )] + pub counter: Box>, + + #[account( + init, + payer = payer, + space = 8 + SolverLock::INIT_SPACE, + seeds = [b"solver_lock", params.hashlock.as_ref(), ¶ms.index.to_le_bytes()], + bump, + )] + pub solver_lock: Box>, + + pub token_mint: Box>, + + #[account( + mut, + constraint = sender_token_account.owner == sender.key() @ TrainError::WrongToken, + constraint = sender_token_account.mint == token_mint.key() @ TrainError::WrongToken, + )] + pub sender_token_account: Box>, + + #[account( + init, + payer = payer, + seeds = [b"solver_vault", params.hashlock.as_ref(), ¶ms.index.to_le_bytes()], + bump, + token::mint = token_mint, + token::authority = solver_lock, + token::token_program = token_program, + )] + pub vault: Box>, + + /// CHECK: payout curve program; validated in the handler. + pub payout_curve_program: Option>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, +} diff --git a/chains/solana/programs/train-htlc/src/lib.rs b/chains/solana/programs/train-htlc/src/lib.rs index 2572885..c96954a 100644 --- a/chains/solana/programs/train-htlc/src/lib.rs +++ b/chains/solana/programs/train-htlc/src/lib.rs @@ -89,6 +89,14 @@ pub mod train_htlc { instructions::solver_lock_token_diff_reward(ctx, params, data) } + pub fn solver_lock_token_native_reward( + ctx: Context, + params: SolverLockParams, + data: Vec, + ) -> Result<()> { + instructions::solver_lock_token_native_reward(ctx, params, data) + } + // ── Gasless intent path ────────────────────────────────────────────────── pub fn initialize_intent_domain( @@ -158,6 +166,15 @@ pub mod train_htlc { instructions::redeem_solver_token_diff_reward(ctx, hashlock, index, secret) } + pub fn redeem_solver_token_native_reward( + ctx: Context, + hashlock: [u8; 32], + index: u64, + secret: [u8; 32], + ) -> Result<()> { + instructions::redeem_solver_token_native_reward(ctx, hashlock, index, secret) + } + // ── Refunds ────────────────────────────────────────────────────────────── pub fn refund_user_sol(ctx: Context, hashlock: [u8; 32]) -> Result<()> { @@ -192,6 +209,14 @@ pub mod train_htlc { instructions::refund_solver_token_diff_reward(ctx, hashlock, index) } + pub fn refund_solver_token_native_reward( + ctx: Context, + hashlock: [u8; 32], + index: u64, + ) -> Result<()> { + instructions::refund_solver_token_native_reward(ctx, hashlock, index) + } + // ── Rent reclamation ───────────────────────────────────────────────────── pub fn close_solver_lock( diff --git a/chains/solana/tests/train-htlc.ts b/chains/solana/tests/train-htlc.ts index 021f7be..ef1cb03 100644 --- a/chains/solana/tests/train-htlc.ts +++ b/chains/solana/tests/train-htlc.ts @@ -165,6 +165,23 @@ describe("train-htlc core", () => { rent: SYSVAR_RENT_PUBKEY, }); + const solverLockTokenNativeRewardAccounts = ( + hashlock: number[], + index: number + ) => ({ + payer: signer.publicKey, + sender: signer.publicKey, + counter: deriveSolverCount(programId, hashlock)[0], + solverLock: deriveSolverLock(programId, hashlock, index)[0], + tokenMint: mintA, + senderTokenAccount: signerAtaA, + vault: deriveSolverVault(programId, hashlock, index)[0], + payoutCurveProgram: null, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + }); + const redeemSolverSolAccounts = ( hashlock: number[], index: number, @@ -967,6 +984,48 @@ describe("train-htlc core", () => { }); }); + describe("Solver Lock Token Native Reward", () => { + it("atomically escrows SPL principal and native SOL reward", async () => { + const { hashlock } = generateHashlock(); + const amount = 200_000; + const reward = 700_000; + const lockPda = deriveSolverLock(programId, hashlock, 1)[0]; + + await program.methods + .solverLockTokenNativeReward( + solverLockParams({ + hashlock, + index: 1, + amount, + reward, + rewardTimelockDelta: 1800, + recipient: recipient.publicKey, + rewardRecipient: rewardRecipient.publicKey, + refundTo: refundTo.publicKey, + }), + Buffer.from([]) + ) + .accounts(solverLockTokenNativeRewardAccounts(hashlock, 1) as any) + .rpc(); + + const lock = await (program.account as any).solverLock.fetch(lockPda); + const vault = await getAccount( + provider.connection, + deriveSolverVault(programId, hashlock, 1)[0] + ); + const rent = await provider.connection.getMinimumBalanceForRentExemption( + (await provider.connection.getAccountInfo(lockPda))!.data.length + ); + + expect(Number(vault.amount)).to.equal(amount); + expect(lock.reward.toNumber()).to.equal(reward); + expect(lock.rewardTokenMint.toBase58()).to.equal( + SystemProgram.programId.toBase58() + ); + expect(await lamports(lockPda)).to.equal(rent + reward); + }); + }); + // ═══════════════════════════════ Redeem Solver ═══════════════════════════════ describe("Redeem Solver SOL", () => { @@ -1192,6 +1251,67 @@ describe("train-htlc core", () => { }); }); + describe("Redeem Solver Token Native Reward", () => { + it("pays SPL principal and native SOL reward atomically", async () => { + const { secret, hashlock } = generateHashlock(); + const amount = 200_000; + const reward = 700_000; + await program.methods + .solverLockTokenNativeReward( + solverLockParams({ + hashlock, + index: 1, + amount, + reward, + rewardTimelockDelta: 1800, + recipient: recipient.publicKey, + rewardRecipient: rewardRecipient.publicKey, + refundTo: refundTo.publicKey, + }), + Buffer.from([]) + ) + .accounts(solverLockTokenNativeRewardAccounts(hashlock, 1) as any) + .rpc(); + + const rewardBefore = await lamports(rewardRecipient.publicKey); + const recipientAta = ata(mintA, recipient.publicKey); + const principalBefore = Number( + (await provider.connection.getAccountInfo(recipientAta)) + ? (await getAccount(provider.connection, recipientAta)).amount + : 0 + ); + + await program.methods + .redeemSolverTokenNativeReward(hashlock, new BN(1), secret) + .accounts({ + caller: signer.publicKey, + solverLock: deriveSolverLock(programId, hashlock, 1)[0], + rentPayer: signer.publicKey, + recipient: recipient.publicKey, + rewardRecipient: rewardRecipient.publicKey, + refundTo: refundTo.publicKey, + tokenMint: mintA, + vault: deriveSolverVault(programId, hashlock, 1)[0], + recipientTokenAccount: recipientAta, + refundToTokenAccount: null, + payoutCurveProgram: null, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + } as any) + .rpc(); + + const principalAfter = Number( + (await getAccount(provider.connection, recipientAta)).amount + ); + expect(principalAfter - principalBefore).to.equal(amount); + expect( + (await lamports(rewardRecipient.publicKey)) - rewardBefore + ).to.equal(reward); + }); + }); + // ═══════════════════════════════ Refund Solver ═══════════════════════════════ describe("Refund Solver", () => { @@ -1358,6 +1478,61 @@ describe("train-htlc core", () => { ); expect(lock.status).to.equal(STATUS_REFUNDED); }); + + it("Native reward: refunds SPL principal and SOL to refund_to", async () => { + const { hashlock } = generateHashlock(); + const amount = 90_000; + const reward = 700_000; + await program.methods + .solverLockTokenNativeReward( + solverLockParams({ + hashlock, + index: 1, + amount, + reward, + timelockDelta: 2, + rewardTimelockDelta: 1, + recipient: recipient.publicKey, + rewardRecipient: rewardRecipient.publicKey, + refundTo: refundTo.publicKey, + }), + Buffer.from([]) + ) + .accounts(solverLockTokenNativeRewardAccounts(hashlock, 1) as any) + .rpc(); + await sleep(3500); + + const refundAta = ata(mintA, refundTo.publicKey); + const tokenBefore = Number( + (await provider.connection.getAccountInfo(refundAta)) + ? (await getAccount(provider.connection, refundAta)).amount + : 0 + ); + const solBefore = await lamports(refundTo.publicKey); + + await program.methods + .refundSolverTokenNativeReward(hashlock, new BN(1)) + .accounts({ + caller: signer.publicKey, + solverLock: deriveSolverLock(programId, hashlock, 1)[0], + rentPayer: signer.publicKey, + refundTo: refundTo.publicKey, + tokenMint: mintA, + vault: deriveSolverVault(programId, hashlock, 1)[0], + refundToTokenAccount: refundAta, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + } as any) + .rpc(); + + expect( + Number((await getAccount(provider.connection, refundAta)).amount) - + tokenBefore + ).to.equal(amount); + expect((await lamports(refundTo.publicKey)) - solBefore).to.equal(reward); + }); }); // ═══════════════════════════════ Close Solver Lock ═══════════════════════════