Running prize contests without witness swiping

Recently Artem Chystiakov ran the first Simplicity CTF challenge, with a real-money prize in Liquid Bitcoin (LBTC). This reminded me that I’ve wanted to run some prize contests to show off Simplicity and encourage people to learn more about it.

One simple pattern is to create a contract that arithmetically recognizes some kind of mathematical object (like a list of integers that are related to each other in some way) and approves payment requests whenever such an object is provided as a witness. It’s pretty easy to create contests from this without trivially giving away the answers, because there are many mathematical problems whose answers are easy to verify but apparently hard to find. We already have an example along these lines with a hash function in the SimplicityHL examples. It looks like this:

/*
 * REVEAL COLLISION
 *
 * The coins move if someone is able to provide a SHA2 collision, e.g.,
 * two distinct byte strings that hash to the same value.
 *
 * We cannot test this program because no SHA2 collision is known :)
 *
 * https://docs.ivylang.org/bitcoin/language/ExampleContracts.html#revealcollision
 */
fn not(bit: bool) -> bool {
    <u1>::into(jet::complement_1(<bool>::into(bit)))
}

fn sha2(string: u256) -> u256 {
    let hasher: Ctx8 = jet::sha_256_ctx_8_init();
    let hasher: Ctx8 = jet::sha_256_ctx_8_add_32(hasher, string);
    jet::sha_256_ctx_8_finalize(hasher)
}

fn main() {
    let string1: u256 = witness::STRING1;
    let string2: u256 = witness::STRING2;
    assert!(not(jet::eq_256(string1, string2)));
    let hash1: u256 = sha2(string1);
    let hash2: u256 = sha2(string2);
    assert!(jet::eq_256(hash1, hash2));
}

This is a good challenge problem because nobody has ever publicly revealed such a collision for SHA256, although it’s not a great challenge problem because it’s too hard (it might take decades, centuries, or longer before anyone will win the challenge).

Something of more customizable difficulty would be integer factorization challenges like the RSA numbers (or other challenge numbers). It’s extremely easy to generate these challenges at any desired level of difficulty, and we could write a contract that says “here is a specific challenge number n; to claim these assets, submit a transaction with two integers witness::p and witness::q such that p×q=n”.

However, the simple structure of “show us an example of numbers with these properties” has a security vulnerability because there’s no strong commitment connecting the winning information with a specific winner (or the winner’s on-chain address). If I reveal a winning answer, it is inherently valid for anyone to submit. During the time when a transaction has been submitted to the mempool, anybody (including a miner) who can view the mempool could see the answer, and could construct a totally new transaction with a higher fee that claims the same coins using the same solution!

(Edited to fix an AI hallucination in the original image that gave the woman’s phone two different handsets.)

The key is just that the attacker has to submit a copy of the claim faster or merely with a somewhat higher fee to make it more appealing to a miner. Then the attacker’s version of the claim is likely to be accepted as authoritative and appear on the blockchain, in preference to the original claim.

Again, the SHA256 collision challenge above suffers from this problem. A bot could watch for claims in the mempool, check whether they’re valid, and quickly submit higher-fee copies of the claims sending the prize to the bot’s owner instead.

When I talked to Andrew Poelstra and Russell O’Connor about this issue a few months ago, they suggested a two-phase claim process with a deposit and timeout. The idea is that the prize money is held by a covenant that can go back and forth between two different states. (I believe the version I present here is my slight adaptation of Russell O’Connor’s solution.)

In the READY state, the covenant is not prepared to pay out the prize. However, it is prepared to accept a deposit from anyone in order to enter the LOCKED state. The deposit amount is verified as sufficient, and gets added to the covenant’s prize pool. The READYLOCKED transition also lets the person making the deposit specify a withdrawal address, which the covenant will remember when in the LOCKED state.

In the LOCKED state, the covenant can do two different things:

  • Process a claim. The user submits a claim; if it is correct (according to the solution-checking logic in the contract), the prize money can be paid out, but only to the remembered address.
  • Return to READY state. If an observer notes that the contract has been in LOCKED state for too long (beyond a timeout duration), the observer can return the contract back to READY state, and be rewarded with a small fee (less than the amount of the deposit).

The advantage of this is that the contract is committed (in LOCKED state) to only paying the prize to the specific party that put the contract into LOCKED state, regardless of whether other transactions appear that claim the prize based on the same witness data. However, a user can’t tie up the contract in LOCKED state forever, and there is a financial cost to submitting a spurious claim or locking the contract without submitting a claim at all (the loss of the deposit). A user with a valid claim doesn’t need to worry too much about the deposit cost because the deposit will be refunded along with the successful prize claim.

Here’s a flowchart of that logic:

I’ve almost finished implementing this and hope it will be a nice example of practical uses of state commitments in SimplicityHL, as well as a practical demo of how to work around the witness swiping issue. We may even be able to run some more contests using this approach!

4 Likes

This is a really fun idea! The SHA256 collision example is conceptually easy to understand as a bitcoiner, but as you mentioned, is basically impossible to solve in practice. But what if, sort of like bitcoin proof of work, you had the contest be to find a partial collision? Bitcoin PoW requires leading zero bits in a single block hash, but instead here you could pick the number of leading bits that match each other. This also opens up an interesting algorithmic challenge of how to know when you’ve hit a collision without using up all of your RAM by just using a hash table.

@tvolk131 Fun! We could certainly do that. For example

fn hash(input: u256) -> u256 {
   // "Hello there :-) "
   let salt: u128 = 0x48656c6c6f207468657265203a2d2920;
   let ctx: Ctx8 = jet::sha_256_ctx_8_init();
   let ctx: Ctx8 = jet::sha_256_ctx_8_add_16(ctx, salt);
   let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, input);

   jet::sha_256_ctx_8_finalize(ctx)
}

fn hash_lt_n(n: u8, input: u256) -> bool {
   // Does SHA256(salt || input) start with n or more leading zeros?

   // This logic is only valid for n<=64.
   assert!(jet::le_8(n, 64));

   let target: u64 = jet::right_shift_64(n, jet::high_64());

   let h: u256 = hash(input);
   let (a, b, c, d): (u64, u64, u64, u64) = <u256>::into(h);

   jet::le_64(a, target)
}

fn main(){
   assert!(hash_lt_n(20, witness::HASH_INPUT));
}

will approve a transaction if provided with the witness

{
    "HASH_INPUT": {
        "value": "0x00000000000000000000000000000000000000000000000000000000002855f5",
        "type": "u256"
    }
}

but will fail for the great majority of witnesses (about 1-2⁻²⁰ of all witness values). For example, if you change the 2855f5 to 2855f4 or 2855f6, it won’t be accepted.

I was tending to imagine puzzle challenges that provide more mental challenge to humans and less computational challenge to computers, but both are potentially valid forms of challenge to consider.

Note that my example contract here is totally vulnerable to witness swiping again (it doesn’t use the multi-phase claim process).

Sorry, I didn’t actually do the “match each other” example that you were mentioning; I instead did a “leading zeroes” approach somewhat more like existing Bitcoin mining.

This version seems to be what you were actually thinking of:

fn hash(input: u256) -> u256 {
   let ctx: Ctx8 = jet::sha_256_ctx_8_init();
   let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, input);

   jet::sha_256_ctx_8_finalize(ctx)
}

fn hash_coll(n: u8, x: u256, y: u256) -> bool {
   // Do SHA256(x) and SHA256(y) start with n identical bits?

   // This logic is only valid for n<=64.
   assert!(jet::le_8(n, 64));

   let (_, shift): (bool, u8) = jet::subtract_8(64, n);
   let mask: u64 = jet::left_shift_64(shift, jet::high_64());

   let (a, _, _, _): (u64, u64, u64, u64) = <u256>::into(hash(x));
   let (b, _, _, _): (u64, u64, u64, u64) = <u256>::into(hash(y));

   jet::eq_64(jet::and_64(mask, a), jet::and_64(mask, b))
}

fn main(){
   assert!(hash_coll(20, witness::A, witness::B));
}

I didn’t make a version that can enforce more than 64 bits of matching because we don’t have some of the jets defined for integer types larger than u64 (so it’s slightly tedious to do bitwise comparisons for a full u256).

There’s definitely a time-space trade-off here. The birthday attack issue means you should be able to find a collision faster but you have to actually remember the values, which does use a significant amount of RAM. You can also do it in constant RAM if you just pick a single input as the one to match, but then you need exponential time for the search. For example, for the 2²⁰ partial match above, you can just pick a particular input like 0x0000000000000000000000000000000000000000000000000000000000000000 and then do about 2²⁰ work to find another input that matches that specific one.

{
    "A": {
        "value": "0x0000000000000000000000000000000000000000000000000000000000000000",
        "type": "u256"
    },
    "B": {
        "value": "0x000000000000000000000000000000000000000000000000000000000003196D",
        "type": "u256"
    }
}

However, with more RAM you should only need to do about 2¹⁰ work to find an arbitrary pair of inputs that match. For example, the numerically smallest pair of 256-bit integers whose SHA256 outputs agree in their first 20 bits are

{
    "A": {
        "value": "0x00000000000000000000000000000000000000000000000000000000000006A6",
        "type": "u256"
    },
    "B": {
        "value": "0x000000000000000000000000000000000000000000000000000000000000069B",
        "type": "u256"
    }
}

which can be found with indeed only about 2¹⁰ hash operations if you can remember all of the intermediate values.

I think rainbow tables could let you pick other points along the tradeoff, although there might be more efficient algorithms to navigate that tradeoff in this setting.

Yes, exactly!

Very cool to see those actual hash values lining up with each other when I hashed those values you provided.

What value does having separate LOCKED and READY states provide? It introduces the overhead of requiring a new claimer to unlock and re-lock the contract, and then reveal their solution, totaling three transactions rather than the more optimal two. Would it be possible to ditch the locked vs ready states and have the only state be the current payout key with a timelocked spend path for changing the payout address, and a non-timelocked spend path for claiming the prize? The payout key could be initially set to a NUMS key.

1 Like

:grinning_face:

Oh, I think that’s a strictly more efficient approach, with no apparent loss of security. I’ll try to implement that version instead.

1 Like

I stumbled across a YouTube short that I think is relevant for possible contest challenges. I wonder how hard it’d be to implement a base 2 butt/head double/triple/etc checker script. It definitely has a nice “difficult to find, easy to verify” quality to it.

That’s definitely the flavor of one kind of contest I’d like to do, but inconveniently for us, that property apparently has a well-known algebraic solution.

Something that’s a little inconvenient for us is that a huge amount of recreational mathematics has already been investigated quite a bit and is very well-indexed online nowadays. I feel that, even with search engines and without LLMs, the gap between “problems that are really easy to solve if you just search for them online” and “completely unsolved problems that you could get academic credit for solving for the first time” has been narrowing a lot. There’s less and less stuff occupying a middle ground between the two.

This is also awkward for me as someone involved in the puzzlehunt community, which is always trying to construct challenges that are genuinely challenging yet fairly reliably solvable (often within a reasonable period of time like an hour or a couple of hours). When there were fewer powerful online tools, it was easier to make challenges that had that combination of properties!

We still know how to make challenges that (as far as we know) inherently require roughly a certain amount of computer time (as you pointed out, that’s how Bitcoin mining itself works, with its adjustable difficulty parameter), but that’s not quite as fun or interesting as problems that inherently require roughly a certain amount of human ingenuity!

Interestingly, for a reason that’s not instantly obvious to me, those digits are the same as the digits in the expansion of 1+{1\over{19}}.

I got this to work with this implementation of the contract:

simc "0.7.0"; // compile contract with -Z enums

fn eq_128(a: u128, b: u128) -> bool {
   // eq_128 is not a jet, but it will be in the stdlib eventually.
   jet::eq_256(<(u128, u128)>::into((0, a)), <(u128, u128)>::into((0, b)))
}

fn is_correct(p: u64, q: u64) -> bool {
    // Check that the proposed solution to the challenge is correct.

    // This is a slightly realistic version. The challenge is to
    // factor 311954490450626290040901547370562193609. This size of
    // integer factorization isn't very difficult for sympy.factorint().
    let n: u128 = 311954490450626290040901547370562193609;
    eq_128(n, jet::multiply_64(p, q))
}

enum Action {
    Update,
    Claim(u64, u64),
}

fn script_hash_for_input_script(state_data: u256) -> u256 {
    let tap_leaf: u256 = jet::tapleaf_hash();
    let state_ctx1: Ctx8 = jet::tapdata_init();
    let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, state_data);
    let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx2); 
    let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf);

    // Compute a taptweak using this.
    let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0;
    let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node);

    // Turn the taptweak into a script hash
    let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init();
    let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); // Segwit v1, length 32
    let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key);
    jet::sha_256_ctx_8_finalize(hash_ctx3)
}

fn is_current_stored_state(dest_addr_script_hash: u256) -> bool {
    jet::eq_256(
        script_hash_for_input_script(dest_addr_script_hash),
        unwrap(jet::input_script_hash(jet::current_index()))
    )
}

fn store(new_state: u256) {
    // Assert that the output state is correct, i.e. "store".
    assert!(jet::eq_256(
        script_hash_for_input_script(new_state),
        unwrap(jet::output_script_hash(0))
    ));
}

fn enforce_relative_duration(min_duration: Duration) {
    // Assert that the current input is spent in a transaction that can only
    // appear a duration of at least min_duration units of 512 seconds after
    // the input's UTXO. Panic otherwise.

    // Transaction version must be at least 2.
    assert!(jet::le_32(2, jet::version()));

    // Fetch and parse sequence
    let actual_data: Either<Distance, Duration> = unwrap(jet::parse_sequence(jet::current_sequence()));
    let actual_duration: Duration = unwrap_right::<Distance>(actual_data);

    assert!(jet::le_16(min_duration, actual_duration));
}

fn and(a: bool, b: bool) -> bool {
    match a {
        true => b,
        false => false,
    }
}

fn not(b: bool) -> bool {
    match b {
        true => false,
        false => true,
    }
}

fn asset_equal(a_asset: Asset1, b_asset: Asset1) -> bool {
    match a_asset {
        Left((a1, a2): (u1, u256)) => match b_asset {
                Left((b1, b2): (u1, u256)) => and(jet::eq_1(a1, b1), jet::eq_256(a2, b2)),
                Right(b: u256) => false,
            },
        Right(a3: u256) => match b_asset {
            Left(b: (u1, u256)) => false,
            Right(b3: u256) => jet::eq_256(a3, b3),
        },
    }
}

fn safe_add_64(a: u64, b: u64) -> u64 {
    let (carry, sum): (bool, u64) = jet::add_64(a, b);
    assert!(not(carry));
    sum
}

fn update(dest_addr_script_hash: u256) {
    let NUMS: u256 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
    let required_duration: Duration = 10;  // Can only update destination every 5120 seconds
    let minimum_deposit: u64 = 100;

    // Check that time is far enough in the future, or that this is the
    // first update (the stored state should be set to all 1s when the
    // prize is established, which is an unattainable scriptHash value
    // and is a signal that no user has yet tried to claim the prize, so
    // the update() can happen with no delay).
    match is_current_stored_state(NUMS) {
        true => (),
        false => enforce_relative_duration(required_duration),
    };

    // Assert covenant is input 0 and fetch its details
    assert!(jet::eq_32(jet::current_index(), 0));

    let (prize_asset, prize_amount): (Asset1, Amount1) = jet::current_amount();
    let explicit_prize_amount: u64 = unwrap_right::<(u1, u256)>(prize_amount);

    let (out_asset, out_amount): (Asset1, Amount1) = unwrap(jet::output_amount(0));
    let explicit_out_amount: u64 = unwrap_right::<(u1, u256)>(out_amount);

    // Enforce recursive covenant script on output 0
    store(dest_addr_script_hash);

    // Ensure the covenant retains the same asset type
    assert!(asset_equal(prize_asset, out_asset));

    // Invariant: The covenant balance must increase by at least
    // the minimum deposit.
    // Note: This permits an unlimited number of other inputs and
    // outputs (including change and fees). The only requirement is that
    // the assets controlled by the covenant increase by the required
    // deposit amount.
    let minimum_output: u64 = safe_add_64(explicit_prize_amount, minimum_deposit);
    assert!(jet::le_64(minimum_output, explicit_out_amount));
}

fn claim(dest_addr_script_hash: u256, p: u64, q: u64) {
    // Check that dest_addr_script_hash is stored in the covenant's
    // existing state commitment (as the script hash that a user has
    // most recently stored using an Update action).
    assert!(is_current_stored_state(dest_addr_script_hash));

    // Check that output[0] also matches the dest_addr_script_hash.
    let osh: u256 = unwrap(jet::output_script_hash(0));
    assert!(jet::eq_256(dest_addr_script_hash, osh));

    // Check that output[0] is receiving the full prize amount. (Otherwise,
    // an attacker could replace a legitimate claim with a modified
    // transaction that pays the legitimate winner a trivial amount and
    // pays the attacker the remainder!)
    let (prize_asset, prize_amount): (Asset1, Amount1) = jet::current_amount();
    let explicit_prize_amount: u64 = unwrap_right::<(u1, u256)>(prize_amount);
    let (out_asset, out_amount): (Asset1, Amount1) = unwrap(jet::output_amount(0));
    let explicit_out_amount: u64 = unwrap_right::<(u1, u256)>(out_amount);
    assert!(jet::le_64(explicit_prize_amount, explicit_out_amount));

    // Check that output[0] is receiving the original prize pool asset.
    // (Otherwise, an attacker could replace a legitimate claim with one
    // that pays the legitimate winner a numerically equal amount of a
    // worthless asset!)
    assert!(asset_equal(prize_asset, out_asset));

    // Check that the proposed solution to the challenge is correct.
    assert!(is_correct(p, q));
}

fn main() {
    let dest_addr_script_hash: u256 = witness::DEST_ADDR_SCRIPT_HASH;
    match witness::ACTION {
       Action::Update => update(dest_addr_script_hash),
       Action::Claim(p: u64, q: u64) => claim(dest_addr_script_hash, p, q),
    }
}

The contract pays a prize to anyone who reveals the factorization of 311954490450626290040901547370562193609.

There’s a lot more I can say about this, but I still want to clean up my demo scripts.

1 Like

@stringhandler has also now written a txmanifest.json file for this contract. I’ve asked him not to publish it for now because I think writing the spending path for the contract (using the tools of one’s choice) should probably be considered part of the challenge!

That is, when we actually run this form of contest publicly, one part of the challenge can be “solve the underlying math problem” and another part of the challenge can be “learn how to construct real transactions with Simplicity contracts and supply appropriate witnesses, using the tools of your choice”.