diff --git a/blackjack/encrypted-ixs/src/lib.rs b/blackjack/encrypted-ixs/src/lib.rs index 3db890af..8fb01d94 100644 --- a/blackjack/encrypted-ixs/src/lib.rs +++ b/blackjack/encrypted-ixs/src/lib.rs @@ -61,20 +61,10 @@ mod circuits { let mut player_hand = player_hand_ctxt.to_arcis().unpack(); - let player_hand_value = calculate_hand_value(&player_hand, player_hand_size); + let card_index = (player_hand_size + dealer_hand_size) as usize; + player_hand[player_hand_size as usize] = deck[card_index]; - let is_bust = player_hand_value > 21; - - let new_card = if !is_bust { - let card_index = (player_hand_size + dealer_hand_size) as usize; - - // Get the next card from the deck - deck[card_index] - } else { - 53 - }; - - player_hand[player_hand_size as usize] = new_card; + let is_bust = calculate_hand_value(&player_hand, player_hand_size + 1) > 21; ( player_hand_ctxt.owner.from_arcis(Pack::new(player_hand)), @@ -98,24 +88,14 @@ mod circuits { player_hand_size: u8, dealer_hand_size: u8, ) -> (Enc, bool) { - let deck_array = deck_ctxt.to_arcis().unpack(); + let deck = deck_ctxt.to_arcis().unpack(); let mut player_hand = player_hand_ctxt.to_arcis().unpack(); - let player_hand_value = calculate_hand_value(&player_hand, player_hand_size); + let card_index = (player_hand_size + dealer_hand_size) as usize; + player_hand[player_hand_size as usize] = deck[card_index]; - let is_bust = player_hand_value > 21; - - let new_card = if !is_bust { - let card_index = (player_hand_size + dealer_hand_size) as usize; - - // Get the next card from the deck - deck_array[card_index] - } else { - 53 - }; - - player_hand[player_hand_size as usize] = new_card; + let is_bust = calculate_hand_value(&player_hand, player_hand_size + 1) > 21; ( player_hand_ctxt.owner.from_arcis(Pack::new(player_hand)), @@ -136,10 +116,10 @@ mod circuits { let mut dealer = dealer_hand_ctxt.to_arcis().unpack(); let mut size = dealer_hand_size as usize; - for _ in 0..7 { + for _ in 0..9 { let val = calculate_hand_value(&dealer, size as u8); - if val < 17 { - let idx = (player_hand_size as usize + size) as usize; + if val < 17 && size < 11 { + let idx = player_hand_size as usize + size; dealer[size] = deck_array[idx]; size += 1; } @@ -164,35 +144,31 @@ mod circuits { /// # Returns /// The total value of the hand (1-21, or >21 if busted) fn calculate_hand_value(hand: &[u8; 11], hand_length: u8) -> u8 { - let mut value = 0; - let mut has_ace = false; + let mut value: u8 = 0; + let mut ace_count: u8 = 0; - // Process each card in the hand for i in 0..11 { - let rank = if i < hand_length as usize { - hand[i] % 13 // Card rank (0=Ace, 1-9=pip cards, 10-12=face cards) - } else { - 0 - }; - if i < hand_length as usize { - if rank == 0 { - // Ace: start with value of 11 - value += 11; - has_ace = true; - } else if rank > 10 { - // Face cards (Jack, Queen, King): value of 10 - value += 10; - } else { - // Pip cards (2-10): face value (rank 1-9 becomes value 1-9) - value += rank; + let card = hand[i]; + if card <= 51 { + let rank = card % 13; // 0=Ace, 1=2, ..., 9=10, 10=J, 11=Q, 12=K + if rank == 0 { + value += 11; + ace_count += 1; + } else if rank <= 9 { + value += rank + 1; + } else { + value += 10; + } } } } - // Convert Ace from 11 to 1 if hand would bust with 11 - if value > 21 && has_ace { - value -= 10; + for _ in 0..11 { + if value > 21 && ace_count > 0 { + value -= 10; + ace_count -= 1; + } } value diff --git a/blackjack/programs/blackjack/src/lib.rs b/blackjack/programs/blackjack/src/lib.rs index db4168c0..573f3ee9 100644 --- a/blackjack/programs/blackjack/src/lib.rs +++ b/blackjack/programs/blackjack/src/lib.rs @@ -47,11 +47,6 @@ pub mod blackjack { blackjack_game.bump = ctx.bumps.blackjack_game; blackjack_game.game_id = game_id; blackjack_game.player_pubkey = ctx.accounts.payer.key(); - blackjack_game.player_hand = [0; 32]; - blackjack_game.dealer_hand = [0; 32]; - blackjack_game.deck_nonce = 0; - blackjack_game.client_nonce = 0; - blackjack_game.dealer_nonce = 0; blackjack_game.player_enc_pubkey = client_pubkey; blackjack_game.game_state = GameState::Initial; blackjack_game.player_hand_size = 0; @@ -184,6 +179,10 @@ pub mod blackjack { !ctx.accounts.blackjack_game.player_has_stood, ErrorCode::InvalidMove ); + require!( + ctx.accounts.blackjack_game.player_hand_size < 11, + ErrorCode::InvalidMove + ); let args = ArgBuilder::new() // Deck @@ -247,9 +246,10 @@ pub mod blackjack { let blackjack_game = &mut ctx.accounts.blackjack_game; blackjack_game.player_hand = player_hand; blackjack_game.client_nonce = client_nonce; + blackjack_game.player_hand_size += 1; if is_bust { - blackjack_game.game_state = GameState::DealerTurn; + blackjack_game.game_state = GameState::Resolving; emit!(PlayerBustEvent { client_nonce, game_id: blackjack_game.game_id, @@ -261,7 +261,6 @@ pub mod blackjack { client_nonce, game_id: blackjack_game.game_id, }); - blackjack_game.player_hand_size += 1; } Ok(()) @@ -287,6 +286,10 @@ pub mod blackjack { !ctx.accounts.blackjack_game.player_has_stood, ErrorCode::InvalidMove ); + require!( + ctx.accounts.blackjack_game.player_hand_size < 11, + ErrorCode::InvalidMove + ); let args = ArgBuilder::new() // Deck @@ -350,10 +353,11 @@ pub mod blackjack { let blackjack_game = &mut ctx.accounts.blackjack_game; blackjack_game.player_hand = player_hand; blackjack_game.client_nonce = client_nonce; + blackjack_game.player_hand_size += 1; blackjack_game.player_has_stood = true; if is_bust { - blackjack_game.game_state = GameState::DealerTurn; + blackjack_game.game_state = GameState::Resolving; emit!(PlayerBustEvent { client_nonce, game_id: blackjack_game.game_id, @@ -435,8 +439,7 @@ pub mod blackjack { blackjack_game.player_has_stood = true; if is_bust { - // This should never happen - blackjack_game.game_state = GameState::PlayerTurn; + blackjack_game.game_state = GameState::Resolving; emit!(PlayerBustEvent { client_nonce: blackjack_game.client_nonce, game_id: blackjack_game.game_id, @@ -444,7 +447,6 @@ pub mod blackjack { } else { blackjack_game.game_state = GameState::DealerTurn; emit!(PlayerStandEvent { - is_bust, game_id: blackjack_game.game_id }); } @@ -608,41 +610,22 @@ pub mod blackjack { Err(_) => return Err(ErrorCode::AbortedComputation.into()), }; - if result == 0 { - // Player busts (dealer wins) - emit!(ResultEvent { - winner: "Dealer".to_string(), - game_id: ctx.accounts.blackjack_game.game_id, - }); - } else if result == 1 { - // Dealer busts (player wins) - emit!(ResultEvent { - winner: "Player".to_string(), - game_id: ctx.accounts.blackjack_game.game_id, - }); - } else if result == 2 { - // Player wins - emit!(ResultEvent { - winner: "Player".to_string(), - game_id: ctx.accounts.blackjack_game.game_id, - }); - } else if result == 3 { - // Dealer wins - emit!(ResultEvent { - winner: "Dealer".to_string(), - game_id: ctx.accounts.blackjack_game.game_id, - }); - } else { - // Push (tie) - emit!(ResultEvent { - winner: "Tie".to_string(), - game_id: ctx.accounts.blackjack_game.game_id, - }); - } + let winner = match result { + 0 | 3 => "Dealer", + 1 | 2 => "Player", + 4 => "Tie", + _ => return Err(ErrorCode::InvalidGameResult.into()), + }; let blackjack_game = &mut ctx.accounts.blackjack_game; + blackjack_game.game_result = result; blackjack_game.game_state = GameState::Resolved; + emit!(ResultEvent { + winner: winner.to_string(), + game_id: blackjack_game.game_id, + }); + Ok(()) } } @@ -826,8 +809,9 @@ pub struct PlayerHit<'info> { mut, seeds = [b"blackjack_game".as_ref(), _game_id.to_le_bytes().as_ref()], bump = blackjack_game.bump, + constraint = blackjack_game.player_pubkey == payer.key() @ ErrorCode::NotAuthorized, )] - pub blackjack_game: Account<'info, BlackjackGame>, + pub blackjack_game: Box>, } #[callback_accounts("player_hit")] @@ -941,8 +925,9 @@ pub struct PlayerDoubleDown<'info> { mut, seeds = [b"blackjack_game".as_ref(), _game_id.to_le_bytes().as_ref()], bump = blackjack_game.bump, + constraint = blackjack_game.player_pubkey == payer.key() @ ErrorCode::NotAuthorized, )] - pub blackjack_game: Account<'info, BlackjackGame>, + pub blackjack_game: Box>, } #[callback_accounts("player_double_down")] @@ -1056,8 +1041,9 @@ pub struct PlayerStand<'info> { mut, seeds = [b"blackjack_game".as_ref(), _game_id.to_le_bytes().as_ref()], bump = blackjack_game.bump, + constraint = blackjack_game.player_pubkey == payer.key() @ ErrorCode::NotAuthorized, )] - pub blackjack_game: Account<'info, BlackjackGame>, + pub blackjack_game: Box>, } #[callback_accounts("player_stand")] @@ -1171,8 +1157,9 @@ pub struct DealerPlay<'info> { mut, seeds = [b"blackjack_game".as_ref(), _game_id.to_le_bytes().as_ref()], bump = blackjack_game.bump, + constraint = blackjack_game.player_pubkey == payer.key() @ ErrorCode::NotAuthorized, )] - pub blackjack_game: Account<'info, BlackjackGame>, + pub blackjack_game: Box>, } #[callback_accounts("dealer_play")] @@ -1286,8 +1273,9 @@ pub struct ResolveGame<'info> { mut, seeds = [b"blackjack_game".as_ref(), _game_id.to_le_bytes().as_ref()], bump = blackjack_game.bump, + constraint = blackjack_game.player_pubkey == payer.key() @ ErrorCode::NotAuthorized, )] - pub blackjack_game: Account<'info, BlackjackGame>, + pub blackjack_game: Box>, } #[callback_accounts("resolve_game")] @@ -1415,7 +1403,6 @@ pub struct PlayerDoubleDownEvent { #[event] pub struct PlayerStandEvent { - pub is_bust: bool, pub game_id: u64, } @@ -1451,4 +1438,8 @@ pub enum ErrorCode { InvalidDealerClientPubkey, #[msg("Cluster not set")] ClusterNotSet, + #[msg("Not authorized to perform this action")] + NotAuthorized, + #[msg("Unexpected game result value from computation")] + InvalidGameResult, } diff --git a/blackjack/tests/blackjack.ts b/blackjack/tests/blackjack.ts index 72283f64..b68c5995 100644 --- a/blackjack/tests/blackjack.ts +++ b/blackjack/tests/blackjack.ts @@ -109,38 +109,31 @@ describe("Blackjack", () => { it("Should play a full blackjack game with state awareness", async () => { console.log("Owner address:", owner.publicKey.toBase58()); + // Wait for MXE account to be ready before initializing comp defs + const mxePublicKey = await getMXEPublicKeyWithRetry( + provider as anchor.AnchorProvider, + program.programId + ); + // --- Initialize Computation Definitions --- console.log("Initializing computation definitions..."); - await Promise.all([ - initShuffleAndDealCardsCompDef(program as any, owner).then((sig) => - console.log("Shuffle/Deal CompDef Init Sig:", sig) - ), - initPlayerHitCompDef(program as any, owner).then((sig) => - console.log("Player Hit CompDef Init Sig:", sig) - ), - initPlayerStandCompDef(program as any, owner).then((sig) => - console.log("Player Stand CompDef Init Sig:", sig) - ), - initPlayerDoubleDownCompDef(program as any, owner).then((sig) => - console.log("Player DoubleDown CompDef Init Sig:", sig) - ), - initDealerPlayCompDef(program as any, owner).then((sig) => - console.log("Dealer Play CompDef Init Sig:", sig) - ), - initResolveGameCompDef(program as any, owner).then((sig) => - console.log("Resolve Game CompDef Init Sig:", sig) - ), - ]); + const inits = [ + { name: "Shuffle/Deal", fn: initShuffleAndDealCardsCompDef }, + { name: "Player Hit", fn: initPlayerHitCompDef }, + { name: "Player Stand", fn: initPlayerStandCompDef }, + { name: "Player DoubleDown", fn: initPlayerDoubleDownCompDef }, + { name: "Dealer Play", fn: initDealerPlayCompDef }, + { name: "Resolve Game", fn: initResolveGameCompDef }, + ]; + for (const { name, fn } of inits) { + const sig = await fn(program as any, owner); + console.log(`${name} CompDef Init Sig:`, sig); + } console.log("All computation definitions initialized."); - await new Promise((res) => setTimeout(res, 2000)); // --- Setup Game Cryptography --- const privateKey = x25519.utils.randomSecretKey(); const publicKey = x25519.getPublicKey(privateKey); - const mxePublicKey = await getMXEPublicKeyWithRetry( - provider as anchor.AnchorProvider, - program.programId - ); console.log("MXE x25519 pubkey is", mxePublicKey); const sharedSecret = x25519.getSharedSecret(privateKey, mxePublicKey); @@ -196,7 +189,10 @@ describe("Blackjack", () => { blackjackGame: blackjackGamePDA, }) .signers([owner]) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); console.log("Initialize game TX Signature:", initGameSig); console.log("Waiting for shuffle/deal computation finalization..."); @@ -265,7 +261,9 @@ describe("Blackjack", () => { // Basic Strategy: Hit on 16 or less, Stand on 17 or more. Hit soft 17. let action: "hit" | "stand" = "stand"; - if (playerValue < 17 || (playerValue === 17 && playerIsSoft)) { + if (gameState.playerHandSize >= 11) { + action = "stand"; + } else if (playerValue < 17 || (playerValue === 17 && playerIsSoft)) { action = "hit"; } @@ -299,7 +297,10 @@ describe("Blackjack", () => { payer: owner.publicKey, }) .signers([owner]) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); console.log("Player Hit TX Signature:", playerHitSig); console.log("Waiting for player hit computation finalization..."); @@ -343,15 +344,15 @@ describe("Blackjack", () => { ); if (playerValue > 21) { - console.error( - "ERROR: Bust detected after PlayerHitEvent, expected PlayerBustEvent!" - ); - playerBusted = true; + expect( + playerValue, + "Bust detected after PlayerHitEvent, expected PlayerBustEvent" + ).to.be.at.most(21); } } else { console.log("Received PlayerBustEvent."); playerBusted = true; - expect(gameState.gameState).to.deep.equal({ dealerTurn: {} }); + expect(gameState.gameState).to.deep.equal({ resolving: {} }); console.log("Player BUSTED!"); } } catch (e) { @@ -362,6 +363,7 @@ describe("Blackjack", () => { console.log("Player decides to STAND."); const playerStandComputationOffset = new anchor.BN(randomBytes(8)); const playerStandEventPromise = awaitEvent("playerStandEvent"); + const playerBustOnStandEventPromise = awaitEvent("playerBustEvent"); const playerStandSig = await program.methods .playerStand( @@ -387,7 +389,10 @@ describe("Blackjack", () => { payer: owner.publicKey, }) .signers([owner]) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); console.log("Player Stand TX Signature:", playerStandSig); console.log("Waiting for player stand computation finalization..."); @@ -402,16 +407,24 @@ describe("Blackjack", () => { finalizeStandSig ); - const playerStandEvent = await playerStandEventPromise; - console.log( - `Received PlayerStandEvent. Is Bust reported? ${playerStandEvent.isBust}` - ); - expect(playerStandEvent.isBust).to.be.false; + const standEvent = await Promise.race([ + playerStandEventPromise, + playerBustOnStandEventPromise, + ]); - playerStood = true; gameState = await program.account.blackjackGame.fetch(blackjackGamePDA); - expect(gameState.gameState).to.deep.equal({ dealerTurn: {} }); - console.log("Player stands. Proceeding to Dealer's Turn."); + + if (!("clientNonce" in standEvent)) { + console.log("Received PlayerStandEvent."); + playerStood = true; + expect(gameState.gameState).to.deep.equal({ dealerTurn: {} }); + console.log("Player stands. Proceeding to Dealer's Turn."); + } else { + console.log("Received PlayerBustEvent during stand."); + playerBusted = true; + expect(gameState.gameState).to.deep.equal({ resolving: {} }); + console.log("Player BUSTED during stand!"); + } } if (!playerBusted && !playerStood) { @@ -452,7 +465,10 @@ describe("Blackjack", () => { blackjackGame: blackjackGamePDA, }) .signers([owner]) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); console.log("Dealer Play TX Signature:", dealerPlaySig); console.log("Waiting for dealer play computation finalization..."); @@ -487,18 +503,10 @@ describe("Blackjack", () => { ); gameState = await program.account.blackjackGame.fetch(blackjackGamePDA); expect(gameState.gameState).to.deep.equal({ resolving: {} }); - } else if (playerBusted) { - console.log("Player busted, skipping Dealer's Turn."); - console.log( - "Manually considering state as Resolving for test flow after player bust." - ); } gameState = await program.account.blackjackGame.fetch(blackjackGamePDA); - if ( - gameState.gameState.hasOwnProperty("resolving") || - (playerBusted && gameState.gameState.hasOwnProperty("dealerTurn")) - ) { + if (gameState.gameState.hasOwnProperty("resolving")) { console.log("Resolving Game..."); const resolveComputationOffset = new anchor.BN(randomBytes(8)); const resultEventPromise = awaitEvent("resultEvent"); @@ -524,7 +532,10 @@ describe("Blackjack", () => { payer: owner.publicKey, }) .signers([owner]) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); console.log("Resolve Game TX Signature:", resolveSig); console.log("Waiting for resolve game computation finalization..."); @@ -595,7 +606,10 @@ describe("Blackjack", () => { mxeAccount, addressLookupTable: lutAddress, }) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); const rawCircuit = fs.readFileSync("build/shuffle_and_deal_cards.arcis"); await uploadCircuit( @@ -647,7 +661,10 @@ describe("Blackjack", () => { mxeAccount, addressLookupTable: lutAddress, }) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); const rawCircuit = fs.readFileSync("build/player_hit.arcis"); await uploadCircuit( @@ -699,7 +716,10 @@ describe("Blackjack", () => { mxeAccount, addressLookupTable: lutAddress, }) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); const rawCircuit = fs.readFileSync("build/player_stand.arcis"); await uploadCircuit( @@ -751,7 +771,10 @@ describe("Blackjack", () => { mxeAccount, addressLookupTable: lutAddress, }) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); const rawCircuit = fs.readFileSync("build/player_double_down.arcis"); await uploadCircuit( @@ -803,7 +826,10 @@ describe("Blackjack", () => { mxeAccount, addressLookupTable: lutAddress, }) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); const rawCircuit = fs.readFileSync("build/dealer_play.arcis"); await uploadCircuit( @@ -855,7 +881,10 @@ describe("Blackjack", () => { mxeAccount, addressLookupTable: lutAddress, }) - .rpc({ commitment: "confirmed", preflightCommitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); const rawCircuit = fs.readFileSync("build/resolve_game.arcis"); await uploadCircuit( diff --git a/coinflip/Anchor.toml b/coinflip/Anchor.toml index 1eac2dba..dfcdb6d2 100644 --- a/coinflip/Anchor.toml +++ b/coinflip/Anchor.toml @@ -17,3 +17,8 @@ wallet = "~/.config/solana/id.json" [scripts] test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 \"tests/**/*.ts\"" + +[test] +startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/coinflip/tests/coinflip.ts b/coinflip/tests/coinflip.ts index 7563d5db..060c6c7e 100644 --- a/coinflip/tests/coinflip.ts +++ b/coinflip/tests/coinflip.ts @@ -34,16 +34,22 @@ describe("Coinflip", () => { type Event = anchor.IdlEvents<(typeof program)["idl"]>; const awaitEvent = async ( - eventName: E + eventName: E, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { listenerId = program.addEventListener(eventName, (event) => { + clearTimeout(timeoutId); res(event); }); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); - return event; }; @@ -107,7 +113,6 @@ describe("Coinflip", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); console.log("Queue sig is ", queueSig); diff --git a/ed25519/Anchor.toml b/ed25519/Anchor.toml index 31d2d431..9b76f0cd 100644 --- a/ed25519/Anchor.toml +++ b/ed25519/Anchor.toml @@ -20,3 +20,5 @@ test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 \"tests/**/*.ts\"" [test] startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/ed25519/tests/ed_25519.ts b/ed25519/tests/ed_25519.ts index 326e675e..60e853f4 100644 --- a/ed25519/tests/ed_25519.ts +++ b/ed25519/tests/ed_25519.ts @@ -38,16 +38,22 @@ describe("Ed25519", () => { type Event = anchor.IdlEvents<(typeof program)["idl"]>; const awaitEvent = async ( - eventName: E + eventName: E, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { listenerId = program.addEventListener(eventName, (event) => { + clearTimeout(timeoutId); res(event); }); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); - return event; }; @@ -57,6 +63,12 @@ describe("Ed25519", () => { it("sign and verify with MPC Ed25519", async () => { const owner = readKpJson(`${os.homedir()}/.config/solana/id.json`); + const mxePublicKey = await getMXEPublicKeyWithRetry( + provider as anchor.AnchorProvider, + program.programId + ); + console.log("MXE x25519 pubkey:", mxePublicKey); + console.log("Initializing computation definitions"); const initSMSig = await initSignMessageCompDef(program, owner); console.log( @@ -70,13 +82,6 @@ describe("Ed25519", () => { initVSSig ); - const mxePublicKey = await getMXEPublicKeyWithRetry( - provider as anchor.AnchorProvider, - program.programId - ); - - console.log("MXE x25519 pubkey:", mxePublicKey); - console.log("\nSigning message with MPC Ed25519"); let message = new TextEncoder().encode("hello"); @@ -101,7 +106,10 @@ describe("Ed25519", () => { Buffer.from(getCompDefAccOffset("sign_message")).readUInt32LE() ), }) - .rpc({ skipPreflight: true, preflightCommitment: "confirmed", commitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); await awaitComputationFinalization( provider as anchor.AnchorProvider, @@ -212,7 +220,10 @@ describe("Ed25519", () => { Buffer.from(getCompDefAccOffset("verify_signature")).readUInt32LE() ), }) - .rpc({ skipPreflight: true, preflightCommitment: "confirmed", commitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); await awaitComputationFinalization( provider as anchor.AnchorProvider, @@ -346,7 +357,6 @@ async function getMXEPublicKeyWithRetry( maxRetries: number = 20, retryDelayMs: number = 500 ): Promise { - console.log(""); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const mxePublicKey = await getMXEPublicKey(provider, programId); diff --git a/rock_paper_scissors/against-house/Anchor.toml b/rock_paper_scissors/against-house/Anchor.toml index 0b6a1f37..813a84fe 100644 --- a/rock_paper_scissors/against-house/Anchor.toml +++ b/rock_paper_scissors/against-house/Anchor.toml @@ -17,3 +17,8 @@ wallet = "~/.config/solana/id.json" [scripts] test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 \"tests/**/*.ts\"" + +[test] +startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/rock_paper_scissors/against-house/encrypted-ixs/src/lib.rs b/rock_paper_scissors/against-house/encrypted-ixs/src/lib.rs index 9adb5f27..751c101a 100644 --- a/rock_paper_scissors/against-house/encrypted-ixs/src/lib.rs +++ b/rock_paper_scissors/against-house/encrypted-ixs/src/lib.rs @@ -13,20 +13,31 @@ mod circuits { pub fn play_rps(player_move_ctxt: Enc) -> u8 { let player_move = player_move_ctxt.to_arcis(); - let first_bit = ArcisRNG::bool(); - let second_bit = ArcisRNG::bool(); + let mut house_move: u8 = 0; + let mut selected = false; - let house_move = if first_bit { - if second_bit { - 0 + for _ in 0..16 { + let b0 = ArcisRNG::bool(); + let b1 = ArcisRNG::bool(); + + let candidate: u8 = if b0 { + if b1 { + 3 + } else { + 2 + } + } else if b1 { + 1 } else { - 2 - } - } else if second_bit { - 1 - } else { - 0 - }; + 0 + }; + + let candidate_valid = candidate < 3; + let take = (!selected) & candidate_valid; + + house_move = if take { candidate } else { house_move }; + selected = selected | candidate_valid; + } // 0 - tie, 1 - player wins, 2 - house wins, 3 - invalid move let result = if player_move.player_move > 2 { diff --git a/rock_paper_scissors/against-house/tests/rock_paper_scissors_against_rng.ts b/rock_paper_scissors/against-house/tests/rock_paper_scissors_against_rng.ts index a2c16d7d..95517355 100644 --- a/rock_paper_scissors/against-house/tests/rock_paper_scissors_against_rng.ts +++ b/rock_paper_scissors/against-house/tests/rock_paper_scissors_against_rng.ts @@ -34,16 +34,22 @@ describe("RockPaperScissorsAgainstRng", () => { type Event = anchor.IdlEvents<(typeof program)["idl"]>; const awaitEvent = async ( - eventName: E + eventName: E, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { listenerId = program.addEventListener(eventName, (event) => { + clearTimeout(timeoutId); res(event); }); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); - return event; }; @@ -105,7 +111,10 @@ describe("RockPaperScissorsAgainstRng", () => { Buffer.from(getCompDefAccOffset("play_rps")).readUInt32LE() ), }) - .rpc({ preflightCommitment: "confirmed", commitment: "confirmed" }); + .rpc({ + skipPreflight: true, + commitment: "confirmed", + }); console.log("Queue sig is ", queueSig); const finalizeSig = await awaitComputationFinalization( diff --git a/rock_paper_scissors/against-player/Anchor.toml b/rock_paper_scissors/against-player/Anchor.toml index 29bef737..f9040959 100644 --- a/rock_paper_scissors/against-player/Anchor.toml +++ b/rock_paper_scissors/against-player/Anchor.toml @@ -17,3 +17,8 @@ wallet = "~/.config/solana/id.json" [scripts] test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 \"tests/**/*.ts\"" + +[test] +startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/rock_paper_scissors/against-player/tests/rock_paper_scissors.ts b/rock_paper_scissors/against-player/tests/rock_paper_scissors.ts index 5b3d6a4b..95e4fa41 100644 --- a/rock_paper_scissors/against-player/tests/rock_paper_scissors.ts +++ b/rock_paper_scissors/against-player/tests/rock_paper_scissors.ts @@ -36,16 +36,22 @@ describe("RockPaperScissors", () => { type Event = anchor.IdlEvents<(typeof program)["idl"]>; const awaitEvent = async ( - eventName: E + eventName: E, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { listenerId = program.addEventListener(eventName, (event) => { + clearTimeout(timeoutId); res(event); }); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); - return event; }; @@ -142,7 +148,6 @@ describe("RockPaperScissors", () => { .signers([owner]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -219,7 +224,6 @@ describe("RockPaperScissors", () => { .signers([playerA]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -296,7 +300,6 @@ describe("RockPaperScissors", () => { .signers([playerB]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -345,7 +348,6 @@ describe("RockPaperScissors", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -412,7 +414,6 @@ describe("RockPaperScissors", () => { .signers([owner]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -489,7 +490,6 @@ describe("RockPaperScissors", () => { .signers([unauthorizedPlayer]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -567,7 +567,6 @@ describe("RockPaperScissors", () => { .signers([owner]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -627,7 +626,6 @@ describe("RockPaperScissors", () => { .signers([playerA]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -687,7 +685,6 @@ describe("RockPaperScissors", () => { .signers([playerB]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -736,7 +733,6 @@ describe("RockPaperScissors", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -808,7 +804,6 @@ describe("RockPaperScissors", () => { .signers([owner]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -870,7 +865,6 @@ describe("RockPaperScissors", () => { .signers([playerA]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -929,7 +923,6 @@ describe("RockPaperScissors", () => { .signers([playerB]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -975,7 +968,6 @@ describe("RockPaperScissors", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); diff --git a/sealed_bid_auction/Anchor.toml b/sealed_bid_auction/Anchor.toml index b5f82846..ae95393c 100644 --- a/sealed_bid_auction/Anchor.toml +++ b/sealed_bid_auction/Anchor.toml @@ -17,3 +17,8 @@ wallet = "~/.config/solana/id.json" [scripts] test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 \"tests/**/*.ts\"" + +[test] +startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/sealed_bid_auction/README.md b/sealed_bid_auction/README.md index 5a5757ac..68b7ef6f 100644 --- a/sealed_bid_auction/README.md +++ b/sealed_bid_auction/README.md @@ -75,7 +75,7 @@ pub struct AuctionState { pub highest_bid: u64, pub highest_bidder: SerializedSolanaPublicKey, // Winner pubkey (lo/hi u128 pair) pub second_highest_bid: u64, // Required for Vickrey auctions - pub bid_count: u8, + pub bid_count: u16, } ``` @@ -156,3 +156,9 @@ Apply sealed-bid auctions when: **Example applications**: NFT auctions, ad bidding systems, procurement contracts, treasury bond auctions, spectrum license sales. This pattern extends to any scenario requiring private comparison and selection: hiring decisions, grant allocations, or matching markets where selection criteria should remain confidential. + +## Known Limitations + +**`min_bid` not enforced against encrypted bids.** The `min_bid` field is stored on-chain but never checked -- on-chain validation is impossible (the bid is encrypted), and circuit-side validation would require passing `min_bid` as a plaintext argument. For production, pass `min_bid` into the circuit and compare before updating state. + +**No per-bidder deduplication.** A bidder can submit multiple bids. This is non-exploitable: in Vickrey mode, duplicate bids can only increase the second-highest price (hurting the bidder). In first-price mode, the bidder always pays their highest bid regardless. The `bid_count` field reflects total bids, not unique bidders. diff --git a/sealed_bid_auction/encrypted-ixs/src/lib.rs b/sealed_bid_auction/encrypted-ixs/src/lib.rs index de65096d..2200faa5 100644 --- a/sealed_bid_auction/encrypted-ixs/src/lib.rs +++ b/sealed_bid_auction/encrypted-ixs/src/lib.rs @@ -13,7 +13,7 @@ mod circuits { pub highest_bid: u64, pub highest_bidder: SerializedSolanaPublicKey, pub second_highest_bid: u64, - pub bid_count: u8, + pub bid_count: u16, } pub struct AuctionResult { diff --git a/sealed_bid_auction/programs/sealed_bid_auction/src/lib.rs b/sealed_bid_auction/programs/sealed_bid_auction/src/lib.rs index f3067e6e..0cfc6dfb 100644 --- a/sealed_bid_auction/programs/sealed_bid_auction/src/lib.rs +++ b/sealed_bid_auction/programs/sealed_bid_auction/src/lib.rs @@ -8,6 +8,10 @@ const COMP_DEF_OFFSET_DETERMINE_WINNER_FIRST_PRICE: u32 = comp_def_offset("determine_winner_first_price"); const COMP_DEF_OFFSET_DETERMINE_WINNER_VICKREY: u32 = comp_def_offset("determine_winner_vickrey"); +// Auction account byte offset: 8 (discriminator) + 1 + 32 + 1 + 1 + 8 + 8 + 2 + 16 = 77 +const ENCRYPTED_STATE_OFFSET: u32 = 77; +const ENCRYPTED_STATE_SIZE: u32 = 32 * 5; + declare_id!("CHFR2eD8dmZ5NM7UbwM7nWFVTfWPpdtKfv6H4Bgtha3e"); #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)] @@ -56,7 +60,7 @@ pub mod sealed_bid_auction { computation_offset: u64, auction_type: AuctionType, min_bid: u64, - end_time: i64, + duration: i64, ) -> Result<()> { let auction = &mut ctx.accounts.auction; auction.bump = ctx.bumps.auction; @@ -64,7 +68,8 @@ pub mod sealed_bid_auction { auction.auction_type = auction_type; auction.status = AuctionStatus::Open; auction.min_bid = min_bid; - auction.end_time = end_time; + let clock = Clock::get()?; + auction.end_time = clock.unix_timestamp + duration; auction.bid_count = 0; auction.encrypted_state = [[0u8; 32]; 5]; @@ -139,13 +144,13 @@ pub mod sealed_bid_auction { auction.status == AuctionStatus::Open, ErrorCode::AuctionNotOpen ); + require!( + Clock::get()?.unix_timestamp < auction.end_time, + ErrorCode::AuctionEnded + ); ctx.accounts.sign_pda_account.bump = ctx.bumps.sign_pda_account; - // Account offset: 8 (discriminator) + 1 + 32 + 1 + 1 + 8 + 8 + 1 + 16 = 76 - const ENCRYPTED_STATE_OFFSET: u32 = 76; - const ENCRYPTED_STATE_SIZE: u32 = 32 * 5; - let args = ArgBuilder::new() .x25519_pubkey(bidder_pubkey) .plaintext_u128(nonce) @@ -196,7 +201,10 @@ pub mod sealed_bid_auction { let auction = &mut ctx.accounts.auction; auction.encrypted_state = o.ciphertexts; auction.state_nonce = o.nonce; - auction.bid_count += 1; + auction.bid_count = auction + .bid_count + .checked_add(1) + .ok_or(ErrorCode::BidCountOverflow)?; emit!(BidPlacedEvent { auction: auction_key, @@ -212,6 +220,10 @@ pub mod sealed_bid_auction { auction.status == AuctionStatus::Open, ErrorCode::AuctionNotOpen ); + require!( + Clock::get()?.unix_timestamp >= auction.end_time, + ErrorCode::AuctionNotEnded + ); auction.status = AuctionStatus::Closed; emit!(AuctionClosedEvent { @@ -235,12 +247,10 @@ pub mod sealed_bid_auction { auction.auction_type == AuctionType::FirstPrice, ErrorCode::WrongAuctionType ); + require!(auction.bid_count > 0, ErrorCode::NoBids); ctx.accounts.sign_pda_account.bump = ctx.bumps.sign_pda_account; - const ENCRYPTED_STATE_OFFSET: u32 = 8 + 1 + 32 + 1 + 1 + 8 + 8 + 1 + 16; - const ENCRYPTED_STATE_SIZE: u32 = 32 * 5; - let args = ArgBuilder::new() .plaintext_u128(auction.state_nonce) .account( @@ -324,12 +334,10 @@ pub mod sealed_bid_auction { auction.auction_type == AuctionType::Vickrey, ErrorCode::WrongAuctionType ); + require!(auction.bid_count > 0, ErrorCode::NoBids); ctx.accounts.sign_pda_account.bump = ctx.bumps.sign_pda_account; - const ENCRYPTED_STATE_OFFSET: u32 = 8 + 1 + 32 + 1 + 1 + 8 + 8 + 1 + 16; - const ENCRYPTED_STATE_SIZE: u32 = 32 * 5; - let args = ArgBuilder::new() .plaintext_u128(auction.state_nonce) .account( @@ -410,7 +418,7 @@ pub struct Auction { pub status: AuctionStatus, pub min_bid: u64, pub end_time: i64, - pub bid_count: u8, + pub bid_count: u16, pub state_nonce: u128, pub encrypted_state: [[u8; 32]; 5], } @@ -428,7 +436,7 @@ pub struct CreateAuction<'info> { seeds = [b"auction", authority.key().as_ref()], bump, )] - pub auction: Account<'info, Auction>, + pub auction: Box>, #[account( init_if_needed, space = 9, @@ -489,7 +497,7 @@ pub struct PlaceBid<'info> { #[account(mut)] pub bidder: Signer<'info>, #[account(mut)] - pub auction: Account<'info, Auction>, + pub auction: Box>, #[account( init_if_needed, space = 9, @@ -561,7 +569,7 @@ pub struct DetermineWinnerFirstPrice<'info> { #[account(mut)] pub authority: Signer<'info>, #[account(mut, has_one = authority @ ErrorCode::Unauthorized)] - pub auction: Account<'info, Auction>, + pub auction: Box>, #[account( init_if_needed, space = 9, @@ -622,7 +630,7 @@ pub struct DetermineWinnerVickrey<'info> { #[account(mut)] pub authority: Signer<'info>, #[account(mut, has_one = authority @ ErrorCode::Unauthorized)] - pub auction: Account<'info, Auction>, + pub auction: Box>, #[account( init_if_needed, space = 9, @@ -768,13 +776,13 @@ pub struct AuctionCreatedEvent { #[event] pub struct BidPlacedEvent { pub auction: Pubkey, - pub bid_count: u8, + pub bid_count: u16, } #[event] pub struct AuctionClosedEvent { pub auction: Pubkey, - pub bid_count: u8, + pub bid_count: u16, } #[event] @@ -799,4 +807,12 @@ pub enum ErrorCode { WrongAuctionType, #[msg("Unauthorized")] Unauthorized, + #[msg("Auction has ended")] + AuctionEnded, + #[msg("Auction has not ended yet")] + AuctionNotEnded, + #[msg("Bid count overflow")] + BidCountOverflow, + #[msg("No bids placed")] + NoBids, } diff --git a/sealed_bid_auction/tests/sealed_bid_auction.ts b/sealed_bid_auction/tests/sealed_bid_auction.ts index b3e3968a..855160d0 100644 --- a/sealed_bid_auction/tests/sealed_bid_auction.ts +++ b/sealed_bid_auction/tests/sealed_bid_auction.ts @@ -51,14 +51,43 @@ describe("SealedBidAuction", () => { const provider = anchor.getProvider(); type Event = anchor.IdlEvents<(typeof program)["idl"]>; + + async function getValidatorTimestamp( + connection: anchor.web3.Connection + ): Promise { + const slot = await connection.getSlot("confirmed"); + const blockTime = await connection.getBlockTime(slot); + if (blockTime === null) { + throw new Error("Could not fetch block time from validator"); + } + return blockTime; + } + const awaitEvent = async ( - eventName: E + eventName: E, + auctionKey?: PublicKey, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { - listenerId = program.addEventListener(eventName, (event) => { - res(event); - }); + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { + listenerId = program.addEventListener( + eventName, + (event: Record) => { + if ( + auctionKey && + event.auction instanceof PublicKey && + !event.auction.equals(auctionKey) + ) + return; + clearTimeout(timeoutId); + res(event as Event[E]); + } + ); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); return event; @@ -122,7 +151,6 @@ describe("SealedBidAuction", () => { // Step 1: Create First-Price Auction console.log("Step 1: Creating first-price auction..."); - const auctionCreatedPromise = awaitEvent("auctionCreatedEvent"); const createComputationOffset = new anchor.BN(randomBytes(8), "hex"); const [auctionPDA] = PublicKey.findProgramAddressSync( @@ -130,12 +158,17 @@ describe("SealedBidAuction", () => { program.programId ); + const auctionCreatedPromise = awaitEvent( + "auctionCreatedEvent", + auctionPDA + ); + const createSig = await program.methods .createAuction( createComputationOffset, { firstPrice: {} }, // AuctionType::FirstPrice new anchor.BN(100), // min_bid: 100 lamports - new anchor.BN(Date.now() / 1000 + 3600) // end_time: 1 hour from now + new anchor.BN(120) // duration: 120 seconds ) .accountsPartial({ authority: owner.publicKey, @@ -159,7 +192,6 @@ describe("SealedBidAuction", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -183,7 +215,7 @@ describe("SealedBidAuction", () => { // Step 2: Place a bid console.log("\nStep 2: Placing bid of 500 lamports..."); - const bidPlacedPromise = awaitEvent("bidPlacedEvent"); + const bidPlacedPromise = awaitEvent("bidPlacedEvent", auctionPDA); const bidComputationOffset = new anchor.BN(randomBytes(8), "hex"); const bidAmount = BigInt(500); @@ -222,7 +254,6 @@ describe("SealedBidAuction", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -241,8 +272,20 @@ describe("SealedBidAuction", () => { expect(bidPlacedEvent.bidCount).to.equal(1); // Step 3: Close auction - console.log("\nStep 3: Closing auction..."); - const auctionClosedPromise = awaitEvent("auctionClosedEvent"); + console.log("\nStep 3: Waiting for auction to end..."); + const auctionAccount = await program.account.auction.fetch(auctionPDA); + const endTime = auctionAccount.endTime.toNumber(); + while (true) { + const currentTime = await getValidatorTimestamp(provider.connection); + if (currentTime >= endTime) break; + const remaining = endTime - currentTime; + console.log( + ` Validator clock: ${currentTime}, end_time: ${endTime}, waiting ${remaining}s...` + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + console.log("Closing auction..."); + const auctionClosedPromise = awaitEvent("auctionClosedEvent", auctionPDA); const closeSig = await program.methods .closeAuction() @@ -259,7 +302,10 @@ describe("SealedBidAuction", () => { // Step 4: Determine winner (first-price) console.log("\nStep 4: Determining winner (first-price)..."); - const auctionResolvedPromise = awaitEvent("auctionResolvedEvent"); + const auctionResolvedPromise = awaitEvent( + "auctionResolvedEvent", + auctionPDA + ); const resolveComputationOffset = new anchor.BN(randomBytes(8), "hex"); const resolveSig = await program.methods @@ -286,7 +332,6 @@ describe("SealedBidAuction", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -355,7 +400,6 @@ describe("SealedBidAuction", () => { // Step 1: Create Vickrey Auction console.log("Step 1: Creating Vickrey auction..."); - const auctionCreatedPromise = awaitEvent("auctionCreatedEvent"); const createComputationOffset = new anchor.BN(randomBytes(8), "hex"); const [vickreyAuctionPDA] = PublicKey.findProgramAddressSync( @@ -363,12 +407,17 @@ describe("SealedBidAuction", () => { program.programId ); + const auctionCreatedPromise = awaitEvent( + "auctionCreatedEvent", + vickreyAuctionPDA + ); + const createSig = await program.methods .createAuction( createComputationOffset, { vickrey: {} }, // AuctionType::Vickrey new anchor.BN(50), // min_bid: 50 lamports - new anchor.BN(Date.now() / 1000 + 3600) + new anchor.BN(120) // duration: 120 seconds ) .accountsPartial({ authority: vickreyAuthority.publicKey, @@ -393,7 +442,6 @@ describe("SealedBidAuction", () => { .signers([vickreyAuthority]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -415,7 +463,7 @@ describe("SealedBidAuction", () => { // Step 2: Place first bid (1000 lamports) console.log("\nStep 2: Placing first bid of 1000 lamports..."); - const bidPlaced1Promise = awaitEvent("bidPlacedEvent"); + const bidPlaced1Promise = awaitEvent("bidPlacedEvent", vickreyAuctionPDA); const bid1ComputationOffset = new anchor.BN(randomBytes(8), "hex"); const bid1Amount = BigInt(1000); @@ -452,7 +500,6 @@ describe("SealedBidAuction", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -470,7 +517,7 @@ describe("SealedBidAuction", () => { // Step 3: Place second bid (700 lamports) - this becomes second-highest console.log("\nStep 3: Placing second bid of 700 lamports..."); - const bidPlaced2Promise = awaitEvent("bidPlacedEvent"); + const bidPlaced2Promise = awaitEvent("bidPlacedEvent", vickreyAuctionPDA); const bid2ComputationOffset = new anchor.BN(randomBytes(8), "hex"); // Use same bidder but different bid amount @@ -513,7 +560,6 @@ describe("SealedBidAuction", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -531,8 +577,25 @@ describe("SealedBidAuction", () => { expect(bidPlaced2Event.bidCount).to.equal(2); // Step 4: Close auction - console.log("\nStep 4: Closing Vickrey auction..."); - const auctionClosedPromise = awaitEvent("auctionClosedEvent"); + console.log("\nStep 4: Waiting for auction to end..."); + const vickreyAuctionAccount = await program.account.auction.fetch( + vickreyAuctionPDA + ); + const vickreyEndTime = vickreyAuctionAccount.endTime.toNumber(); + while (true) { + const currentTime = await getValidatorTimestamp(provider.connection); + if (currentTime >= vickreyEndTime) break; + const remaining = vickreyEndTime - currentTime; + console.log( + ` Validator clock: ${currentTime}, end_time: ${vickreyEndTime}, waiting ${remaining}s...` + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + console.log("Closing Vickrey auction..."); + const auctionClosedPromise = awaitEvent( + "auctionClosedEvent", + vickreyAuctionPDA + ); const closeSig = await program.methods .closeAuction() @@ -550,7 +613,10 @@ describe("SealedBidAuction", () => { // Step 5: Determine winner (Vickrey) console.log("\nStep 5: Determining winner (Vickrey)..."); - const auctionResolvedPromise = awaitEvent("auctionResolvedEvent"); + const auctionResolvedPromise = awaitEvent( + "auctionResolvedEvent", + vickreyAuctionPDA + ); const resolveComputationOffset = new anchor.BN(randomBytes(8), "hex"); const resolveSig = await program.methods @@ -578,7 +644,6 @@ describe("SealedBidAuction", () => { .signers([vickreyAuthority]) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); diff --git a/share_medical_records/Anchor.toml b/share_medical_records/Anchor.toml index 6594643c..3d096e36 100644 --- a/share_medical_records/Anchor.toml +++ b/share_medical_records/Anchor.toml @@ -17,3 +17,8 @@ wallet = "~/.config/solana/id.json" [scripts] test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 \"tests/**/*.ts\"" + +[test] +startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/share_medical_records/programs/share_medical_records/src/lib.rs b/share_medical_records/programs/share_medical_records/src/lib.rs index a0ed12a8..c5933df8 100644 --- a/share_medical_records/programs/share_medical_records/src/lib.rs +++ b/share_medical_records/programs/share_medical_records/src/lib.rs @@ -208,6 +208,10 @@ pub struct SharePatientData<'info> { pub clock_account: Account<'info, ClockAccount>, pub system_program: Program<'info, System>, pub arcium_program: Program<'info, Arcium>, + #[account( + seeds = [b"patient_data", payer.key().as_ref()], + bump, + )] pub patient_data: Account<'info, PatientData>, } diff --git a/share_medical_records/tests/share_medical_records.ts b/share_medical_records/tests/share_medical_records.ts index 7a36e8c4..e129632c 100644 --- a/share_medical_records/tests/share_medical_records.ts +++ b/share_medical_records/tests/share_medical_records.ts @@ -36,16 +36,22 @@ describe("ShareMedicalRecords", () => { type Event = anchor.IdlEvents<(typeof program)["idl"]>; const awaitEvent = async ( - eventName: E + eventName: E, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { listenerId = program.addEventListener(eventName, (event) => { + clearTimeout(timeoutId); res(event); }); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); - return event; }; diff --git a/voting/Anchor.toml b/voting/Anchor.toml index 540f6c18..57c188a6 100644 --- a/voting/Anchor.toml +++ b/voting/Anchor.toml @@ -17,3 +17,8 @@ wallet = "~/.config/solana/id.json" [scripts] test = "yarn run ts-mocha -p ./tsconfig.json -t 2000000 \"tests/**/*.ts\"" + +[test] +startup_wait = 20000 +shutdown_wait = 2000 +upgradeable = false diff --git a/voting/README.md b/voting/README.md index 7ff00a3b..5fcd5ada 100644 --- a/voting/README.md +++ b/voting/README.md @@ -49,6 +49,7 @@ Key properties: - **Ballot secrecy**: Individual votes remain encrypted throughout the tallying process - **Distributed computation**: Arcium nodes jointly compute aggregate tallies - **Result accuracy**: Aggregate totals are computed correctly despite processing only encrypted data +- **Double-vote prevention**: A `VoterRecord` PDA (seeded by poll + voter keys) is initialized via Anchor's `init` constraint in the `vote` instruction — a second vote from the same voter fails because the account already exists ## Implementation Details diff --git a/voting/programs/voting/src/lib.rs b/voting/programs/voting/src/lib.rs index 432a7253..ecbeae6b 100644 --- a/voting/programs/voting/src/lib.rs +++ b/voting/programs/voting/src/lib.rs @@ -120,6 +120,8 @@ pub mod voting { ) .build(); + ctx.accounts.voter_record.bump = ctx.bumps.voter_record; + ctx.accounts.sign_pda_account.bump = ctx.bumps.sign_pda_account; queue_computation( @@ -157,10 +159,9 @@ pub mod voting { ctx.accounts.poll_acc.nonce = o.nonce; let clock = Clock::get()?; - let current_timestamp = clock.unix_timestamp; emit!(VoteEvent { - timestamp: current_timestamp, + timestamp: clock.unix_timestamp, }); Ok(()) @@ -373,7 +374,7 @@ pub struct Vote<'info> { #[account( address = derive_mxe_pda!() )] - pub mxe_account: Account<'info, MXEAccount>, + pub mxe_account: Box>, #[account( mut, address = derive_mempool_pda!(mxe_account, ErrorCode::ClusterNotSet) @@ -423,7 +424,15 @@ pub struct Vote<'info> { bump = poll_acc.bump, has_one = authority )] - pub poll_acc: Account<'info, PollAccount>, + pub poll_acc: Box>, + #[account( + init, + payer = payer, + space = 8 + VoterRecord::INIT_SPACE, + seeds = [b"voter", poll_acc.key().as_ref(), payer.key().as_ref()], + bump, + )] + pub voter_record: Box>, } #[callback_accounts("vote")] @@ -606,6 +615,14 @@ pub struct PollAccount { pub question: String, } +/// Per-poll voter deduplication record. +#[account] +#[derive(InitSpace)] +pub struct VoterRecord { + /// PDA bump seed + pub bump: u8, +} + #[error_code] pub enum ErrorCode { #[msg("Invalid authority")] diff --git a/voting/tests/voting.ts b/voting/tests/voting.ts index c7a766a3..1a1dfae4 100644 --- a/voting/tests/voting.ts +++ b/voting/tests/voting.ts @@ -57,16 +57,22 @@ describe("Voting", () => { type Event = anchor.IdlEvents<(typeof program)["idl"]>; const awaitEvent = async ( - eventName: E + eventName: E, + timeoutMs = 120000 ): Promise => { let listenerId: number; - const event = await new Promise((res) => { + let timeoutId: NodeJS.Timeout; + const event = await new Promise((res, rej) => { listenerId = program.addEventListener(eventName, (event) => { + clearTimeout(timeoutId); res(event); }); + timeoutId = setTimeout(() => { + program.removeEventListener(listenerId); + rej(new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); await program.removeEventListener(listenerId); - return event; }; @@ -140,7 +146,6 @@ describe("Voting", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); @@ -157,6 +162,8 @@ describe("Voting", () => { // Cast votes for each poll with different outcomes const voteOutcomes = [true, false, true]; // Different outcomes for each poll + let firstPollPDA: PublicKey; + let firstVoterRecordPDA: PublicKey; for (let i = 0; i < POLL_IDS.length; i++) { const POLL_ID = POLL_IDS[i]; const vote = BigInt(voteOutcomes[i]); @@ -169,6 +176,24 @@ describe("Voting", () => { console.log(`Voting for poll ${POLL_ID}`); + // Derive poll PDA (poll_id uses little-endian u32) + const pollIdBuffer = Buffer.alloc(4); + pollIdBuffer.writeUInt32LE(POLL_ID); + const [pollPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("poll"), owner.publicKey.toBuffer(), pollIdBuffer], + program.programId + ); + + const [voterRecordPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("voter"), pollPDA.toBuffer(), owner.publicKey.toBuffer()], + program.programId + ); + + if (i === 0) { + firstPollPDA = pollPDA; + firstVoterRecordPDA = voterRecordPDA; + } + const voteComputationOffset = new anchor.BN(randomBytes(8), "hex"); const queueVoteSig = await program.methods @@ -195,10 +220,11 @@ describe("Voting", () => { Buffer.from(getCompDefAccOffset("vote")).readUInt32LE() ), authority: owner.publicKey, + pollAcc: pollPDA, + voterRecord: voterRecordPDA, }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); console.log(`Queue vote for poll ${POLL_ID} sig is `, queueVoteSig); @@ -218,6 +244,59 @@ describe("Voting", () => { ); } + // Test double-vote prevention: attempt to vote again on the first poll + // Reuse firstPollPDA and firstVoterRecordPDA derived during the voting loop + console.log("\n--- Testing double-vote prevention ---"); + const DOUBLE_VOTE_POLL_ID = POLL_IDS[0]; + const doubleVoteNonce = randomBytes(16); + const doubleVoteCiphertext = cipher.encrypt( + [BigInt(true)], + doubleVoteNonce + ); + + const doubleVoteComputationOffset = new anchor.BN(randomBytes(8), "hex"); + + try { + await program.methods + .vote( + doubleVoteComputationOffset, + DOUBLE_VOTE_POLL_ID, + Array.from(doubleVoteCiphertext[0]), + Array.from(publicKey), + new anchor.BN(deserializeLE(doubleVoteNonce).toString()) + ) + .accountsPartial({ + computationAccount: getComputationAccAddress( + arciumEnv.arciumClusterOffset, + doubleVoteComputationOffset + ), + clusterAccount: clusterAccount, + mxeAccount: getMXEAccAddress(program.programId), + mempoolAccount: getMempoolAccAddress(arciumEnv.arciumClusterOffset), + executingPool: getExecutingPoolAccAddress( + arciumEnv.arciumClusterOffset + ), + compDefAccount: getCompDefAccAddress( + program.programId, + Buffer.from(getCompDefAccOffset("vote")).readUInt32LE() + ), + authority: owner.publicKey, + pollAcc: firstPollPDA, + voterRecord: firstVoterRecordPDA, + }) + .rpc({ + preflightCommitment: "confirmed", + commitment: "confirmed", + }); + + expect.fail("Double vote should have been rejected"); + } catch (error) { + console.log("Double vote correctly rejected:", error.message); + expect(error.message).to.satisfy( + (msg: string) => msg.includes("already in use") || msg.includes("0x0") + ); + } + // Reveal results for each poll for (let i = 0; i < POLL_IDS.length; i++) { const POLL_ID = POLL_IDS[i]; @@ -247,7 +326,6 @@ describe("Voting", () => { }) .rpc({ skipPreflight: true, - preflightCommitment: "confirmed", commitment: "confirmed", }); console.log(`Reveal queue for poll ${POLL_ID} sig is `, revealQueueSig);