Vault Language Proposal

Vault Language Proposal

Author : Joonmo Yang<jmyang@codechain.io>

Created: 2019-10-02

1. Abstract

Writing a complex script for CodeChain Virtual Machine(CCVM) is difficult. Although it is powerful enough to express most of the common use cases for UTXOs, it’s too low-level for programmers to manage. Bitcoin had a similar problem for their script system, and Miniscript was proposed recently to address this situation. We propose Vault, a simple and human-readable script language inspired by Miniscript to provide a better experience for writing scripts for CodeChain VM.

2. Examples

2.1. p2pk

pk(A)

2.2. p2pkh

pkh(H_A)

2.3. A or B

pk(A) || pk(B)

2.4. (A and B) can unlock, or H_C can burn before 100s

success = pk(A) && pk(B);
burn = pkh(C) && before(100s);

2.5. 2-of-3 multisig

pk(2, A, B, C)

2.6. 2 of H_A, B and 100s timelock

thresh(2, pkh(H_A), pk(B), after(100s))

2.7. Hash Time Locked Contract

pk(A) && after(1000blk) || pkh(B) && hash(X)

3. Overview

3.1. Writing a Vault script

Top level structure:

success = expr ;
burn = expr ;

If success = expr ; is omitted, success = false; is inserted as a default.

If burn = expr ; is omitted, burn = false; is inserted as a default.

If burn = expr ; is omitted, success = and the semicolon can be omitted from the success condition as well.

Base expressions:

  • pk(A): true if the unlock script provides a signature of public key A
  • pkh(H_A): true if the unlock script provides a signature and a public key of the public key hash H_A
  • after(time): true if the asset is unlocked after time. time can be specified in seconds (s) or a block number (blk)
  • before(time) : true if the asset is unlocked after time. time can be specified in seconds (s) or a block number (blk)
  • hash(X) : true if the unlock script provides a preimage of hash X

Complex expressions:

  • A && B = thresh(2, A, B) : true if both A and B are true
  • A || B = thresh(1, A, B) : true if either A or B is true
  • thresh(n, A, B, C, …) : true if exactly n(>0) expressions in A, B, C, … are true

3.2. Locking an asset with Vault


A Vault script is compiled into two values: the lock script and the parameters. When creating an asset in CodeChain, the creator should attach the parameters and the hash of the compiled lock script to the transaction.

3.3. Unlocking an asset with Vault


When unlocking an asset locked with a Vault script, the required unlock script can be generated from the compiler. The compiler receives a Vault script, list of signatures and preimages, expected execution time, and a flag for whether to burn or unlock this asset. The unlock script will be generated only if the conditions specified by the Vault script can be satisfied with the given values and the given time. If there are multiple combinations of values that can satisfy the lock condition, the generated unlock script could be different depending on the implementation. With the generated unlock script, the asset owner can unlock and spend the asset by attaching it to a transaction.

3.4. Example Usage

The following is a example Javascript-like pseudocode that locks and unlocks an asset with the Vault script pk(A) || after(100s). The details might be changed in the actual implementation.

// Creating an asset
const pubkey = "SOME_PUBLIC_KEY";
const timelock = 100;
const script = `pk(${pubkey}) || after(${timelock}s)`;

const [lockScript, parameters] = compileVault(script);
const lockScriptHash = blake160(lockScript);
const createTransaction = new TransferAssetTransaction({
    ...someOtherArguments1,
    outputs: [{ lockScriptHash, parameters }]
);

<Example pseudocode for creating an asset with Vault>

// Unlocking an asset
const pubkey = "SOME_PUBLIC_KEY";
const timelock = 100;
const script = `pk(${pubkey}) || after(${timelock}s)`;

const tag = createTag(ALL_OUTPUTS, ALL_INPUTS);
const signatures = { [pubkey]: [tag, "VALID_SIGNATURE"] };
const confirmTime = 120;
const [lockScript, unlockScript] = unlockVault(script, {
    signatures,
    time: confirmTime
});
const unlockTransaction = new TransferAssetTransaction({
    ...someOtherArguments2,
    inputs: [{
        utxo: createTransaction.output[0],
        lockScript,
        unlockScript
    }]
});

<Example pseudocode for unlocking an asset created with Vault>

4. Language Specification

4.1. Top level structure

A Vault script consists of two parts: the success condition and the burn condition. The order of the conditions does not matter. Examples of top-level structures are as follows:

Condition type Syntax Default
success success = expr ; success = false;
burn burn = expr ; burn = false;

If a condition is omitted, the default condition is automatically inserted. Furthermore, if the burn condition is omitted, the success = part and the semicolon can be omitted as well.

The following are examples of how the omitted conditions are treated:

Script Fully expanded
success = pk(A);
burn = after(100s);
success = pk(A);
burn = after(100s);
success = pk(A); success = pk(A);
burn = false;
burn = after(100s); success = false;
burn = after(100s);
pk(A) success = pk(A);
burn = false;

4.2. Types

Expressions accept one or more parameters, and each of the parameters must have the correct type that the expression expects. The type check is performed at compile-time and does not affect the run-time behavior of an expression. The following types are used in the expressions:

Name Description Syntax Example
U64 64bit unsigned integer 0 [1-9][0-9]* 12345
H160 160bit hexadecimal value 0x[0-9a-fA-F]{20} 0x0123456789abcdef0123
H256 256bit hexadecimal value 0x[0-9a-fA-F]{32} 0x(0123 repeated 8 times)
H512 512bit hexadecimal value 0x[0-9a-fA-F]{64} 0x(0123 repeated 16 times)
Time Length of time (U64 value)s
(U64 value)blk
300s
128blk

4.3. Expressions

4.3.1. Base expressions

Expression Parameter Type Description
true Always true
false Always false
pk(pub) pub: H512 True if a signature of public key pub is provided.
pk(m, pub1, …, pubn) m: U64
pub: H512
True if m signatures of pub1, …, pubn are provided.
pkh(hash) hash: H160 True if a public key that hashes to hash and the signature of that public key is provided. Blake160 is used as the hash function.
after(time) time: Time True if it’s executed after time.
before(time) time: Time True if it’s executed before time.
blake256(hash) hash: H256 True if the blake256 preimage of hash is provided.
sha256(hash) hash: H256 True if the sha256 preimage of hash is provided.
ripemd160(hash) hash: H160 True if the ripemd160 preimage for hash is provided.
keccak256(hash) hash: H256 True if the keccak256 preimage for hash is provided.
blake160(hash) hash: H160 True if the blake160 preimage for hash is provided.

4.3.2. thresh expression

The thresh expression is the only way to combine different expressions. The syntax is as follows:

thresh(m, e1, …, en) (m: U64 > 0, e1,…,en: Expression)

The thresh expression above is true when exactly m expressions in subexpressions e1, …, en are true. The evaluation order of the subexpressions is from left to right, and if the number of true expressions becomes m, the remaining expressions are not executed. In other words, if a user can satisfy m expressions in e1, …, ek, then ek+1, …, en will not be executed.

To provide a better user experience, the following syntactic sugars are provided:

  • expr1 || expr2 = thresh(1, expr1, expr2)
  • expr1 && expr2 = thresh(2, expr1, expr2)

These syntactic sugars are expanded to the thresh expression by the compiler before compiling them to the CCVM instructions. Note that if && and || are used together, && has a higher precedence over ||.

5. Generating a lock script

5.1. Compiling base expressions

Base expressions are compiled into the CodeChain script according to the following translation rules:

Expression Parameters Script
true PUSH 1
false PUSH 0
pk(pub) pub CHKSIG
pk(m, pub1, …, pubn) n pub1 … pubn m CHKMULTISIG
pkh(hash) hash COPY 1 BLAKE160 EQ JZ 2 PUSH 0 JMP 1 CHKSIG
after(time) time RELTIME GT
before(time) time RELTIME LT
blake256(hash) hash SWAP BLAKE256 EQ
sha256(hash) hash SWAP SHA256 EQ
ripemd160(hash) hash SWAP RIPEMD160 EQ
keccak256(hash) hash SWAP KECCAK256 EQ
blake160(hash) hash SWAP BLAKE160 EQ

5.2. Compiling thresh expressions

A thresh expression executes its subexpressions from left to right, and stops the execution when the number of true subexpressions becomes m(>0). This is implemented by pushing a special value counter to the stack and incrementing it if the execution result of a subexpression is true. When the counter becomes m, the remaining subexpressions are not executed and the parameters of those subexpressions are removed from the stack. This ensures that the stack only contains the parameters of the remaining expressions.

5.2.1. Parameters

The parameters of the thresh(m, e1, …, en) are defined as follows:

…param(e1), …, …param(en), m

5.2.2. Preparing counter

At the start of the execution, a special value counter is created for tracking the number of true subexpressions in the current thresh expression. It is initialized by the following script:

PUSH 0

5.2.3. Executing subexpressions

Before executing the subexpressions, the parameters for that expression is lifted to the top of the stack. If the subexpression is a base expression, the required number of values from the unlock script are lifted too. Note that these lifted values will be consumed and removed from the stack after executing the subexpression.

After executing a subexpression, the stack will look like the following table:

Stack Description
0/1 Execution result of the subexpression
0 counter
Parameters of the remaining subexpressions
m Number of required true expressions

The result is added to the counter and is compared with the threshold value(m) in the stack. If the counter is equal to m, all the remaining subexpressions are skipped. It can be expressed as the following script:

ADD DUP COPY (index of m in the stack) EQ JNZ (distance to the POP instructions)
Instruction Stack (left is the stack top)
0/1(result) counter params m
ADD new_counter params m
DUP new_counter new_counter params m
COPY m new_counter new_counter params m
EQ new_counter == m new_counter params m
JNZ new_counter params m

<Execution example>

When the subexpressions are skipped, all the remaining parameters for those subexpressions are dropped and 1 is pushed to the stack. It can be expressed as the following script:

POP POP … POP PUSH 1

For optimization purposes, the counter handling instructions for the last subexpression is different from the other instructions. When the last subexpression is executed, we don’t have to maintain the counter because we don’t have any more subexpression to execute. Thus, we can push counter + result == m to the stack as a result. It can be expressed as the following script:

ADD EQ JMP (distance to the end of the script)
Instruction Stack
0/1(result) counter m
ADD m
EQ new_counter == m
JMP new_counter == m

<Execution example for thresh>

So the compilation result of thresh(m, e1, e2, …, en) looks like this:

PUSH 0
    (…LIFT values for e1)
        (…instructions for e1)
    ADD DUP COPY (index of m) EQ JNZ (distance to POPs)
    (…LIFT values for e2)
        (…instructions for e2)
    ADD DUP COPY (index of m) EQ JNZ (distance to POPs)
    …
    (…LIFT values for en)
        (…instructions for en)
    ADD EQ JMP (distance to the end of the script)
POP … POP PUSH 1

5.3. Merging success and burn conditions

The final compilation result depends on the success expression and the burn expression. There are three cases we should consider:

5.3.1. burn = false

The compilation result of the expression for the success condition is used as the final result.

5.3.2. burn != false && success = false

In this case, the script must burn the asset if the burn expression is satisfied. Thus, the compilation result of the burn expression is used and the following instructions are added at the end:

JZ 1 BURN

5.3.3. burn != false && success != false

In this case, the compilation result requires an additional value to be provided by the unlock script. If the provided value is zero, the success condition is executed. Otherwise, the burn condition is executed.

5.4. Compilation examples

5.4.1. p2pk

Vault script: success = pk(A);
Parameters: A
Lock script: CHKSIG

5.4.2. p2pkh

Vault script: success = pkh(H_A);
Parameters: H_A
Lock script: COPY 1 BLAKE160 EQ JZ 2 PUSH 0 JMP 1 CHKSIG

5.4.3. A or B

Vault script: success = pk(A) || pk(B);
Parameters: A B 1
Lock script:

PUSH 0
LIFT 5 LIFT 5 LIFT 3
    CHKSIG
ADD DUP COPY 3 EQ JNZ 7
LIFT 4 LIFT 4 LIFT 3
    CHKSIG
ADD EQ JMP 4
POP POP POP PUSH 1

5.4.4. (A and B) can unlock, H_C can burn before 100s

Vault script:

success = pk(A) && pk(B);
burn = pkh(H_C) && before(100s);

Parameters: H_C 100 2 A B 2
Lock script:

JZ 30
DROP 3 DROP 3 DROP 3
PUSH 0
LIFT 5 LIFT 5 LIFT 3
    COPY 1 BLAKE160 EQ JZ 2 PUSH 0 JMP 1 CHKSIG
ADD DUP COPY 3 EQ JNZ 6
LIFT 1
    RELTIME LT
ADD EQ JMP 3
POP POP PUSH 1
JZ 25 BURN
POP POP POP
PUSH 0
LIFT 5 LIFT 5 LIFT 3
    CHKSIG
ADD DUP COPY 3 EQ JNZ 7
LIFT 4 LIFT 4 LIFT 3
    CHKSIG
ADD EQ JMP 4
POP POP POP PUSH 1

5.4.5. 2-of-3 multisig

Vault script: success = pk(2, A, B, C);
Parameters: 3 A B C 2
Lock script: CHKMULTISIG

5.4.6. 2 of H_A, B and 100s timelock

Vault script: thresh(2, pkh(H_A), pk(B), after(100s))
Parameters: A B 100 2
Lock script:

PUSH 0
LIFT 7 LIFT 7 LIFT 7 LIFT 4
    COPY 1 BLAKE160 EQ JZ 2 PUSH 0 JMP 1 CHKSIG
ADD DUP COPY 4 EQ JNZ 15
LIFT 5 LIFT 5 LIFT 3
    CHKSIG
ADD DUP COPY 3 EQ JNZ 7
LIFT 1
    RELTIME GT
ADD EQ JMP 5
POP POP POP POP PUSH 1

5.4.7. Hash Time Locked Contract

Vault script: pk(A) && after(1000blk) || pkh(B) && hash(X)
Parameters: A 1000 2 B X 2 1
Lock script:

PUSH 0
LIFT 3 LIFT 3 LIFT 3
    PUSH 0
    LIFT 10 LIFT 10 LIFT 3
        CHKSIG
    ADD DUP COPY 3 EQ JNZ 6
    LIFT 1
        RELTIME GT
    ADD EQ JMP 4
    POP POP POP PUSH 1
ADD DUP COPY 6 EQ JNZ 38
LIFT 4 LIFT 4 LIFT 4 LIFT 4
    PUSH 0
    LIFT 8 LIFT 8 LIFT 8 LIFT 4
        COPY 1 BLAKE160 EQ JZ 2 PUSH 0 JMP 1 CHKSIG
    ADD DUP COPY 3 EQ JNZ 8
    LIFT 5 LIFT 2
        SWAP BLAKE160 EQ
    ADD EQ JMP 4
    POP POP POP PUSH 1
ADD EQ JMP 6
POP POP POP POP POP PUSH 1

6. Generating an unlock script

The compiler is also responsible for generating an unlock script corresponding to a Vault script when the correct set of values are given.

6.1. Deciding which expressions to satisfy

For the assets locked with a Vault script, the structure of the unlock script differs a lot depending on the conditions that can be satisfied. For example, for the expression thresh(2, e0, e1, e2), if we are only aware of the values that can satisfy e0 and e2, we have to provide some values that can make the e1’s result false. However, if we know the values that can satisfy e0 and e1, we don’t have to provide any more values.

Thus, the list of expressions to be satisfied should be decided before generating an unlock script for a Vault script. The compiler receives values that can be used for this decision, and automatically derives the list of expressions from them. The list of values that the compiler receives is as follows:

  • Map of (public key, (tag, signature)) (default: empty map)
  • Map of (hash, preimage) (default: empty map)
  • Expected age of an asset at the time of execution in terms of seconds
  • Expected age of an asset at the time of execution in terms of blocks
  • Flag for deciding whether to unlock or burn. burn and success are allowed. (default: success)

All the values are optional, but the execution time must be provided if the Vault script contains after(…s) or before(…s) and the execution block number must be provided if the Vault script contains after(…blk) or before(…blk).

The resulting list of expressions must satisfy the following rules:

  • If the flag is success, the top level expression of the success condition must be selected.
  • If the flag is burn, the top level expression of the burn condition must be selected.
  • If thresh(m, e0, …, en) is selected, exactly m expressions in e0, …, en must be selected.
  • If a base expression is selected, the condition in the table below must be satisfied.
Expression Satisfaction condition
true Always
false Never
pk(pub) The signature table contains an entry for public key pub
pk(m, pub1, …, pubn) The signature table contains m entries in n public keys pub1, …, pubn
The signature table’s entries for pub1, …, pubn must have the same value for tag
pkh(hash) The preimage table contains an entry for hash.
The preimage of the hash has a length of 64.
The signature table contains an entry for the preimage of hash
after(times) The expected age of an asset in second is larger than time.
after(timeblk) The expected age of an asset in block is larger than time.
before(times) The expected age of an asset in second is less than time.
before(timeblk) The expected age of an asset in block is less than time.
blake256(hash) The preimage table contains an entry for hash.
sha256(hash) The preimage table contains an entry for hash.
ripemd160(hash) The preimage table contains an entry for hash.
keccak256(hash) The preimage table contains an entry for hash.
blake160(hash) The preimage table contains an entry for hash.

<Base expression satisfaction table>

If the top level expression couldn’t be selected, the compiler throws an error. If there were multiple possible ways to select a list of expressions, the actual selected list of expressions depends on the implementation.

6.2. Base expressions

There are some cases (e.g. in thresh expression) where we have to intentionally make the script fail. To generate such unlock scripts, dummy values that are expected to never satisfy the condition is used.

Unlock scripts that {do, do not} satisfy the condition for the base expressions are as follows:

Expression Used values Unlock script that satisfies the condition Unlock script that fails to satisfy the condition
true Always possible Impossible
false Impossible Always possible
pk(pub) (tag, sig) = SignatureMap[pub] PUSHB 65 sig PUSH tag PUSHB 65 0x0…0 PUSH 0
pk(m, pub1, …, pubn) sig1, sig2, … = signatures of m public keys in pub1, …, pubn PUSH tag PUSHB 65 sig1 PUSHB 65 sig2, … PUSH 0 PUSHB 65 sig1 PUSHB 65 sig2, …
pkh(hash) pub = PreimageMap[hash]
(tag, sig) = SignatureMap[pub]
PUSHB 65 sig PUSH tag PUSHB 64 pub PUSHB 65 0x0…0 PUSH 0 PUSHB 64 0x0…0
after(time) Always possible if selected
Impossible if not selected
Always possible if not selected
Impossible if selected
before(time) Always possible if selected
Impossible if not selected
Always possible if not selected
Impossible if selected
blake256(hash) preimage = PreimageMap[hash] PUSHB len(preimage) preimage PUSH 0 if blake256(0) != hash
PUSH 1 if blake256(0) == hash
sha256(hash) preimage = PreimageMap[hash] PUSHB len(preimage) preimage PUSH 0 if sha256(0) != hash
PUSH 1 if sha256(0) == hash
ripemd160(hash) preimage = PreimageMap[hash] PUSHB len(preimage) preimage PUSH 0 if ripemd160(0) != hash
PUSH 1 if ripemd160(0) == hash
keccak256(hash) preimage = PreimageMap[hash] PUSHB len(preimage) preimage PUSH 0 if keccak256(0) != hash
PUSH 1 if keccak256(0) == hash
blake160(hash) preimage = PreimageMap[hash] PUSHB len(preimage) preimage PUSH 0 if blake160(0) != hash
PUSH 1 if blake160(0) == hash

6.3. thresh expressions

As described in 4.3.2, the thresh(m, e1, …, en) expression executes the subexpressions from left to right, and skips the remaining subexpressions if the number of accumulated true expressions is equal to m. If ek is the rightmost subexpression in the selected subexpressions, ek is the last executed subexpression because the number of selected subexpressions is equal to m. Thus, the generated unlock script should provide inputs for all the subexpressions until ek, and should not provide inputs for the subexpressions after ek. If there are any subexpressions in e1, …, ek that are not selected, the generated unlock script should provide the inputs that can make that subexpression’s result false.

The generated unlock script for thresh(m, e1, …, en) is as follows:

…unlock(ek, is_selected(ek)), …, …unlock(e1, is_selected(e1))

where

  • ek = rightmost selected expression in e1, …, en
  • is_selected(e) = true if e is selected, false otherwise
  • unlock(e, true) = Unlock script that makes e succeed
  • unlock(e, false) = Unlock script that makes e fail

To make a thresh expression fail, all the unlock scripts for the subexpressions must fail as follows:

…unlock(en, false), …, …unlock(e1, false)

where

  • unlock(e, false) = Unlock script that makes e fail

6.4. Generating the final unlock script

After generating the unlock script for the selected expressions, we make final changes to the result for the burn/success selection.

6.4.1. burn = false

The generated unlock script of the success expression is used as the final result.

6.4.2. burn != false && success = false

The generated unlock script of the burn expression is used as the final result.

6.4.3. burn != false && success != false

In this case, the lock script requires an additional value as specified in 5.3.3. If the flag provided to the compiler was burn, PUSH 1 is appended at the end of the unlock script. If the flag was success, PUSH 0 is appended.

6.5. Example

6.5.1. p2pk

Vault script: success = pk(A);
Unlock script: PUSHB 65 <sig> PUSH 3

6.5.2. p2pkh

Vault script: success = pkh(H_A);
Unlock script: PUSHB 65 <sig> PUSH 3 PUSHB 64 <pub>

6.5.3. A or B

Vault script: success = pk(A) || pk(B);
Unlock script: PUSHB 65 <sigA> PUSH 3

6.5.4. (A and B) can unlock, H_C can burn before 100s

Vault script:

success = pk(A) && pk(B);
burn = pkh(H_C) && before(100s);

Unlock script: PUSHB 65 <sigC> PUSH 3 PUSHB 64 <pubC> PUSH 1

6.5.5. 2-of-3 multisig

Vault script: success = pk(2, A, B, C);
Unlock script: PUSH 3 PUSHB 65 <sigA> PUSHB 65 <sigB>

6.5.6. 2 of H_A, B and 100s timelock

Vault script: thresh(2, pkh(H_A), pk(B), after(100s))
Unlock script:

PUSHB 65 <0x0…0> PUSH 0
PUSHB 65 <sigA> PUSH 3 PUSHB 65 <pubA>

6.5.7. Hash Time Locked Contract

Vault script: pk(A) && after(1000blk) || pkh(B) && hash(X)
Unlock script:

PUSHB 32 <preX>
PUSHB 65 <sigB> PUSH 3 PUSHB 64 <pubB>
PUSHB 65 <0x0…0> PUSH 0

7. Required features in CodeChain

7.1. LIFT opcode

The LIFT opcode used in this document doesn’t exist in the CCVM now. Its behavior is "lifting” the value in the stack to the top. In other words, LIFT n removes the stack item at the nth index and pushes the removed value to the top of the stack. Its behavior is the same as COPY n DROP n+1, but since it’s repeated too much, it’d be better to make a new opcode for this.

7.2. Script-based timelock

The current implementation of the timelock uses the “timelock” field on the AssetTransferInput data structure. This allows only one timelock condition in an asset, so composable timelocks such as after(100s) && before(200s) && pk(A) cannot exist. One way of solving this is by introducing opcodes for fetching the current (absolute and relative) timestamp of the transaction. We can compare the desired time with the value retrieved from the opcode to check if the timelock is satisfied. The RELTIME opcode in this document is used for this purpose, representing the opcode for pushing the relative time value (in seconds) since the asset was created.

7.3. Arithmetic and Integer comparison opcodes

The ADD opcode is used for accumulating the number of satisfied expressions in thresh expressions. Also, GT and LT opcodes are used for checking the timelock. Since we do not have such opcodes in our current CCVM spec, we need to add these instructions.

1 Like

I had a presentation about this proposal at CodeChain Techtalk on 10/16.
Here’s the link for the slides.