SDK - Shuffle

Hide card information and ensure card security.

Environment

  • Language: javascript/typescript

Overview

Almost all multi-player card games require a fair and unpredictable shuffling mechanism. Simply using random numbers on the chain cannot meet this demand. Therefore, Zypher provides Shuffle solution, and the usage process is as follows.

graph LR;
    Start(["N Players join game \n with their own public keys"])

    Aggregate["Aggregate N public keys \n to generate a shared key"]
    Mask["Mask the deck \n by the shared key"]
    Shuffle{{"All players \n shuffle the deck"}}

    CollectOtherRevealTokens["Collect reveal tokens (N-1) \n from other players"]
    ClientUnmask["Player can check the card locally"]
    CollectAllevealTokens["Collect all reveal tokens (N)"]
    ChainUnmask["Everybody can check the card on chain"]

    Start --> Aggregate
    subgraph Shuffling
    Aggregate --> Mask --> Shuffle
    end

    subgraph Revealing
    Shuffle --> CollectOtherRevealTokens --> ClientUnmask
    Shuffle --> CollectAllevealTokens --> ChainUnmask
    end

Include:

  • The beginning of the game

    • All players provide their own public key, which is used to generate the game aggregated key for the game.
    • We use the aggregated key to mask the originally public cards into a deck dedicated to the game.
  • Shuffling stage

    • Each player takes turns shuffling the deck and updating the deck based on the previous deck state.
    • When updating the deck, we will use the ZK mechanism to ensure that players have the ability to disrupt the deck based on our rules.
  • opening stage

    • For the card whose result is to be displayed, the correct card content can be displayed by gathering the reveal tokens of all players.
    • When submitting reveal tokens, the contract can ensure that each player provides the tokens that comply with the rules through the ZK mechanism.
    • If only a single player wants to see his hand, other players need to calculate the corresponding token of their hand and give it to that player.
    • If the card is to be fully disclosed, all players will hand over the corresponding tokens to the contract, and the card will be confirmed through on-chain calculations.

Below we use a standard game process to demonstrate how to use the Shuffle solution we provide at each stage.

Installation

We package the WASM and types used on the front-end and the verifier interfaces used on the contract, and upload to NPM packages. It can be downloaded to the development environment through npm:

$ npm i @zypher-game/secret-engine

For the contract part, please select the verifiers address from the table below:

NetworkNameAddress
opBNB TestnetShuffleVerifier (20)0x1De814F9A303253288fa9195C6e7aae7cabB6670
opBNB TestnetShuffleVerifier (52)0xfbDF4217a3959cE4D3c39b240959c800e3c9E640
opBNB TestnetRevealVerifier0x8d084e5c212834456c07Cef2c1e2a258fF04b5eb
Arbitrum Sepolia TestnetShuffleVerifier (20)0x.....
Arbitrum Sepolia TestnetShuffleVerifier (52)0x.....
Arbitrum Sepolia TestnetRevealVerifier0x.....

Game Starts

sequenceDiagram
    actor C as Clients
    participant S as Smart Contract

    C ->>+ S: Join a game (public key)
    NOTE right of S: Aggregate public keys to create the game key
    S -->>- C: game key

At the beginning of the game, each player provides his or her public key. After having the public keys of all players, the contract can calculate the game aggregated key specific to the game.

/**
 * @typedef {`0x${string}`} Hex - A hex string to represent a uint256 type in smart contract,
 *                                starts with 0x, and followed by 64 0-F's.
 * @typedef {`0x${string}`} Bytes - A hex string to represent a bytes type in smart contract,
 *                                  starts with 0x, and followed by various counts of 0-F's
 */

import * as SE from '@zypher-game/secret-engine'
// SE = require('@zypher-game/secret-engine')

/** @type {{ sk: Hex, pk: Hex, pkxy: [Hex, Hex] }} */
const key = SE.generate_key()
// {
//   sk: '0x013c0b55d277082e76dbebccb9bfc880f1ba0da01888151cc276acfa38984067',
//   pk: '0x792c82fa73288f6c90d25225db24349ddb64a01808c836232f14f5c0f770421d',
//   pkxy: [
//     '0x033a5235209daa9f2831ce6153581fb708d5441d606bdc5b1cbe7bd02b6c9bde',
//     '0x1d4270f7c0f5142f2336c80818a064db9d3424db2552d2906c8f2873fa822c79'
//   ]
// }

// send key.pkxy to smart contract to aggregate the game-key

Players provide their key.pkxy to the contract to generate the encryption key for the game.

After all players have provided public keys, the game aggregated key can be generated through ZgRevealVerifier::aggregateKeys on the contract:

import { ZgRevealVerifier, Point } from "@zypher-game/secret-engine/Verifiers.sol";

address revealVerifier = 0xRevealVerifierAddress;

contract Game {
    Point[] private publicKeys;
    Point public gameKey;

    function createGameKey() public {
        gameKey = ZgRevealVerifier(revealVerifier).aggregateKeys(publicKeys);
    }
}

Mask the Game Deck

Now, players can obtain the game aggregated key specific to the current game from the contract, and the first player to shuffle the deck needs to mask the entire deck with the game key before shuffling the deck.

sequenceDiagram
    actor C as Clients
    participant S as Smart Contract

    C -->> S: Join a game (public key)
    S -->>+ C: Game started (game key)

    Note right of C: mask the deck with game key
    C -->>- S: Shuffle the deck

We do this part on the client side:

// Read from Game::gameKey above
// You can transform UInt256 to Hex form by ethers.BigNumber.toHexString() or Viem.toHex(BigInt)
const GAME_KEY = {
  // 19617196613779207970341794679927977798923702524850702039209699977369237063600
  x: '0x2b5ef0976407ba9bcbaff98bd98463845e33431dee905378db8577f6cd3407b0',
  // 4588353255913810257093228132198460617090012643664498862176822601823348662802
  y: '0x0a24ea792f01d36b0842ef58670e8d3fa53c68be4f2b6ec4985d25fa65c92212',
}

/** @type {Hex} */
const gameKey = SE.public_compres([
  GAME_KEY.x,
  GAME_KEY.y,
])
// 0x1222c965fa255d98c46e2b4fbe683ca53f8d0e6758ef42086bd3012f79ea248a

const DECK_SIZE = 52
SE.init_prover_key(DECK_SIZE)
/** @type {Hex[24]} */
const pkc = SE.refresh_joint_key(gameKey, DECK_SIZE)
// ['0x1943430a6dd86225d689b616b0efde793fbd6f3d24ba1a4ee2a8f0ec4f16fed2', ...]

/** @type {{ card: [Hex, Hex, Hex, Hex], proof: Bytes }[]} */
const maskedDeck = SE.init_masked_cards(gameKey, DECK_SIZE)

After the first player has masked the deck, he can shuffle the deck directly locally, and we continue to the next section.

Shuffle the Game Deck

sequenceDiagram
    actor C as Clients
    participant S as Smart Contract

    box Zypher Games on-chain verifiers
    participant SV as Shuffle Verifier
    end

    C -->> S: Join a game (public key)
    S -->> C: Game started (game key)

    Note over C: mask the deck with game key
    C ->> S: Shuffle the deck
    S ->> SV: verify
    SV -->> S: verified
    S --> C: next

If it is the first player to shuffle the cards, we directly adjust the result of the previous step to the same specifications as the contract. If it is the second player or later, the deck will be shuffled directly with the deck found on the contract.

/** @type {[Hex, Hex, Hex, Hex][]} */
let deckBefore = []

// 1st shuffler only:
deckBefore = maskedDeck.map((masked) => masked.card)
// 2nd to last shufflers, the deck is read from the game contract.
deckBefore = (await game.deck()).map(hashes => hashes.map(bn => bn.toHexString()))

/** @type {{ cards: [Hex, Hex, Hex, Hex][], proof: Bytes }} */
const shuffled = SE.shuffle_cards(gameKey, deckBefore)

// Send pkc & shuffled.cards & shuffled.proof to the game contract.

When the client completes the shuffling, the previous and new deck, proof and pkc are submitting to the contract to verify and update the deck.

import { ZgShuffleVerifier } from "@zypher-game/secret-engine/Verifiers.sol";

address shuffleVerifier = 0xShuffleVerifierAddress;

contract Game {
    uint256[4][] public deck;
    uint256[24] public pkc;

    // Only the 1st shuffler; require the masked deck (before) & pkc
    function initShuffle(
      uint256[24] calldata pkc_,
      uint256[4][52] calldata before_,
      uint256[4][52] calldata after_,
      bytes calldata proof_
    ) public {
        require(deck.length == 0); // Only for the 1st time shuffler
        pkc = pkc_;
        uint256[] memory input = new uint256[](52 * 4 * 2);

        for (uint8 i = 0; i < 52; i++) {
            input[i * 4 + 0] = before_[i][0];
            input[i * 4 + 1] = before_[i][1];
            input[i * 4 + 2] = before_[i][2];
            input[i * 4 + 3] = before_[i][3];

            input[i * 4 + 0 + 208] = after_[i][0];
            input[i * 4 + 1 + 208] = after_[i][1];
            input[i * 4 + 2 + 208] = after_[i][2];
            input[i * 4 + 3 + 208] = after_[i][3];
        }

        require(
            ZgShuffleVerifier(shuffleVerifier).verifyShuffle(proof_, input, pkc_)
        );

        pkc = pkc_;
        deck = after_;
    }

    // 2nd .. Nth shuffle
    function shuffle(
        uint256[4][52] calldata after_,
        bytes calldata proof_
    ) public {
        require(deck.length == 52);

        uint256[] memory input = new uint256[](52 * 4 * 2);

        for (uint8 i = 0; i < 52; i++) {
            input[i * 4 + 0] = deck[i][0];
            input[i * 4 + 1] = deck[i][1];
            input[i * 4 + 2] = deck[i][2];
            input[i * 4 + 3] = deck[i][3];

            input[i * 4 + 0 + 208] = after_[i][0];
            input[i * 4 + 1 + 208] = after_[i][1];
            input[i * 4 + 2 + 208] = after_[i][2];
            input[i * 4 + 3 + 208] = after_[i][3];
        }

        require(
            ZgShuffleVerifier(shuffleVerifier).verifyShuffle(proof_, input, pkc)
        );

        deck = after_;
    }
}

Submit ZK Reveal Tokens

After all players have shuffled the deck, if player want the "value" of any card, they must collect the ZK information (reveal token) provided by all other players to unlock it.

When players submit card reveal tokens, they can ensure their correctness with the Reveal Verifier we provide:

sequenceDiagram
    actor C as Clients
    participant S as Smart Contract

    box Zypher Games on-chain verifiers
    participant SV as Shuffle Verifier
    participant RV as Reveal Verifier
    end

    C -->> S: Join a game (public key)
    S -->> C: Game started (game key)

    Note over C: mask the deck with game key

    loop every player shuffled once
    C -->> S: Shuffle the deck
    S -->> SV: verify
    SV -->> S: verified
    end

    Note over C,S: every player shuffled once


    C ->> S: Single card ZK reveal token
    S ->> RV: verify
    RV -->> S: verified

To generate the ZK reveal token of any card, it can be calculated on the client side using the player's secret key:

// Read from the Game contract deck, format the card into Hex string form:
const card = [
  '0x2e38a93db7f4a1da169218f5eccd3ff5211b1708913f1b202a8c4b8ece29e8d7',
  '0x0e5b527f880591f57d6acc82b72f526ebff10ff88f7a12d8abb6ed842e25b4cc',
  '0x1e7737dbacb218ad9f6ff22895ba8fb6b3394f41d581a8fb60d76e2cbeecee2e',
  '0x2fc94d679ff1d9723d7a18f23b2c4d7ccfdb91014b61398ed2e17d50c5838292',
]

SE.init_reveal_key() // Cache data when client is free

/** @type { card: [Hex, Hex], snark_proof: Hex[8] } */
const res = SE.reveal_card_with_snark(sk, card)

// Send reveal token (res.card) & proof (res.snark_proof) to contract to provide to other players

When the contract receives the ZK reveal token and proof, it can be verified online instantly:

import { ZgRevealVerifier, Point } from "@zypher-game/secret-engine/Verifiers.sol";

address revealVerifier = 0xRevealVerifierAddress;

contract Game {
    mapping(address => Point) public publicKeys;

    uint256[4][] public deck;
    mapping(uint256 => Point[]) public rTokens;

    function revealCard(
        uint8 index,
        Point calldata revealToken,
        uint256[8] calldata proof
    ) public {
        require(
            ZgRevealVerifier(revealVerifier).verifyRevealWithSnark(
                [
                    deck[index][2],
                    deck[index][3],
                    revealToken.x,
                    revealToken.y,
                    publicKeys[msg.sender].x,
                    publicKeys[msg.sender].y
                ],
                proof
            )
        );

        rTokens[index].push(revealToken);
    }
}

After collecting a sufficient number of reveal tokens, player will get the card's "value".

Reveal Card

There are two calculation methods, one is calculated on the client side, and the other is calculated on the contract:

  • Local:

    • Through reveal tokens provided by other players, player can locally reveal cards whose results only player can see:
    /** @type {number} */
    const cardId = SE.unmask_card(key.sk, deck[index], rTokens)
    // 8 (0 - 51)
    
  • Public in the contract:

    • All players provide reveal tokens, and the contract reveals the card through Reveal Verifier:
    import { ZgRevealVerifier, MaskedCard } from "@zypher-game/secret-engine/Verifiers.sol";
    
    uint8 cardId = ZgRevealVerifier(revealVerifier).unmaskCard(
        MaskedCard(deck[index][0], deck[index][1], deck[index][2], deck[index][3]),
        rTokens[index]
    );
    // 8
    

Through the above two methods, the common card shuffling and opening process can be achieved.