Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Informational
13 findings
12 fixed
1 acknowledged
Medium Risk1 finding
Forced lockup from permit payload not enforced on-chain
Description
In
place_bid(), the lockup parameter is a free argument passed by the bidder. The only on-chain enforcement is the monotonic ratchet inupdate_bid()once lockup is set to true, it cannot revert to false. However, nothing forces it to be true in the first place.The
PurchasePermitV3contains a payload field that is signed by the permit signer and, in the EVM implementation, is decoded to extract a forcedLockup flag that is enforced on-chain. In the Solana program, this payload is explicitly marked as opaque and never decoded:/// Opaque payload bytes for off-chain use. Not decoded by the program.pub payload: Vec<u8>,The error variant
BidMustHaveLockupis defined but never referenced in any instruction logic, suggesting enforcement was intended but not implemented. A bidder can submit lockup: false regardless of the permit signer's intent, silently bypassing the forced lockup requirement.Recommendation
Decode the permit payload on-chain and enforce the
forced_lockupflag, matching the EVM implementation's behavior:if permit.forced_lockup && !lockup { return Err(SettlementSaleError::BidMustHaveLockup.into()); }Alternatively, if the payload is intentionally kept opaque for the Solana program, consider adding
forced_lockupas an explicit field onPurchasePermitV3so it is covered by the permit signature and enforceable on-chain.Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix. A new Borsh-serialized
PurchasePermitPayloadstruct was introduced with aforced_lockup: boolfield (mirroring the EVM version), and adecode_payload()method was added toPurchasePermitV3that returns None for an empty payload or the decoded struct otherwise.In
place_bid, right after permit validation, the decoded payload is checked withrequire!(!payload.forced_lockup || lockup, SettlementSaleError::BidMustHaveLockup), finally wiring the previously-orphaned error variant.
Informational13 findings
Missing validation on Ed25519 precompile instruction format
Description
parse_ed25519_instructionverifiesix.program_id == ED25519_PROGRAM_IDbut does not verify that the instruction has zero accounts. TheEd25519precompile is stateless and should never have accounts attached. Additionally,parse_ed25519_instruction_dataacceptsmessage_data_size == 0, allowing an empty message to pass parsing. The precompile ignores extraneous accounts, and an empty parsed message would fail the downstream comparison against the serialized permit inverify_ed25519_signature, but adding these checks hardens the instruction validation and is consistent with best practices in other audited Solana programs.Recommendation
Add an accounts check in
parse_ed25519_instructionand an optional message size check inparse_ed25519_instruction_data:require!(ix.accounts.is_empty(), SettlementSaleError::Ed25519MalformedData); require!(message_data_size > 0, SettlementSaleError::Ed25519MalformedData);Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
bidder unnecessarily marked mutable in CancelOrReduce and ClaimRefund
Description
The bidder account is marked
#[account(mut)]in bothCancelOrReduceandClaimRefund, but it is never mutated..It is only used for PDA seed derivation, signer verification, and event emission via.key(). The token transfer goes from vault tobidder_token_accountsigned by the sale PDA, so no lamports touch the bidder's system account. The unnecessary write lock slightly increases compute cost and reduces transaction parallelism. For comparison,SetAllocationandProcessRefundcorrectly leave their signer non-mutable.Recommendation
Remove mut from bidder in both structs.
Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Unused error variant InvalidEd25519Instruction
Description
The error variant
InvalidEd25519Instructionis defined inerrors.rsbut never referenced anywhere in the codebase. The Ed25519 verification logic uses more specific variants such asEd25519InstructionMissing,Ed25519WrongProgram,andEd25519MalformedDatainstead. This is dead code that adds unnecessary surface area to the error enum.Recommendation
Remove the unused variant from
SettlementSaleErrorCoinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Missing CPI guard on Ed25519 signature verification
Finding Description
load_current_index_checkedreturns the top-level instruction index, not the index of the CPI callee. Ifplace_bidis invoked via CPI from a malicious wrapping program, the Ed25519 signature check still passes because it resolves the instruction atcurrent_index- 1 to the top-level Ed25519 precompile instruction placed by the attacker. This allows the wrapping program to control the amount, price, and lockup parameters passed toplace_bidwithin the valid permit bounds, on behalf of a victim who signs the malicious transaction. The victim must sign the transaction for the attack to succeed , and the attacker cannot exceed backend-authorized permit limits, bounding the financial impact.Recommendation
Add a CPI guard to verify_ed25519_signature that confirms the current instruction belongs to this program. Something like:
let current_ix = load_instruction_at_checked(current_index as usize, instructions_sysvar) .map_err(|_| SettlementSaleError::Ed25519InstructionMissing)?;require!(current_ix.program_id == crate::ID, SettlementSaleError::UnauthorizedCpi);Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Single-step authority transfer allows no recovery from erroneous transfers
Description
The
transfer_authority()function performs an immediate, single-step ownership transfer by directly overwriting thesale.adminfield with the providednew_authoritypubkey. There is no intermediate "pending" state and no acceptance step required from the incoming authority. Once the transaction confirms, the old admin irrevocably loses all administrative privileges.If an admin mistakenly passes an incorrect pubkey: a typo, a wrong copy-paste, or the address of a keypair whose private key is lost, administrative control of the sale is permanently and irrecoverably transferred.
This is a well-known footgun pattern in both EVM and Solana programs. While operational security (e.g., using multisigs, careful key management) is ultimately the strongest mitigation, a defensive two-step pattern at the protocol level provides an additional safety net at low complexity cost.
Recommendation
Implement a two-step (propose/accept) authority transfer pattern:
propose_authority(new_authority)called by the current admin; stores the proposed new authority in apending_adminfield on the SettlementSale account without modifying the active admin.accept_authority()called by the proposed new authority; promotespending_admintoadminand clears the pending field.
This guarantees the receiving address is controlled by an entity capable of signing, eliminating the class of bugs where authority is transferred to an unreachable key.
Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Purchase permit signer cannot be rotated without contract upgrade
Description
The
permit_signeris set once duringinitialize_saleand stored on theSettlementSaleaccount. There is no instruction to update it. If the signing key is compromised or needs routine rotation, a program upgrade is required.Recommendation
Consider documenting this behavior as a deliberate design choice and prepare necessary playbooks beforehand to deal with an emergency situation where the
permit_signermight be required to be changed.Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix. A new "Permit Signer Key Management" section to
ARCHITECTURE.mdthat documents the non-rotatability as a deliberate design choice and provides a four-step compromise-response playbook. No code changes were made.Function validate_structure() accepts zero-duration permit windows
Description
Function
validate_structure()uses a<=check for the time window, allowingopens_at == closes_at. Such a permit passes structural validation but can never be used.validate_against_sale()requirescurrent_time >= opens_atANDcurrent_time < closes_at, which is unsatisfiable when the two values are equal.A permit signer mistake issuing such a permit would silently block the affected wallet from bidding, with no on-chain error until the bid is actually attempted.
Recommendation
Consider using a strict
<check (opens_at < closes_at) invalidate_structure()to reject zero-duration windows early, surfacing the permit signer error at validation time rather than at bid time.Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Misleading error variant name Ed25519MultipleSignatures
Description
The error variant
Ed25519MultipleSignaturesfires for anynum_signatures != 1, includingnum_signatures == 0. The error message ("must contain exactly one signature") correctly describes the constraint, but the variant name implies only the>1case. This can be confusing when debugging a zero-signature scenario, as the error name suggests multiple signatures were found.Recommendation
Consider renaming the variant to
Ed25519InvalidSignatureCountto accurately reflect that it covers both zero and multiple signature cases.Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Missing documentation for minAmount non-enforcement during commitment reduction
Description
In the Solana program,
reduce_commitmentandcancel_bidallow entities to reduce their committed amount below themin_amountthreshold originally enforced by the purchase permit at bid submission time.This is intentional:
min_amountis a bid-time constraint, not an ongoing floor. However, unlike the EVM implementation which explicitly documents this behavior ("No minimum floor is enforced on the resulting commitment. The minAmount constraint from the purchase permit applies only at bid submission and entities may reduce below that threshold here."), the Solana program has no equivalent documentation.Recommendation
Add inline documentation clarifying that no minimum floor is enforced on the resulting commitment and that the
min_amountconstraint from the purchase permit applies only at bid submission.Coinbase: Fixed in sunrisedotdev/svm-spearbit#1
Cantina: Verified fix.
Function set_allocation emits AllocationSet events for no-op allocations
Description
The
set_allocationfunction emits anAllocationSetevent even when there is no effective state change. This occurs in two scenarios:- When both
prev_acceptedandaccepted_amountare zero. In this case, the ifprev_accepted > 0branch is skipped, and callingadd_accepted(0)has no effect onwallet_binding.accepted_amount,entity_state.accepted_amount, orsale.total_accepted. - When
prev_acceptedequalsaccepted_amount(both non-zero, with allow_overwrite = true). Here,sub_accepted(X)followed byadd_accepted(X)results in no net change across the same state variables.
In both situations, an
AllocationSet { prev_accepted, accepted_amount, … }event is still emitted despite no observable on-chain change. While this does not impact funds or break accounting invariants, it can pollute the event stream and potentially mislead off-chain systems that rely on event diffs or treat these events as indicators of meaningful allocation changes.Recommendation
Add an early return before the emit when the call is a no-op:
if prev_accepted == accepted_amount { return Ok(()); }Coinbase: Fixed in sunrisedotdev/svm-spearbit#2
Cantina: Verified fix.
- When both
Function cancel_bid lacks a zero-amount guard
Description
When a wallet calls
cancel_bid, the handler reads their refundable balance and passes it straight into the shared reduction helper without first checking that the amount is actually greater than zero.pub fn cancel_bid(ctx: Context<CancelOrReduce>) -> Result<()> { let amount = ctx.accounts.wallet_binding.refund_amount()?; apply_reduction(..., amount)?; ... }Inside
apply_reduction, a zero amount takes a special shortcut: it flips the wallet's refunded flag to true and returns early, skipping the token transfer, the entity-state update, and the usual accounting adjustments.if amount == 0 { binding.refunded = true; return Ok(()); }In practice this branch is unreachable today. For
refund_amount()to be zero during Cancellation, the binding's contributed_amount would have to be zero and the only way that happens is via a previous full cancellation, which would have already set refunded = true and caused the upstreamrequire!(!binding.refunded)check to reject the call before it ever reaches the shortcut.Still, it's a behavioral outlier worth cleaning up:
- The EVM contract never allows this path. cancelBid silently skips (wallet, token) pairs whose refundable amount is zero, and _reduceCommitment explicitly reverts with ZeroAmount if called with zero.
- The sibling Solana instruction reduce_commitment already does the right thing and rejects zero with
require!(amount > 0, ZeroAmount)at the top of the handler.
So
cancel_bidis the only place, across either implementation, where the refunded flag can in theory be flipped without any corresponding transfer or state change.Recommendation
Add the same zero-amount guard that
reduce_commitmentalready uses, socancel_bidbecomes consistent with both its sibling and the EVM reference:pub fn cancel_bid(ctx: Context<CancelOrReduce>) -> Result<()> { let amount = ctx.accounts.wallet_binding.refund_amount()?; + require!(amount > 0, SettlementSaleError::ZeroAmount); apply_reduction(..., amount)?; ... }Coinbase: Fixed in sunrisedotdev/svm-spearbit#2
Cantina: Verified fix.
Role grants accept Pubkey::default() as an operator
Description
Function
grant_roledon't check that the operator argument is non-zero. An admin passingPubkey::default()creates a RoleBinding PDA that no one can ever exercise (no signer can satisfyauthority.key() == Pubkey::default()), leaving an orphaned account consuming rent until the admin manually revokes it.This is inconsistent with the rest of the program, which rejects zero pubkeys at every other input boundary:
initialize_sale(ZeroPermitSigner, ZeroProceedsReceiver),set_proceeds_receiver(ZeroProceedsReceiver), andtransfer_authority(ZeroAuthority).Recommendation
Add the same zero-pubkey guard used elsewhere to
grant_roleinstruction:require!(operator != Pubkey::default(), SettlementSaleError::ZeroAuthority);Coinbase: Fixed in sunrisedotdev/svm-spearbit#2
Cantina: Verified fix.
[Appendix] Test Coverage Improvements: settlement_sale::place_bid(), cancel_bid(), process_refund()
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Overview
The existing test suites for
place_bid,cancel_bid, andprocess_refundinprograms/settlement-salecovered the main happy paths and most stage/pause/refunded error cases, but had several gaps: (1) no test pinningmax_wallets_per_entityas an inclusive upper bound, (2) no test for the zero-delta (price-only) re-bid code path, (3) no regression guards onwallet_count/sale.total_committedacross multi-wallet bid sequences, (4) no test thatentity.refundedflips totrueafter every wallet on the entity cancels, (5) no test that cancelling after a same-wallet re-bid refunds the full accumulatedcontributed_amount, and (6) no test pinning the intentional asymmetry whereprocess_refundsucceeds while the sale is paused (unlikeclaim_refund). This appendix documents the tests added to close those gaps.test_place_bid.rs : place_bid()Test Details Status accepts_bids_up_to_max_wallets_per_entitySale initialized with max_wallets_per_entity = 2; two distinct wallets bid on the same entity;entity.wallet_countreaches the configured maximum of2(inclusive upper bound, no off-by-one)✔ places_bid_with_same_amount_higher_price_transfers_nothingRe-bid with same amountand higherpriceupdatesentity.current_priceonly;delta = 0path skipstransfer_to_vault,sale.add_committed, andwallet_binding.add_contribution; vault balance, bidder balance,contributed_amount, andtotal_committedall unchanged✔ test_place_bid(strengthened)Inline entity.wallet_countandsale.total_committedassertions added after each of the three sequential bids; pins thatwallet_countdoes not re-increment on re-bid from an already-bound wallet and thatsale.add_committed(delta)runs on every non-zero-delta bid✔ test_cancel_bid.rs : cancel_bid()Test Details Status cancel_bid_marks_entity_refunded_when_all_wallets_cancelTwo wallets bid on the same entity; after the first cancels, entity.refundedstaysfalseandentity.current_amount > 0; after the second also cancels,entity.refunded = true,entity.current_amount = 0,sale.total_committed = 0,sale.total_cancelled = 2 * BID_AMOUNT✔ cancel_bid_after_rebid_refunds_total_contributed_amountSame wallet places BID_AMOUNT, then re-bids2 * BID_AMOUNT; cancel must refund the full accumulatedcontributed_amount = 2 * BID_AMOUNT, zero outcontributed_amount, and setcancelled_amount = sale.total_cancelled = 2 * BID_AMOUNT✔ test_process_refund.rs : process_refund()Test Details Status process_refund_succeeds_while_pausedSale is paused in Donestage; authority-initiatedprocess_refundstill succeeds, transfers the correct delta (BID_AMOUNT - ACCEPTED_AMOUNT) from vault to the wallet's token account, marksbinding.refunded = true, and updatessale.total_refunded; pins the intentional asymmetry withclaim_refund(which is paused-gated)✔ Test Code
test_place_bid.rs : accepts_bids_up_to_max_wallets_per_entity/// max_wallets_per_entity is an inclusive upper bound: wallet_count may reach/// the configured maximum.#[tokio::test]async fn accepts_bids_up_to_max_wallets_per_entity() { let sale_uuid = [36u8; 16]; let entity_id = [42u8; 16]; let mut ctx = context::setup(&sale_uuid).await; ctx.initialize_sale(2, true, false, true, ctx.authority.insecure_clone().pubkey()) .await; let (entity_pda, _) = pda::derive_entity_state_pda(&ctx.sale_pda, &entity_id, &ctx.program_id); let bidder = ctx.bidder.insecure_clone(); let bidder_ta = ctx.bidder_token_account; let (wb_pda, _) = pda::derive_wallet_binding_pda(&ctx.sale_pda, &bidder.pubkey(), &ctx.program_id); ctx.place_bid(&bidder, &bidder_ta, &entity_id, &entity_pda, &wb_pda, BID_AMOUNT, BID_PRICE, false) .await .unwrap(); let entity = ctx.fetch_entity(entity_pda).await; assert_eq!(entity.wallet_count, 1); let bidder2 = ctx.bidder2.insecure_clone(); let bidder2_ta = ctx.bidder2_token_account; let (wb2_pda, _) = pda::derive_wallet_binding_pda(&ctx.sale_pda, &bidder2.pubkey(), &ctx.program_id); ctx.place_bid( &bidder2, &bidder2_ta, &entity_id, &entity_pda, &wb2_pda, BID_AMOUNT * 2, BID_PRICE, false, ) .await .unwrap(); let entity = ctx.fetch_entity(entity_pda).await; assert_eq!(entity.wallet_count, 2); assert_eq!(entity.current_amount, BID_AMOUNT * 2);}test_place_bid.rs : places_bid_with_same_amount_higher_price_transfers_nothing/// Re-bidding with the same amount but a higher price must update the price/// without transferring tokens (delta = 0 path skips add_committed //// add_contribution / transfer_to_vault).#[tokio::test]async fn places_bid_with_same_amount_higher_price_transfers_nothing() { let sale_uuid = [37u8; 16]; let entity_id = [42u8; 16]; let mut ctx = context::setup(&sale_uuid).await; ctx.initialize_default_sale(5).await; let (entity_pda, _) = pda::derive_entity_state_pda(&ctx.sale_pda, &entity_id, &ctx.program_id); let bidder = ctx.bidder.insecure_clone(); let bidder_ta = ctx.bidder_token_account; let (wb_pda, _) = pda::derive_wallet_binding_pda(&ctx.sale_pda, &bidder.pubkey(), &ctx.program_id); ctx.place_bid(&bidder, &bidder_ta, &entity_id, &entity_pda, &wb_pda, BID_AMOUNT, BID_PRICE, false) .await .unwrap(); let bidder_ta_before = ctx.token_balance(bidder_ta).await; let vault_before = ctx.token_balance(ctx.vault_pda).await; let sale_before = ctx.fetch_sale().await; let wb_before = ctx.fetch_wallet_binding(wb_pda).await; ctx.place_bid(&bidder, &bidder_ta, &entity_id, &entity_pda, &wb_pda, BID_AMOUNT, BID_PRICE + 50, false) .await .unwrap(); let entity = ctx.fetch_entity(entity_pda).await; assert_eq!(entity.current_amount, BID_AMOUNT); assert_eq!(entity.current_price, BID_PRICE + 50); let wb_after = ctx.fetch_wallet_binding(wb_pda).await; assert_eq!(wb_after.contributed_amount, wb_before.contributed_amount); let sale_after = ctx.fetch_sale().await; assert_eq!(sale_after.total_committed, sale_before.total_committed); assert_eq!(ctx.token_balance(bidder_ta).await, bidder_ta_before); assert_eq!(ctx.token_balance(ctx.vault_pda).await, vault_before);}test_place_bid.rs : test_place_bid(strengthened assertions)// Section 1 (after first bid by `bidder`):assert_eq!(entity.wallet_count, 1);assert_eq!(sale.total_committed, BID_AMOUNT); // Section 2 (after second distinct wallet `bidder2` bids):assert_eq!(entity.wallet_count, 2);assert_eq!(sale.total_committed, 20_000_000); // Section 4 (after `bidder` re-bids with a higher amount):// Re-bid by an already-bound wallet must NOT re-increment wallet_count.assert_eq!(entity.wallet_count, 2);assert_eq!(sale.total_committed, 25_000_000);test_cancel_bid.rs : cancel_bid_marks_entity_refunded_when_all_wallets_cancel/// After every wallet on an entity cancels, `entity.refunded` must flip to true/// (second half of the two-wallet case; existing test only covers the first/// cancel where one binding still remains).#[tokio::test]async fn cancel_bid_marks_entity_refunded_when_all_wallets_cancel() { let (mut ctx, _, entity_pda, wb_pda, wb2_pda) = setup_with_two_bids().await; let bidder = ctx.bidder.insecure_clone(); let bidder_ta = ctx.bidder_token_account; let bidder2 = ctx.bidder2.insecure_clone(); let bidder2_ta = ctx.bidder2_token_account; ctx.cancel_bid(&bidder, &bidder_ta, &entity_pda, &wb_pda).await.unwrap(); let entity = ctx.fetch_entity(entity_pda).await; assert!(!entity.refunded); assert_eq!(entity.current_amount, BID_AMOUNT); ctx.cancel_bid(&bidder2, &bidder2_ta, &entity_pda, &wb2_pda) .await .unwrap(); let entity = ctx.fetch_entity(entity_pda).await; assert!(entity.refunded); assert_eq!(entity.current_amount, 0); let sale = ctx.fetch_sale().await; assert_eq!(sale.total_committed, 0); assert_eq!(sale.total_cancelled, BID_AMOUNT * 2);}test_cancel_bid.rs : cancel_bid_after_rebid_refunds_total_contributed_amount/// Cancelling after a same-wallet re-bid must refund the full latest/// `contributed_amount` (pins monotonic accumulation through re-bids).#[tokio::test]async fn cancel_bid_after_rebid_refunds_total_contributed_amount() { let sale_uuid = [4u8; 16]; let mut ctx = context::setup(&sale_uuid).await; ctx.initialize_default_sale(3).await; let entity_id = [42u8; 16]; let (entity_pda, _) = pda::derive_entity_state_pda(&ctx.sale_pda, &entity_id, &ctx.program_id); let bidder = ctx.bidder.insecure_clone(); let bidder_ta = ctx.bidder_token_account; let (wb_pda, _) = pda::derive_wallet_binding_pda(&ctx.sale_pda, &bidder.pubkey(), &ctx.program_id); ctx.place_bid( &bidder, &bidder_ta, &entity_id, &entity_pda, &wb_pda, BID_AMOUNT, BID_PRICE, false, ) .await .unwrap(); ctx.place_bid( &bidder, &bidder_ta, &entity_id, &entity_pda, &wb_pda, BID_AMOUNT * 2, BID_PRICE, false, ) .await .unwrap(); let binding_before = ctx.fetch_wallet_binding(wb_pda).await; assert_eq!(binding_before.contributed_amount, BID_AMOUNT * 2); ctx.open_cancellation().await; let balance_before = ctx.token_balance(bidder_ta).await; let vault_before = ctx.token_balance(ctx.vault_pda).await; ctx.cancel_bid(&bidder, &bidder_ta, &entity_pda, &wb_pda) .await .unwrap(); let balance_after = ctx.token_balance(bidder_ta).await; let vault_after = ctx.token_balance(ctx.vault_pda).await; assert_eq!(balance_after - balance_before, BID_AMOUNT * 2); assert_eq!(vault_before - vault_after, BID_AMOUNT * 2); let binding = ctx.fetch_wallet_binding(wb_pda).await; assert!(binding.refunded); assert_eq!(binding.contributed_amount, 0); assert_eq!(binding.cancelled_amount, BID_AMOUNT * 2); let sale = ctx.fetch_sale().await; assert_eq!(sale.total_committed, 0); assert_eq!(sale.total_cancelled, BID_AMOUNT * 2);}test_process_refund.rs : process_refund_succeeds_while_paused/// `process_refund` must still succeed when the sale is paused — it is an/// authority-driven refund path and intentionally has no `require_not_paused`/// gate (asymmetric with `claim_refund`, which IS paused-gated).#[tokio::test]async fn process_refund_succeeds_while_paused() { let (mut ctx, _, entity_pda, wb_pda) = setup_in_done().await; let bidder = ctx.bidder.insecure_clone(); let bidder_ta = ctx.bidder_token_account; let authority = ctx.authority.insecure_clone(); let pause_ix = instructions::set_paused(&ctx.program_id, &authority.pubkey(), &ctx.sale_pda, true); ctx.send_tx_with_signer(&[pause_ix], &authority).await.unwrap(); assert!(ctx.fetch_sale().await.is_paused); let balance_before = ctx.token_balance(bidder_ta).await; let vault_before = ctx.token_balance(ctx.vault_pda).await; ctx.process_refund(&entity_pda, &wb_pda, &bidder.pubkey(), &bidder_ta, false) .await .unwrap(); let balance_after = ctx.token_balance(bidder_ta).await; let vault_after = ctx.token_balance(ctx.vault_pda).await; assert_eq!(balance_after - balance_before, BID_AMOUNT - ACCEPTED_AMOUNT); assert_eq!(vault_before - vault_after, BID_AMOUNT - ACCEPTED_AMOUNT); let binding = ctx.fetch_wallet_binding(wb_pda).await; assert!(binding.refunded); let sale = ctx.fetch_sale().await; assert_eq!(sale.total_refunded, BID_AMOUNT - ACCEPTED_AMOUNT); assert!(sale.is_paused);}