🔐 Securing Pool Contracts on Stacks with Signature Verification

Building pool-based systems on the Stacks blockchain is straightforward. But how do we prevent unauthorized withdrawals or tampering in a decentralized pool contract?

This guide walks through how we implemented a secure withdrawal system using message signature verification. We'll walk through the Clarity contract, then show you how to build the signing function on the backend, break it down line by line, and show you how to interact with the contract using Stacks.js.

This same strategy powers secure withdrawals in the Stacks Wars pool contracts.


✨ Goal

We'll build a simple Clarity smart contract where:

  • Players can join a pool by paying a fixed entry fee

  • Players can withdraw from the pool after backend verification

To avoid on-chain computation of winner logic, we allow the backend to sign a message confirming a withdrawal amount, and the contract checks the validity of that signature.


⚖️ Contract Constants and Variables

(define-constant ENTRY_FEE u1000000) ;; 1 STX
(define-constant TRUSTED_PUBLIC_KEY 0x0390a5cac7c33fda49f70bc1b0866fa0ba7a9440d9de647fecb8132ceb76a94dfa)

(define-data-var total-players uint u0)
  • ENTRY_FEE: The required amount to enter the pool, in micro STX (1 STX = 1_000_000 micro STX).

  • TRUSTED_PUBLIC_KEY: The public key used to verify if a message was signed by our backend.

  • total-players: Keeps track of how many players are currently in the pool.


⚠️ Custom Errors

(define-constant ERR_UNAUTHORIZED u100)
(define-constant ERR_SERIALIZATION u103)
(define-constant ERR_INSUFFICIENT_FUNDS u104)

Readable and semantic error codes make debugging and user feedback easier.


🖊️ construct-message-hash

(define-private (construct-message-hash (amount uint))
  (let ((message {
    amount: amount,
    winner: tx-sender,
    contract: (as-contract tx-sender)
  }))
    (match (to-consensus-buff? message)
      buff (ok (sha256 buff))
      (err ERR_SERIALIZATION))))
  • This function creates the exact message we expect the backend to sign.

  • It builds a tuple containing:

    • amount: The withdrawal amount

    • winner: The player requesting withdrawal

    • contract: The contract address added to prevent replay attacks, be sure to use a unique identifier depending on you case.

  • The hash of this tuple is what will be verified later.


join-pool

(define-public (join-pool)
  (begin
    (try! (stx-transfer? ENTRY_FEE tx-sender (as-contract tx-sender)))
    (var-set total-players (+ (var-get total-players) u1))
    (ok true)))
  • Transfers ENTRY_FEE from the player to the contract.

  • Increments total-players by 1.


🎉 withdraw

(define-public (withdraw (amount uint) (signature (buff 65)))
  (begin
    (let (
      (msg-hash (try! (construct-message-hash amount)))
      (recipient tx-sender))
    (asserts! (secp256k1-verify msg-hash signature TRUSTED_PUBLIC_KEY) (err ERR_UNAUTHORIZED))
    (try! (as-contract (stx-transfer? amount tx-sender recipient)))
    (ok true))))
  • Uses the hash and signature to verify authenticity.

  • If valid, transfers the amount from contract to player.

  • Makes sure only messages signed by the trusted backend can trigger reward withdrawal.


💻 Backend: Signing the Withdrawal

We use the Stacks JS tooling to generate the signature. Specifically, this was implemented using the following versions:

"dependencies": {
    "@stacks/wallet-sdk": "^7.0.5", 
    "@stacks/transactions": "^7.0.6", 
    "@stacks/connect": "^8.1.9"
}
import { generateWallet } from "@stacks/wallet-sdk";
import { createHash } from "crypto";

const getSignerPrivateKey = async () => {
  const secretKey = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw";
  const wallet = await generateWallet({ secretKey, password: "" });
  return wallet.accounts[0].stxPrivateKey;
};

✉️ generateSignature

import {
  principalCV,
  serializeCV,
  signMessageHashRsv,
  tupleCV,
  uintCV,
} from "@stacks/transactions";

export const generateSignature = async (
  amount: number,
  claimerAddress: string,
  contractAddress: `${string}.${string}`
) => {
  const message = tupleCV({
    amount: uintCV(amount),
    winner: principalCV(claimerAddress),
    contract: principalCV(contractAddress),
  });
  const serialized = serializeCV(message);
  const buffer = Buffer.from(serialized, "hex");
  const hash = createHash("sha256").update(buffer).digest();

  const privateKey = await getSignerPrivateKey();

  return signMessageHashRsv({
    messageHash: hash.toString("hex"),
    privateKey,
  });
};

🧪 Interacting with the Contract

Here’s how to interact with the contract at ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.factory, assuming amount = 1 STX:

import { ClarityType, StxPostCondition } from "@stacks/transactions";
import { request } from "@stacks/connect";

export const joinPool = async () => {
  const address = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM";

  const stxPostCondition: StxPostCondition = {
    type: "stx-postcondition",
    address,
    condition: "eq",
    amount: 1_000_000,
  };

  return await request("stx_callContract", {
    contract: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.factory",
    functionName: "join-pool",
    functionArgs: [],
    network: "testnet",
    postConditionMode: "deny",
    postConditions: [stxPostCondition],
  });
};

export const withdrwalFromPool = async () => {
  const contract = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.factory";
  const walletAddress = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM";
  const amount = 1_000_000;

  const signature = await generateSignature(amount, walletAddress, contract);

  const stxPostCondition: StxPostCondition = {
    type: "stx-postcondition",
    address: contract,
    condition: "lte",
    amount,
  };

  return await request("stx_callContract", {
    contract,
    functionName: "withdraw",
    functionArgs: [
      { type: ClarityType.UInt, value: amount },
      { type: ClarityType.Buffer, value: signature },
    ],
    network: "testnet",
    postConditionMode: "deny",
    postConditions: [stxPostCondition],
  });
};

🚀 Summary

You now have a secure and extensible setup to:

  • Let users join a pool

  • Let verified users withdraw funds using backend-generated signatures

This approach keeps logic off-chain and verifies it on-chain, just like we did in Stacks Wars pool contracts.

2
Subscribe to my newsletter

Read articles from Isaac Omenuche (Flames) directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Isaac Omenuche (Flames)
Isaac Omenuche (Flames)