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!

(Sorry the AI hallucinated two different handsets on the attacker’s phone. Maybe it’s just a really fancy kind of phone?)

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.