Skip to content

Custom script builders

The transaction builder works with any script through two abstract classes:

  • LockingScriptBuilder, whose getScriptPubkey() returns the Locking Script, and whose parse(script) reads one back.
  • UnlockingScriptBuilder, whose getScriptSig() returns the Unlocking Script, built from the signatures list that signers fill, and whose parse(script) reads one back.

The built-in P2PKH, P2PK, multisig and data builders are all implementations of these. On this page we will write a pair for a script of our own: coins that need both a secret and a Signature to spend.

The examples use the keys and the fundingTransaction helper described in About the examples.

/// Locks coins to the SHA-256 hash of a secret, and to a Public Key hash.
/// Spending needs both the secret and a Signature from the key.
/// OP_SHA256 <hash> OP_EQUALVERIFY OP_DUP OP_HASH160 <pubkeyHash> OP_EQUALVERIFY OP_CHECKSIG
class HashLockBuilder extends LockingScriptBuilder {
late List<int> secretHash;
late List<int> pubkeyHash;
HashLockBuilder(this.secretHash, Address owner) : pubkeyHash = HEX.decode(owner.pubkeyHash160);
/// Reads the two hashes back out of an existing Locking Script.
HashLockBuilder.fromScript(SVScript script) : super.fromScript(script);
@override
SVScript getScriptPubkey() => ScriptBuilder()
.opCode(OpCodes.OP_SHA256)
.addData(Uint8List.fromList(secretHash))
.opCode(OpCodes.OP_EQUALVERIFY)
.opCode(OpCodes.OP_DUP)
.opCode(OpCodes.OP_HASH160)
.addData(Uint8List.fromList(pubkeyHash))
.opCode(OpCodes.OP_EQUALVERIFY)
.opCode(OpCodes.OP_CHECKSIG)
.build();
@override
void parse(SVScript script) {
final chunks = script.chunks;
if (chunks.length != 8 || chunks[0].opcodenum != OpCodes.OP_SHA256) {
throw ScriptException(ScriptError.SCRIPT_ERR_UNKNOWN_ERROR, 'Not a hash-lock script');
}
secretHash = chunks[1].buf!;
pubkeyHash = chunks[5].buf!;
}
}

The fromScript constructor of the base class stores the script and calls parse, so a builder that implements parse can read existing Outputs.

/// Unlocking Script: <signature> <public key> <secret>.
/// The signer fills [signatures] when the transaction is built.
class HashUnlockBuilder extends UnlockingScriptBuilder {
final SVPublicKey publicKey;
final List<int> secret;
HashUnlockBuilder(this.publicKey, this.secret);
@override
SVScript getScriptSig() {
if (signatures.isEmpty) return SVScript(); // before signing, the script is empty
return ScriptBuilder()
.addData(Uint8List.fromList(HEX.decode(signatures[0].toTxFormat())))
.addData(Uint8List.fromList(HEX.decode(publicKey.toHex())))
.addData(Uint8List.fromList(secret))
.build();
}
@override
void parse(SVScript script) {} // not needed for spending
}

Do note that getScriptSig is called before signing as well, when the builder serialises the Transaction to compute the digest. Return an empty script while signatures is empty.

We lock coins with spendToLockBuilder:

final secret = utf8.encode('correct horse battery staple');
final locker = HashLockBuilder(sha256(secret), myAddress);
final lockTx = TransactionBuilder()
.spendFromTxnWithSigner(DefaultTransactionSigner(sighashAll, aliceKey), fundingTx, 0,
TransactionInput.MAX_SEQ_NUMBER, P2PKHUnlockBuilder(aliceKey.publicKey))
.spendToLockBuilder(locker, BigInt.from(50000))
.sendChangeToPKH(myAddress)
.build(true);

We spend them with the custom unlocker. The signer signs as usual, and adds its Signature to the unlocker’s signatures:

final spendTx = TransactionBuilder()
.spendFromTxnWithSigner(DefaultTransactionSigner(sighashAll, aliceKey), lockTx, 1,
TransactionInput.MAX_SEQ_NUMBER, HashUnlockBuilder(aliceKey.publicKey, secret))
.spendToPKH(myAddress, BigInt.from(49900))
.build(true);

And we read an Output back:

final parsed = HashLockBuilder.fromScript(lockedOutput.script);
HEX.encode(parsed.pubkeyHash) == myAddress.pubkeyHash160; // true

For a script that needs no Signature, return the script from getScriptSig directly, or wrap a ready-made script in DefaultLockBuilder.fromScript / DefaultUnlockBuilder.fromScript. The Interpreter page has an example.