Pay-To-NFT locking scheme?

I’m looking for a pay-to-nft locking scheme that is secure and fits within cashtoken commitment (40 bytes); For my use I need it to require no more than 32 bytes so I have some bytes left for other states. (So directly storing 32 byte token ID + 8 bytes for commitment is not an option)

I’m specifically looking for one that is secured by any token ID with any commitment. Use case would for example allow a Cauldron pool or Moria loan to be owned by the holder of a specific Bliss ticket or Guru NFT.

First thing that came to mind was something like:

contract P2NFTH(bytes nfthash) {
    function unlock() {
        require(nfthash == hash256(
              tx.inputs[0].tokenCategory
            + tx.inputs[0].nftCommitment));
    }
}

However, this this is less secure than P2PKH

  • P2PKH Security:

    • Lock: HASH160(pubkey) (20 bytes).
    • Security: 160-bit preimage resistance (2^160 to find a pubkey) + 128-bit ECDSA signature security (2^128 to forge).
    • Effective: Layered defense; signature is the practical barrier.
  • P2NFTH (neither token ID nor commitment fixed):

    • Lock: SHA256d(nft-token-id || nft commitment) (32 bytes).
    • Security: 128-bit collision resistance (2^128 to grind both components).
    • Effective: Single-layer hash; weaker than P2PKH’s combined security.

Summary: P2PKH is stronger overall due to its dual-layer protection (hash + signature) vs. P2NFTH’s single-layer 128-bit collision resistance.

So my question would be two-fold:

  • Is this P2NFTH good enough; considering that P2SH32 itself is considered secure?
  • Are there any alternatives to that are alternative means to achieve the same with better security?

Notable related scheme:

@bitcoincashautist suggested a pay-to-nft lock that optimizes for input size, but does not include NFT commitment.
https://x.com/bchautist/status/1746882045312114788

Grok suggests that adding a prefix to it changes it from a collision attack to a preimage attack.

contract P2NFTH(bytes nfthash) {
    function unlock() {
        require(nfthash == hash256(
              bytes("some known prefix") + tx.inputs[0].tokenCategory
            + tx.inputs[0].nftCommitment));
    }
}

“Adding the fixed prefix flips P2NFTH from 2^128 (collision) to 2^256 (preimage), making it much stronger than both the original P2NFTH and P2SH (20 bytes)”

it is not lol, Grok made up some bs

Adding the fixed prefix flips P2NFTH from 2^128 (collision) to 2^256 (preimage), making it much stronger than both the original P2NFTH and P2SH (20 bytes)”

this makes no sanse, Grok is just confused…
when you talk with Grok you have to challenge assertions like these, then magically he discovers he’s wrong and finds the correct place in idea space, once you’re happy with his understanding, you can ask him to produce something to summarize it all

yes

Nice and clean! You can also make the index variable - make it function param, e.g.

contract P2NFTH(bytes nfthash) {
    function unlock(int i) {
        require(nfthash == hash256(
              tx.inputs[i].tokenCategory
            + tx.inputs[i].nftCommitment));
    }
}

An alternate approach with the security of p2sh32 with the help of introspection

contract NFTAuthenticator(bytes tokenCategory, bytes nftCommitment) {
    function unlock() {
        require(tx.inputs[0].tokenCategory == tokenCategory);
        require(tx.inputs[0].nftCommitment == nftCommitment);
    }
}

contract MainApp(bytes authenticatorBytecode) {
  function unlock() {
    // 0th input from the NFT owner
    // 1st input from the authenticator contract 

    bytes nftTokenCategory = tx.inputs[0].tokenCategory.split(32)[0];
    bytes nftCommitment = tx.inputs[0].nftCommitment;
    bytes scriptBytecode = bytes(nftCommitment.length) + nftCommitment + 0x20 + nftTokenCategory + authenticatorBytecode;
    bytes32 scriptHash = hash256(scriptBytecode);
    require(tx.inputs[1].lockingBytecode == new LockingBytecodeP2SH32(scriptHash));
  }
}
  • The NFT owner should attach their NFT in the transaction (0th input in this example)
  • NFT authenticator’s utxo as another input (1st in this example)
  • The mainApp’s utxo and rest of the transaction logic

Edit 0: The mainApp also needs the scriptHash of the authenticator in the constructor and then require it later.

contract MainApp(bytes authenticatorBytecode, bytes authenticatorScriptHash) {
require(new LockingBytecodeP2SH32(scriptHash) == authenticatorScriptHash);

or

contract MainApp(bytes authenticatorScriptHash) {
    ...
    bytes scriptBytecode = bytes(nftCommitment.length) + nftCommitment + 0x20 + nftTokenCategory + 0x00ce8800cf87;
    bytes32 scriptHash = hash256(scriptBytecode);
    require(new LockingBytecodeP2SH32(scriptHash) == authenticatorScriptHash);
}

High-level if you want to work around the 40 byte commitment limit you can also use a sidecar output traveling together with the main input:

  • use a sidecar holding a token with the tokenid = offloads 32 bytes of state
  • use a sidecar nft to keep the 8byte commiment info about the owner nft

this way you can use the 40 bytes of contract state for contract-specific info unrelated to the ownerId + ownerCommitment