Finding Alice and Bob in Wonderland: a writeup of Croupier, the Keybase bot

You may have observed that plenty respectable, prolific Hacker News users0 have added a Keybase signature to their profile:

Hacker_News
Seems that 20%+ of the Hacker News leaderboard has a Keybase signature in their profile…

So. What is Keybase?

Keybase’s mission is to

make cryptographically secure communication, cryptocurrency, and identity management into a user-friendly experience

Well, that’s a tall order…1 

Anyway, Keybase is like Slack, except free.  

keybase_chat_screenshot____.png

And transparently encrypted.  And early stage software; all of the error handling is transparent.  And open source.  And the currently-small user population of ~300,000 is mostly programmers.  And the team is responsive to community requests2.

In March 2019, Keybase issued a pair of press releases that made it to the top of Hacker News. 

Please allow me to TL;DR these:

  • Users may now generate randomness, in a way that’s indisputably fair, within Keybase chat conversations.

    coin_animation2
  • Users may now send money in chat in a conversational way.
    keybase salad.png
  • The Stellar foundation, backed by some of the smartest people in tech including Patrick Collison and Keith Rabois, backs Keybase.  Sure, social proof matrices have their flaws, but if it worked for Google and PageRank…

Anyway, the sudden emergence of these two technologies – integrated moneysending and trustable randomness – led to a few synapses sparking and – as neural connections and memories tangled – sent me down a rabbit hole of nostalgia:

The year is 2002. I’m a pimply-faced youth attending high school, living most of my life in an online fantasy world, a MMORPG called Nexus: TK.

nexustk

My main character was a rogue, but I had the opportunity to specialize in the merchant subpath, and I learned a spell called ✨ gamble ✨.

Either player could ante up to 250,000 coins and then the winning player (presumably based on the outcome of a Random Number Generator) would be awarded the proceeds.  There would be a cool little animation around the winning character, so anyone could spectate the outcome of the gamble, celebrating with the winner and taking schadenfreudian delight in the loser’s defeat.

Anyway, it clicked to me that with the launch of Keybase’s new tech, I could implement the gamble spell within Keybase.  I decided to roll up my sleeves, and the result?

Croupier.

Keybase_2

Keybase_1

Keybase

Players start a round by sending croupier money in chat, with a minimum bet size of 0.01XLM:

+0.01XLM@croupier

The countdown clock begins, allowing people 60 seconds to join the betting round. After any contribution to the pot, the clock resets, giving people another 60 seconds to join.  Once the clock runs out, each player is assigned a numerical range proportional to the size of their bet. Keybase randomly generates a number and the proceeds (minus the transaction fees to receive the bets – extremely small, approximately 100 stroops or 0.000001XLM) are directed to whoever’s range contains the number.

Yes, this means that with a bet of just 0.01XLM, one could win a pot containing hundreds of lumens. Note: If the flip fails to resolve or there aren’t 2+ participants in the game, participants are automatically refunded.

Here’s the source code….

Build it with:

 tsc --lib es2015 index.ts

And then run it with:

node index.js
import * as _ from "lodash";
import * as mysql from "mysql";
import * as os from "os";
import * as Bot from "./keybase-bot";

import "source-map-support/register";

const bot: Bot = new Bot(os.homedir());
const bot2: Bot = new Bot(os.homedir());

const botUsername: string = "croupier";
const paperkey: string = process.env.CROUPIER_PAPERKEY_1;
const paperkey2: string = process.env.CROUPIER_PAPERKEY_2;

import { ChatChannel, MessageSummary, Transaction } from "./keybase-bot";

interface IParticipant {
  username: string;
  transaction: Transaction;
}

interface ISnipe {
  participants: Array;
  betting_open: boolean;
  clock: string;
  timeout: string;
}

function documentSnipe(channel: ChatChannel, winner: string, wasCancelled: boolean): void {
  const participants: string = JSON.stringify(activeSnipes[JSON.stringify(channel)].participants);
  const connection: mysql.Connection = mysql.createConnection({
    database : process.env.MYSQL_DB,
    host     : process.env.MYSQL_HOST,
    password : process.env.MYSQL_PASSWORD,
    user     : process.env.MYSQL_USER,
  });

  connection.connect();

  if (winner !== null) {
    winner = `'${winner}'`;
  }

  connection.query(`INSERT INTO snipes
    (participants, winner, was_cancelled)
    VALUES
    ('${participants}', ${winner}, ${wasCancelled})`, (error, results, fields) => {
      if (error) {
        console.log(error);
      }
    });

  connection.end();
}

function processRefund(txn: Transaction, channel: ChatChannel): void {
  console.log("refunding txn", txn);
  // API returns a response, number of stroops
  const transactionFees: number = 300 * 0.0000001;
  console.log("refunding txn fees", transactionFees);
  const refund: number = _.round(txn.amount - transactionFees, 7);
  console.log("total refund is", refund);
  bot.chat.sendMoneyInChat(channel.topicName, channel.name, refund.toString(), txn.fromUsername);
}

function extractTxn(msg: MessageSummary): void {
  const txnId: string = msg.content.text.payments[0].result.sent;
  bot.wallet.details(txnId).then((details) => processTxnDetails(details, msg.channel));
}

function sendAmountToWinner(winnerUsername: string, channel: ChatChannel): void {

  let bounty: number;
  const snipe: ISnipe = activeSnipes[JSON.stringify(channel)];

  bounty = 0;

  snipe.participants.forEach((participant) => {
     bounty += parseFloat(participant.transaction.amount);
     bounty -= (300 * 0.0000001); // transaction fees for receiving the transaction
  });

  bounty = _.round(bounty, 7);
  console.log("now rounded", bounty);

  bot.chat.sendMoneyInChat(channel.topicName, channel.name, bounty.toString(), winnerUsername);

}

function resolveFlip(channel: ChatChannel, winningNumber: number): void {

  let winnerUsername: string;
  const bettorRange: object = buildBettorRange(channel);
  Object.keys(bettorRange).forEach((username) => {
    if (bettorRange[username][0] <= winningNumber && bettorRange[username][1] >= winningNumber) {
      winnerUsername = username;
    }
  });

  sendAmountToWinner(winnerUsername, channel);
  bot.chat.send(channel, {
    body: `Congrats to @${winnerUsername}`,
  });

  documentSnipe(channel, winnerUsername, false);
}

function buildBettorRange(channel: ChatChannel): any {
  const bettorMap: object = {};
  activeSnipes[JSON.stringify(channel)].participants.forEach((participant) => {
    if (typeof(bettorMap[participant.username]) === "undefined") {
      bettorMap[participant.username] = Math.floor(participant.transaction.amount / 0.01);
    } else {
      bettorMap[participant.username] += Math.floor(participant.transaction.amount / 0.01);
    }
  });

  const bettorRange: object = {};
  let start: number = 0;
  Object.keys(bettorMap).forEach((key) => {
    bettorRange[key] = [start + 1, start + bettorMap[key]];
    start += bettorMap[key];
  });

  return bettorRange;
}

function flip(channel: ChatChannel): void {

  const bettorRange: object = buildBettorRange(channel);
  const bettingValues: Array = Object.values(bettorRange);
  const flatBettingValues: Array = _.flatten(bettingValues);

  const minBet: number = flatBettingValues.reduce((a, b) => Math.min(a, b));
  const maxBet: number = flatBettingValues.reduce((a, b) => Math.max(a, b));

  let bettingTable: string = "Betting table\n";

  Object.keys(bettorRange).forEach((username) => {
    bettingTable += `\n@${username}: \`${bettorRange[username][0]} - ${bettorRange[username][1]}\``;
  });

  bot2.chat.send(channel, {
    body: bettingTable,
  });
  bot2.chat.send(channel, {
    body: `/flip ${minBet}..${maxBet}`,
  });
}

function processTxnDetails(txn: Transaction, channel: ChatChannel): void {
  if (txn.toUsername !== botUsername) {
    return;
  }
  const isNative: boolean = txn.asset.type === "native";
  if (!isNative) {
    return;
  }
  if (parseFloat(txn.amount) < 0.01) {     bot.chat.send(channel, {       body: "Thanks for the tip, but bets should be >= 0.01XLM",
    });
    return;
  }

  const snipe: ISnipe = activeSnipes[JSON.stringify(channel)];
  if (typeof(snipe) === "undefined") {
    launchSnipe(channel);
    activeSnipes[JSON.stringify(channel)].participants.push({
      transaction: txn,
      username: txn.fromUsername,
    });
  }

  console.log("betting_open 178");
  if (snipe.betting_open === false) {
    bot.chat.send(channel, {
      body: `Betting has closed - refunding`,
    });
    processRefund(txn, channel);
    return;
  }

  activeSnipes[JSON.stringify(channel)].participants.push({
    transaction: txn,
    username: txn.fromUsername,
  });
  bot.chat.send(channel, {
    body: `@${txn.fromUsername} is locked into the snipe!`,
  });
  resetSnipeClock(channel);
}

function resetSnipeClock(channel: ChatChannel): void {

  const snipeTimeout: number = 60;
  clearTimeout(activeSnipes[JSON.stringify(channel)].timeout);
  bot.chat.delete(channel, activeSnipes[JSON.stringify(channel)].clock, {});
  bot.chat.send(channel, {
    body: `Betting stops in ${snipeTimeout} seconds`,
  }).then((sentMessage) => {
    runClock(channel, sentMessage.id, snipeTimeout);
    activeSnipes[JSON.stringify(channel)].clock = sentMessage.id;
  });
  const finalizeBetsTimeout: NodeJS.Timeout = setTimeout(() => {
    finalizeBets(channel);
  }, snipeTimeout * 1000);
  activeSnipes[JSON.stringify(channel)].timeout = finalizeBetsTimeout;

}

const activeSnipes: object = {};

function launchSnipe(channel: ChatChannel): void {
  // Tell the channel: OK, your snipe has been accepted for routing.

  const snipeTimeout: number = 60;
  let message: string = "The snipe is on.  Bet in multiples of 0.01XLM.  Betting format:";
  message += `\`\`\`+0.01XLM@${botUsername}\`\`\``;

  activeSnipes[JSON.stringify(channel)] = {
    betting_open: true,
    clock: null,
    participants: [],
    timeout: null,
  };

  bot.chat.send(channel, { body: message });

  bot.chat.send(channel, {
    body: `Betting stops in ${snipeTimeout} seconds`,
  }).then((sentMessage) => {
    runClock(channel, sentMessage.id, snipeTimeout);
    activeSnipes[JSON.stringify(channel)].clock = sentMessage.id;
  });

  const finalizeBetsTimeout: NodeJS.Timeout = setTimeout(() => {
    finalizeBets(channel);
  }, snipeTimeout * 1000);
  activeSnipes[JSON.stringify(channel)].timeout = finalizeBetsTimeout;

}

function finalizeBets(channel: ChatChannel): void {
  bot.chat.send(channel, {
    body: "No more bets!",
  });

  console.log("betting_open 255");
  activeSnipes[JSON.stringify(channel)].betting_open = false;
   // Give 5 seconds to finalize transactions + 1 extra.
  setTimeout(() => {
    executeFlipOrCancel(channel);
  }, 6 * 1000);
}

/* TODO: check that there are _different_ participants not someone betting against themself multiple times */
function executeFlipOrCancel(channel: ChatChannel): void {
  const snipe: ISnipe = activeSnipes[JSON.stringify(channel)];
  if (typeof(snipe) !== "undefined") {
    const participantUsernames: Array = snipe.participants.map((participant) => participant.username);
    const uniqParticipants: Array = _.union(participantUsernames);
    if (uniqParticipants.length > 1) {
      flip(channel);
    } else if (uniqParticipants.length === 1) {
      snipe.participants.forEach((participant) => {
        processRefund(participant.transaction, channel);
      });
      bot.chat.send(channel, {
        body: "The snipe has been cancelled due to a lack of participants.",
      });
      documentSnipe(channel, null, true);
      activeSnipes[JSON.stringify(channel)] = undefined;
    } else {
      bot.chat.send(channel, {
        body: "The snipe has been cancelled due to a lack of participants.",
      });
      documentSnipe(channel, null, true);
      activeSnipes[JSON.stringify(channel)] = undefined;
    }
  }
}

function cancelFlip(conversationId: string, channel: ChatChannel, err: Error): void {
  clearInterval(flipMonitorIntervals[conversationId]);
  if (typeof(activeSnipes[JSON.stringify(channel)]) !== "undefined") {
    bot.chat.send(channel, {
      body: `The flip has been cancelled due to error, and everyone is getting a refund`,
    });
    activeSnipes[JSON.stringify(channel)].participants.forEach((participant) => {
      processRefund(participant.transaction, channel);
    });
    documentSnipe(channel, null, true);
    activeSnipes[JSON.stringify(channel)] = undefined;
  }
}

const flipMonitorIntervals: object = {};

function monitorFlipResults(msg: MessageSummary): void {

  flipMonitorIntervals[msg.conversationId] = setInterval((() => {
    try {
      bot.chat.loadFlip(
        msg.conversationId,
        msg.content.flip.flipConvId,
        msg.id,
        msg.content.flip.gameId,
      ).then((flipDetails) => {
        if (flipDetails.phase === 2) {
          console.log("results are in");
          resolveFlip(msg.channel, flipDetails.resultInfo.number);
          clearInterval(flipMonitorIntervals[msg.conversationId]);
          activeSnipes[JSON.stringify(msg.channel)] = undefined;
        } else {
          console.log("results are NOT in", flipDetails);
        }
      }).catch((err) => {

        cancelFlip(msg.conversationId, msg.channel, err);

      });
    } catch (err) {
      cancelFlip(msg.conversationId, msg.channel, err);
    }
  }), 1000);
}

const allClocks: Array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reverse();
const runningClocks: object = {};

function runClock(channel: ChatChannel, messageId: string, seconds: number): void {
  try {
    bot.chat.edit(channel, messageId, {
      message: {
        body: ":clock" + allClocks[seconds % 12].toString() + ":" + ` betting stops in ${seconds}s`,
      },
    });
  } catch (e) {
    return;
  }

  if (seconds > 1) {
    setTimeout(() => {
      runClock(channel, messageId, seconds - 1);
    }, 1000);
  } else {
    setTimeout(() => {
      bot.chat.edit(channel, messageId, {
        message: {
          body: "~:clock" + allClocks[seconds % 12].toString() + ":" + ` betting stops in 1s~ no longer accepting bets`,
        },
      });
    }, 1000);
  }
}

async function main(): Promise {
  try {
    await bot.init(botUsername, paperkey);
    console.log(`Bot initialized with username ${botUsername}.`);
    await bot2.init(botUsername, paperkey2);
    console.log("Second key initialized");
    console.log("Listening for all messages...");

    const channel: object = {
       membersType: "team", name: "mkbot", public: false, topicName: "test3", topicType: "chat",
    };
    const message: object = {
      body: `${botUsername} was just restarted...[development mode] [use at own risk] [not functional]`,
    };

    bot.chat.send(channel, message);

    await bot.chat.watchAllChannelsForNewMessages(
      async (msg) => {
        try {
          if (msg.content.type === "flip" && msg.sender.username === botUsername) {
            monitorFlipResults(msg);
            return;
          }
          if (msg.content.type === "text" && msg.content.text.payments && msg.content.text.payments.length === 1) {
            extractTxn(msg);
          }
        } catch (err) {
          console.error(err);
        }
      },
      (e) => console.error(e),
    );
  } catch (error) {
    console.error(error);
  }
}

async function shutDown(): Promise {
  await bot.deinit();
  await bot2.deinit();
  process.exit();
}

process.on("SIGINT", shutDown);
process.on("SIGTERM", shutDown);

main();

You might notice that this is mostly written in TypeScript!  As the project grew to 500 lines of code, I found it a bit difficult to manage bugs.  Converting the JS into TypeScript was mostly really easy.  A few rounds of TSLint and ESHint and TypeScript compilation later, everything was pretty much smooth sailing.

Misc note: I found the Type system something a bit counterintuitive to grapple with, especially when dealing with JSON and partially undocumented APIs.  Fortunately, the keybase-bot project has been partially implemented in typescript and all the types were already prepared for me. Niiiiice.

1C88E460-A57B-4EA2-B2C5-2C6ECFA2DFB5
v1 of the bot was designed on pen and paper, and then redesigned around some limitations3 I ran into as well as UX conveniences that seemed obvious in hindsight.

A few notes on deploying the bot…

@dxb, the leader of the @mkbot Keybase channel and the most helpful person in the testing and iterative development process4, showed me how to use Docker to deploy the bot.  I actually hadn’t had an opportunity to use Docker in production, so it was super informative!

Here was my deployment process:

  1. apt-get install docker-compose
  2. Added my current logged in user to the docker group with
    usermod -aG docker
  3. Logged out and logged back in
  4. Created a new folder and included a file called
    Dockerfile

    Here’s the Dockerfile:

    FROM archlinux/base
    
    RUN pacman -Sy && \
        pacman -S --ask 20 --noconfirm \
        base \
        binutils \
        fakeroot \
        git \
        jq \
        gcc \
        tcl \
        expect \
        nodejs-lts-dubnium \
        npm \
        sudo \
        keybase
    
    RUN useradd -m keybase && \
        mkdir /keybase && \
        chmod 777 /keybase
    
    RUN echo 'keybase ALL=(ALL) NOPASSWD: ALL' | EDITOR='tee -a' visudo
    
    USER keybase
    WORKDIR /home/keybase
    
    ENV PATH=/home/keybase:$PATH
    
  5. In the same folder, created a file called
    docker-compose.yaml

    Here’s the contents:

    version: '3.2'
    services:
        croupier:
            image: keybase_dxb:latest
            environment:
                BOT_OWNER: zackburt
                CROUPIER_PAPERKEY_1: warrior laugh...redacted...
                CROUPIER_PAPERKEY_2: warrior laugh...redacted...
                MYSQL_HOST: croupier-cluster.redacted.us-east-1.rds.amazonaws.com
                MYSQL_USER: croupier
                MYSQL_PASSWORD: redacted
                MYSQL_DB: croupier
            entrypoint: /home/keybase/scripts/start.sh
            restart: always
            volumes:
              - type: volume
                source: 0_home
                target: /home/keybase
              - type: bind
                source: ./0_scripts
                target: /home/keybase/scripts
    
    volumes:
        0_home:
    

    Note: we use two paper keys.  Due to limitations of the keybase client, the Keybase paperkey that launched the flip doesn’t have the ability to read the flip, so we use another paperkey for that.

     

  6. Built the Docker file with
    docker build --no-cache -t keybase_dxb .
  7. Created a folder called 0_scripts/ and put these scripts in it, sendmoney.sh and start.sh.  Notice that line in the docker-compose.yaml where start.sh is automatically invoked once the container starts.  Here they are:
    1. sendmoney.sh
      #!/bin/bash
      
      KB_TEAM=$1
      KB_CHAN=$2
      KB_AMT=$3
      KB_RCPT=$4
      WORKING_DIR=$5
      HOME_DIR=$6
      KB_SPAWN="expect -c 'spawn ${WORKING_DIR}/keybase --home ${HOME_DIR} chat send --channel ${KB_TEAM} ${KB_CHAN} \"+${KB_AMT}XLM@${KB_RCPT}\" ; expect \"if you are sure\" ; send -- \"sendmoney\r\" ; expect eof'"
      eval $KB_SPAWN
      

      Note: the keybase client requires some interaction to send money in chat, so @dxb cooked up this little expect script.

      Note as well: by default, sendMoneyInChat() is not a method in the keybase-bot library.  So we hacked up keybase-bot.js to add this method to the Chat class that extends ClientBase:

      sendMoneyInChat(channel, team, amount, recipient) {
      
        console.log('sendMoneyInChat: 1');
        const child = child_process.spawn('/home/keybase/scripts/sendmoney.sh',
          [channel, team, amount, recipient, this._workingDir, this.homeDir]);
      
        child.stdout.on('data', (data) => {
          console.log(`stdout: ${data}`);
        });
      
        child.stderr.on('data', (data) => {
          console.log(`stderr: ${data}`);
        });
      
        child.on('close', (code) => {
          console.log(`child process exited with code ${code}`);
        });
      
        child.on('error', (err) => {
          console.log(`child process errored with err ${err}`);
        });
      
      }
      
    2. start.sh
      #!/bin/bash
      
      # start keybase service without kbfs and gui
      keybase oneshot --username croupier --paperkey "warrior laugh redacted.."
      run_keybase -fg
      
      # put the commands to run at startup here
      sudo pacman -Scc
      
      keybase chat send $BOT_OWNER "$(date) - starting bot"
      
      sudo npm install -g typescript
      killall keybase
      rm -rf croupier 
      git clone https://github.com/codeforcash/croupier.git
      cd croupier
      npm install
      tsc --lib es2015 index.ts
      node index.js
      
  8. chmod +x 0_scripts/start.sh

    and

    chmod +x 0_scripts/sendmoney.sh
  9. Started the container with
    docker-compose up croupier

You’ll notice that the Docker container automatically builds based on the source code in the GitHub repo.  In fact, @dxb has it rigged so that his bots restart automatically whenever there’s a new commit to the codebase… but that’s outside the scope of this article as I haven’t followed him to that level yet. 🙂

Want to try croupier yourself?  Join the @mkbot channel on Keybase and get a game going with +0.01XLM@croupier.  Or, invite @croupier into any Keybase team you’re in and start the game the same way.

Is this legal?

Let it be said: I’m not a lawyer.  And Croupier does not take a rake except arguably that refunds and winnings are issued less transaction fees (maybe rounded up a few stroops – à la the salami slicing scheme described in Office Space and Superman 3).

As far as I know, in New York, skill games are legal and new legislation continues to reify that.  Now, is Croupier a skill game, given outcomes are determined by the provably just randomness features implemented on Keybase?

I say: yes!

  • One has the ability to increase one’s chance of winning through adding additional bets (although this doesn’t affect the expected value of one’s outcome, since one’s odds are always directly proportional to one’s bet commitment).
  • Croupier is inherently a social bot, and bet sizing has to be done in a way that doesn’t draw the ire of one’s companions – nobody wants to be steamrolled by someone with an unfairly large bankroll.
  • If somebody jukes the odds at the last minute, one has the ability to force a draw – and a refund of everybody’s bets: if two users with the same paper keys subscribe to the chat channel, Keybase doesn’t certify the randomness. In practice, Croupier refunds all bets. Forcing a duplicate registration event is a strategy, even though there are social consequences to being a bad actor.

Footnotes:

[0] Hacker News: social media for elite/elitist technologists who claim to despise social media and yet maintain some presence on the site.

[1] The Keybase founders, Max and Chris, previously launched SparkNotes and then OKCupid.

So perhaps indeed they could make cryptography cross the chasm.

[2] @pzduniak, thoroughly polite all the way through my plentiful nagging, added flip support to the keybase-bot library.

[3] Keybase has this cool feature where users may request money, but as of writing there was no convenient API for accessing it.

[4] @dxb, @locknload, @jessecooper, and @max were especially helpful testers! 

[5] Code For Cash is recruiting a developer to join Keybase and fully own development of the Windows Keybase client.  

[6] Croupier’s namesake is Dan Egan from the HBO series Veep.

dan egan
Dan’s colleagues undermined him by telling his boss he would “make a good croupier” – not a political operator.