Skip to main content

Cryptography

SolidCash exposes the Bitcoin Cash signature and hashing primitives directly, plus two Ethereum-compatible primitives that BCH has no opcode for: keccak256 and ecrecover. Both are implemented as co-spent side-cars so their cost lands on a dedicated input budget rather than on your contract's.

Hashing​

FunctionReturnsDescription
sha256(x)bytes32SHA-256 of x
hash256(x)bytes32Double SHA-256 of x
ripemd160(x)bytes20RIPEMD-160 of x
hash160(x)bytes20RIPEMD-160 of the SHA-256 of x
sha1(x)bytes20SHA-1 of x
keccak256(x)bytes32Real Keccak-256, Ethereum-identical

The four Bitcoin-native hashes compile to single opcodes and are effectively free. keccak256 does not.

keccak256​

Bitcoin Cash has no Keccak opcode, and computing keccak256 in Script costs roughly 2.26 million VM operations per 136-byte block, which exceeds what a contract can afford on its own input budget. SolidCash therefore compiles keccak256(x) to a compute-and-commit side-car: a small co-spent UTXO whose covenant runs keccak256 on its own per-input budget, commits the digest into its recreated NFT commitment, and self-preserves. Your contract reads that commitment as the result.

contract Verifier(bytes32 expected) {
function check(bytes preimage) {
require(keccak256(preimage) == expected);
}
}

The digest is byte-identical to Ethereum's, so role ids, EIP-712 domain separators and ERC-7201 namespaced slots all match what an Ethereum contract derives.

The soundness of the side-car rests on three checks the contract performs at the call site:

  • Preimage bind - the side-car hashed your argument, not some other value.
  • Covenant integrity - the co-spent input is a genuine compute side-car, verified by hashing its covenant.
  • Result read - the digest is read from the side-car's own recreated commitment.

A constant keccak256("literal") is folded at compile time and costs nothing at runtime.

note

The SDK co-spends side-cars transparently, so contract.unlock.check(preimage) just works, provided side-cars exist at the expected branded or shared address. One Keccak side-car serves up to three straight-line calls when their block cost fits, and a contract that calls keccak256 in a loop needs one for each iteration it reaches.

Signature checking​

caution

All signature-checking functions must comply with the NULLFAIL rule, which only allows 0x as an invalid signature. Any other invalid signature immediately fails the entire script.

checkSig​

bool checkSig(sig s, pubkey pk)

Checks that transaction signature s is valid for the current transaction and matches public key pk.

checkMultiSig​

bool checkMultiSig(sig[] sigs, pubkey[] pks)

Performs a multi-signature check over lists of signatures and public keys.

note

The SDK supports fixed and dynamic signature/public-key arrays. OP_CHECKMULTISIG uses legacy ECDSA signatures, so construct each signature argument with new SignatureTemplate(key, undefined, SignatureAlgorithm.ECDSA); the SDK's default Schnorr template is appropriate for checkSig, but not checkMultiSig.

checkDataSig​

bool checkDataSig(datasig s, bytes msg, pubkey pk)

Checks that s is a valid signature for message msg under public key pk. OP_CHECKDATASIG verifies the signature over sha256(msg), so the signer signs sha256(msg), not msg directly.

caution

This differs from Ethereum, where the digest is signed directly. Account for it when porting a signer.

Working around NULLFAIL​

Because a non-empty invalid signature aborts the script rather than returning false, use an empty 0x signature to mean "absent":

contract Escrow(pubkey seller, pubkey referee) {
// This fails outright: userSig is invalid for whichever key it does not match.
// require(checkSig(userSig, seller) || checkSig(userSig, referee));

function release(sig sellerSig, sig refereeSig) {
// Pass 0x for the unused signature so the check returns false instead of aborting.
require(checkSig(sellerSig, seller) || checkSig(refereeSig, referee));
}
}

Signature recovery (ecrecover)​

Bitcoin Cash exposes signature verification, not recovery: OP_CHECKDATASIG takes the public key rather than deriving it. SolidCash nevertheless implements real secp256k1 public-key recovery, in pure Script, via a co-spent recovery side-car pair. The result is bit-for-bit identical to the EVM's ecrecover precompile, so a signature produced by ethers.js recovers to the same address.

Three builtins expose it, all taking Solidity's (hash, v, r, s) shape:

FunctionReturnsDescription
ecrecoverPubKey(bytes32 hash, int v, bytes32 r, bytes32 s)pubkeyThe recovered secp256k1 public key
ecrecoverP2pkh(bytes32 hash, int v, bytes32 r, bytes32 s)addressThe signer's P2PKH locking bytecode
ecrecoverEvm(bytes32 hash, int v, bytes32 r, bytes32 s)bytes20The signer's Ethereum address
contract Permit(bytes20 signer) {
function verify(bytes32 digest, int v, bytes32 r, bytes32 s) {
require(ecrecoverEvm(digest, v, r, s) == signer);
}
}

Use ecrecoverEvm when checking against an Ethereum address (an EIP-712 permit, a MetaMask signature), and ecrecoverP2pkh when checking against a BCH address.

note

Bare Solidity ecrecover(hash, v, r, s) is rewritten to one of these projections only when the compiler's ecrecover option is set (to p2pkh or evm), and is otherwise undefined. This lets a buried library call, such as OpenZeppelin's ECDSA.sol, project correctly without patching the source.

caution

Recovery is expensive. It spans two co-spent side-car inputs per call, because the double-scalar multiplication cannot fit in a single input's operation budget even with GLV optimisation. If you already know the public key, use checkDataSig instead: it is a single opcode.

ecrecoverEvm additionally co-spends a Keccak sidecar to hash the recovered uncompressed public key. Sidecar NFTs must be provisioned before use on a real network; see Computation Sidecars and Result NFTs.

Recovering versus verifying​

If the public key is available to the spender, verifying is dramatically cheaper than recovering:

contract Cheap(bytes20 expectedPkh) {
function verify(datasig s, bytes message, pubkey pk) {
// One opcode. The pubkey is supplied by the unlocker and checked.
require(checkDataSig(s, message, pk));
require(hash160(pk) == expectedPkh);
}
}

This is as strong a guarantee as recovery: a valid signature over message under pk proves the signer controls pk. Recovery is only necessary when you must match a foreign, EVM-produced signature.

Hash and selector policy​

UseHashMatches Ethereum?
keccak256(data) at runtimereal keccak (side-car)Yes
keccak256("literal") constantreal keccak (compile-time fold)Yes
Yul keccak256(p, l)real keccakYes
erc7201(id) namespaced slotreal keccak, full EIP-7201 formulaYes
Function selector, defaultsha256No
Function selector / interfaceId under evmCompatreal keccak256 over the canonical signatureYes
event.selector (E.selector) under evmCompatreal keccak256Yes
Mapping / array physical storage slotsha256No (internal to BCH)
Event covenant name hash (event-address salt)sha256No (BCH-native)
caution

abi.encodeWithSignature and abi.encodeCall derive their 4-byte selector with sha256 by default, so encoded calldata is not cross-VM compatible. Use them for SolidCash-to-SolidCash calls, or enable evmCompat for real keccak256 selectors over Solidity's canonical signature spelling. abi.encodeWithSignature hashes the signature string it is handed, so under evmCompat a signature built at runtime is accepted and lowers through the keccak256 side-car.

See also​