Unspent Phi v3 Timelocking Token-Aware Contracts

The Bitcoin (Cash) VM has had both relative and absolute timelocks for over a decade.

Early dapps that use this functionality are HODL plugin by @mainnet_pat, and the Last Will and Mecenas plugins by @Licho.

Contracts employing relative timelocks can also return some balance to enable recurring payments. With introspection, PUSH style seemingly automatic functionality is possible by making the contract parameters public for optimistic anyone-can-spend MEV execution.

Updating early timelocking contracts with introspection, it’s possible to create an ecosystem where payments, wills, and vaults seemingly automatically happen on schedule for both tokens and coin value.

Absolute timelock (BIP65) HODL-style vault (with CashToken support).

Many utxos may be spent “in parallel”. No early withdraws!

// Locktime: lock funds until a certain block time.
//
// - Allow spending after a predefined staticblocktime (BIP65).
// - Allow chaining many utxos in parallel, with each utxo contributing to the fee.
//
// L,3,<Locktime>,<receiptLockingBytecode>,<contractBytecode>
// 
contract Locktime(

  // length of time to lock contract, blocks
  int locktime,

  // LockingBytecode of the beneficiary, the address receiving payments
  bytes recipientLockingBytecode

) {
  function execute() {

    // Check that time has passed and that time locks are enabled
    // OP_CHECKLOCKTIMEVERIFY OP_DROP
    require(tx.time >= locktime);

    // Check that each output sends to the recipient
    // OP_INPUTINDEX OP_OUTPUTBYTECODE OP_EQUALVERIFY
    require(tx.outputs[this.activeInputIndex].lockingBytecode == recipientLockingBytecode);

    // Check that each output sends the balance minus an executor allowance
    // OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE c409 OP_SUB OP_GREATERTHANOREQUAL
    require(tx.outputs[this.activeInputIndex].value >= tx.inputs[this.activeInputIndex].value - 2500);

    // Require tokens go forward
    // OP_INPUTINDEX OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY 
    require(tx.outputs[this.activeInputIndex].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);

    // OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_NUMEQUALVERIFY 
    require(tx.outputs[this.activeInputIndex].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount);

    // OP_INPUTINDEX OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUAL
    require(tx.outputs[this.activeInputIndex].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
  }
}

Relative timelock (BIP68) deadman-switch or Last Will vault (with CashToken support)

Many utxos may be spent “in parallel”. Refresh is accomplished via NFT baton.


// Will: an automatic "deadman" switch with variable timeout.
//
// - Allow forwarding utxos after a predefined interval (BIP68).
// - Allow chaining many utxos in parallel, with each utxo contributing to the fee.

// W,3,<authCat>,<timeout>,<receiptLockingBytecode>,<contractBytecode>

contract Will(

  // Category of the authenticating baton
  // The auth baton is managed by the wallet of the user, 
  // The contract is agnostic of the token paid to the receipt.
  bytes32 authCat,

  // length of time (blocks) to lock utxos 
  int timeout,

  // LockingBytecode of the beneficiary, the address receiving payments
  bytes recipientLockingBytecode

) {

  // OP_3 OP_PICK OP_0 OP_NUMEQUAL OP_IF 
  function execute() {

    // Check that time has passed and that time locks are enabled
    // OP_SWAP OP_CHECKSEQUENCEVERIFY OP_DROP 
    require(tx.age >= timeout);

    // Check that each output sends to the recipient
    // OP_INPUTINDEX OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY 
    require(tx.outputs[this.activeInputIndex].lockingBytecode == recipientLockingBytecode);

    // Check that each output sends the balance minus an executor allowance
    // OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE c409 OP_SUB OP_GREATERTHANOREQUAL 
    require(tx.outputs[this.activeInputIndex].value >= tx.inputs[this.activeInputIndex].value - 2500);

    // Require tokens go forward
    // OP_INPUTINDEX OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY 
    require(tx.outputs[this.activeInputIndex].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);

    // OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_NUMEQUALVERIFY 
    require(tx.outputs[this.activeInputIndex].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount);

    // OP_INPUTINDEX OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUAL
    require(tx.outputs[this.activeInputIndex].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
        
  } // OP_NIP OP_NIP

  // Allow refreshing, or withdraw, with the authentication baton
  //  OP_ELSE OP_3 OP_ROLL OP_1 OP_NUMEQUALVERIFY 
  function refresh() {

      // Authentication failed, script fails.
      // OP_0 OP_UTXOTOKENCATEGORY OP_SWAP OP_2 OP_CAT OP_EQUAL 
      require(tx.inputs[0].tokenCategory == authCat + 0x02);

  } // OP_NIP OP_NIP OP_ENDIF

}

Subscriptions, Share Vesting, Revocable Token Annuities

A contract for fixed MEV-powered token payments, managed by NFT baton.

Example applications:

  • Vestment of shares at regular intervals.
  • Monthly or weekly “stable” coin denominated subscriptions.
  • Revocable bitcoin annuities via WBCH.

Ownership of the subscription balance is managed with NFT authentication. Subscription ownership transfers with the minting baton.

If a token balance doesn’t permit an installment, the remaining tokens are sent to the intended receipt; the utxo value can be claimed by the executor.

// S,3,<authCat>,<period>,<receiptLockingBytecode>,<installment>,<contractBytecode>

// ...

contract Subscription(

  // Category of the authenticating baton
  // The auth baton is managed by the wallet of the user, 
  // The contract is agnostic of the token paid to the receipt.
  bytes32 authCat,

  // payment interval (blocks)
  int period,

  // LockingBytecode of the beneficiary, the address receiving payments
  bytes recipientLockingBytecode,

  // Amount of tokens being vested each period 
  int installment

) {
  // OP_4 OP_PICK OP_0 OP_NUMEQUAL OP_IF 
  function execute() {

    
    // Require version 2 for BIP68 support
    // OP_TXVERSION OP_2 OP_NUMEQUALVERIFY 
    require(tx.version == 2);

    // Require a single utxo input
    // OP_TXINPUTCOUNT OP_1 OP_NUMEQUALVERIFY 
    require(tx.inputs.length == 1);

    // Require a rolling timelock is satisfied
    // OP_SWAP OP_CHECKSEQUENCEVERIFY OP_DROP 
    require(tx.age >= period);

    // Require minimum token dust  
    // OP_0 OP_OUTPUTVALUE 2003 OP_NUMEQUALVERIFY 
    require(tx.outputs[0].value == 800);

    // Require payment in the same token
    // OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY 
    require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);

    // Require that each output sends to the intended recipient
    // OP_0 OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY 
    require(tx.outputs[0].lockingBytecode == recipientLockingBytecode);

    // If not enough tokens remain to fulfill an installment,
    // OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_2 OP_PICK OP_LESSTHANOREQUAL OP_IF 
    if(tx.inputs[this.activeInputIndex].tokenAmount <= installment){

        // require token liquidation
        // OP_0 OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_NUMEQUALVERIFY 
        require(tx.outputs[0].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount);

        // utxo sats are unencumbered in the final installment

    } 
    // OP_ELSE 
    else{

        // Require that installment paid
        // OP_0 OP_OUTPUTTOKENAMOUNT OP_2 OP_PICK OP_NUMEQUALVERIFY 
        require(tx.outputs[0].tokenAmount == installment);

        // Require the executor fee is not excessive 
        // OP_1 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE f00a OP_SUB OP_GREATERTHANOREQUAL OP_VERIFY 
        require(tx.outputs[1].value >= tx.inputs[this.activeInputIndex].value - 2800);
            
        // Require that the token remainder after installment is returned
        // OP_1 OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_3 OP_PICK OP_SUB OP_GREATERTHANOREQUAL OP_VERIFY 
        require(tx.outputs[1].tokenAmount >=  tx.inputs[this.activeInputIndex].tokenAmount - installment);

        // Require the token category is identical
        // OP_1 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY 
        require(tx.outputs[1].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);

        // Require the second output match the active bytecode
        // OP_1 OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY 
        require(tx.outputs[1].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);

    } // OP_ENDIF

  } // OP_2DROP OP_DROP

  // Withdraw or adjust balances.
  // OP_1 OP_ELSE OP_4 OP_ROLL OP_1 OP_NUMEQUALVERIFY 
  function withdraw() {

      // Transactions beginning with a minting auth baton are unrestricted
      // OP_0 OP_UTXOTOKENCATEGORY OP_SWAP OP_2 OP_CAT OP_EQUAL
      require(tx.inputs[0].tokenCategory == authCat + 0x02);

  }  // OP_NIP OP_NIP OP_NIP 
  

} // OP_ENDIF
3 Likes

To facilitate management of authentication batons, wallet software can denote a pointer to purpose of the NFT in the commitment.

The full locking bytecode for a p2sh32 address of the associated Will or Subscription is 35 bytes. This leaves room for a three character identifier and push bytes encoding:

    "phi"    <contract lockingBytecode>
03 555033 23 aa20<32-byte hash>87

Prior to the contract being called, wallet software would need to find the op_return record matching the baton to know the parameters, which would require some kind of indexer.

But after the contract has been called, wallet software can easily infer the type, and parameters, of the contract from any spending transaction, which eliminates the need for a custom indexer.

Given the contract parameters, wallet software could locally calculate metadata for the user’s “one-off” NFT baton with a static function.

Dutch Token Auctions

In a straight dutch auction, the asking price for an item starts high, then decreases incrementally until a party bids―at which point, the first bidder wins.

Since a Bitcoin Cash unspent transaction output can carry tokens, it’s straight-forward to encode this locking logic in a standard contract and allow any consignor to encumber their property for auction.

With the below contract, sellers could begin their tokens at some very high price (1000-100 BCH) and bidders then divide the initial price by the age of the unspent output to determine the current asking price.

A seller with dozens of NFTs or tokens could sell them all as utxos on a single auction contract.


// Unspent Phi v3
//
// Dutch Auction  
//
// Sell NFTs or fungible tokens. 
//
// Start from a (very) high price, allowing lower and lower bids each block.
// The first bid above the threshold wins the lot.
//
// - Consignors may sell many lots at once.
// - Each UTXO is one token lot.
// - Lots CANNOT be pullled or passed, everything must sell.
// - Lots can be batched in transactions 1:1 per input/output.
// - Minimum price is open divided by 65.5k blocks (1 year). 
//
//   Transaction
//          Inputs           -> Outputs
//  ==============================================
//  i    | Dutch  800 sats   -> Consigner xx,xxx sats
//       |          1 NFT                      - 
// [i+1] | Dutch  800 sats   -> Consigner xx,xxx sats
//       |        100 FT                      - 
// len-1 | Buyer xx,xxx sats -> Winner 800 sats
//       |            -                  1 NFT
// len   | Buyer xx,xxx sats -> Winner 800 sats
//       |            -                100 FT
// 
// String & op_return serializations:
//
// N,3,<start>,<receiptLockingBytecode>,<contractLockingBytecode>
// 
// 6a 047574786f 01 4e 01 03 ... <contractLockingBytecode>
//
//
// unlocking bytecode: 
// c2529c69c0cc517a537996a269c0cd517a87690079b275007a03ffff009f

contract DutchAuction(

  // Opening bid 
  int open,

  // LockingBytecode of the consigner, the address receiving proceeds
  bytes recipientLockingBytecode

) {

  // 
  function buy(int age) {
       
    // Require version 2 for BIP68 support
    // OP_TXVERSION OP_2 OP_NUMEQUALVERIFY
    require(tx.version == 2, "must use v2 transaction"); 

    // Check that each output sends the balance minus an executor allowance
    // OP_INPUTINDEX OP_OUTPUTVALUE OP_SWAP OP_3 OP_PICK OP_DIV OP_GREATERTHANOREQUAL OP_VERIFY
    require(tx.outputs[this.activeInputIndex].value >= open/age, "bid too low");

    // Check that each output sends to the recipient
    // OP_INPUTINDEX OP_OUTPUTBYTECODE OP_EQUALVERIFY
    require(tx.outputs[this.activeInputIndex].lockingBytecode == recipientLockingBytecode, "must pay consigner");
    
    // Check that time has passed
    // OP_DUP OP_CHECKSEQUENCEVERIFY OP_DROP
    require(tx.age >= age, "must satisfy age (bip68)");

    // Require the active input nSequence number is provided in blocks.
    // ffff00 OP_LESSTHAN
    require(age < 65535, "must specify age in blocks");   
  } 

}```
1 Like