Skip to main content

Tutorial 3: Oracle

Overview

In this tutorial, we will go over how to build a smart contract that consumes off-chain data from an oracle. Specifically, we will implement a smart contract that lets two players bet on the price of BSV at some point in the future. It retrieves prices from an oracle.

What is an Oracle?

A blockchain oracle is a third-party service or agent that provides external data to a blockchain network. It is a bridge between the blockchain and the external world, enabling smart contracts to access, verify, and incorporate data from outside the blockchain. This allows smart contracts to execute based on real-world events and conditions, enhancing their utility and functionality.

Credit: bitnovo

The data supplied by oracles can include various types of information, such as stock prices, weather data, election results, and sports scores.

Rabin Signatures

A digital signature is required to verify the authenticity and integrity of arbitrary data provided by known oracles in a smart contract. Instead of ECDSA used in Bitcoin, we use an alternative digital signature algorithm called Rabin signatures. This is because Rabin signature verification is orders of magnitude cheaper than ECDSA. We have implemented Rabin signature as part of the standard libraries scrypt-ts-lib, which can be imported and used directly.

Contract Properties

Our contract will take signed pricing data from the WitnessOnChain oracle. Depending if the price target is reached or not, it will pay out a reward to one of the two players.

There are quite a few properties which our price betting smart contract will require:

// Price target that needs to be reached.
@prop()
targetPrice: bigint

// Symbol of the pair, e.g. "BSV_USDC"
@prop()
symbol: ByteString

// Timestamp window in which the price target needs to be reached.
@prop()
timestampFrom: bigint
@prop()
timestampTo: bigint

// Oracles Rabin public key.
@prop()
oraclePubKey: RabinPubKey

// Addresses of both players.
@prop()
aliceAddr: Addr
@prop()
bobAddr: Addr

Notice that the type RabinPubKey, which represents a Rabin public key, is not a standard type. You can import it the following way:

import { RabinPubKey } from 'scrypt-ts-lib'

Public Method - unlock

The contract will have only a single public method, namely unlock. As parameters, it will take the oracles signature, the signed message from the oracle, and a signature of the winner, who can unlock the funds:

@method()
public unlock(msg: ByteString, sig: RabinSig, winnerSig: Sig) {
// Verify oracle signature.
assert(
RabinVerifierWOC.verifySig(msg, sig, this.oraclePubKey),
'Oracle sig verify failed.'
)

// Decode data.
const exchangeRate = PriceBet.parseExchangeRate(msg)

// Validate data.
assert(
exchangeRate.timestamp >= this.timestampFrom,
'Timestamp too early.'
)
assert(
exchangeRate.timestamp <= this.timestampTo,
'Timestamp too late.'
)
assert(exchangeRate.symbol == this.symbol, 'Wrong symbol.')

// Decide winner and check their signature.
const winner =
exchangeRate.price >= this.targetPrice
? this.alicePubKey
: this.bobPubKey
assert(this.checkSig(winnerSig, winner))
}

Let's walk through each part.

First, we verify that the passed signature is correct. For that we use the RabinVerifierWOC library from the scrypt-ts-lib package

import { RabinPubKey, RabinSig, RabinVerifierWoc } from 'scrypt-ts-lib'

Now, we can call the verifySig method of the verification library:

// Verify oracle signature.
assert(
RabinVerifierWOC.verifySig(msg, sig, this.oraclePubKey),
'Oracle sig verify failed.'
)

The verification method requires the message signed by the oracle, the oracles signature for the message, and the oracle's public key, which we already set via the constructor.

Next, we need to parse information from the chunk of data that is the signed message and assert on it. For a granular description of the message format check out the "Exchange Rate" section in the WitnessOnChain API docs.

We need to implement the static method parseExchangeRate as follows:

// Parses signed message from the oracle.
@method()
static parseExchangeRate(msg: ByteString): ExchangeRate {
// 4 bytes timestamp (LE) + 8 bytes rate (LE) + 1 byte decimal + 16 bytes symbol
return {
timestamp: Utils.fromLEUnsigned(slice(msg, 0n, 4n)),
price: Utils.fromLEUnsigned(slice(msg, 4n, 12n)),
symbol: slice(msg, 13n, 29n),
}
}

We parse out the following data:

  • timestamp - The time at which this exchange rate is present.
  • price - The exchange rate encoded as an integer -> (priceFloat * (10^decimal)).
  • symbol - The symbol of the token pair, e.g. BSV_USDC.

Finally, we wrap the parsed values in a custom type, named ExchangeRate and return it. Here's the definition of the type:

type ExchangeRate = {
timestamp: bigint
price: bigint
symbol: ByteString
}

Now we can validate the data. First, we check if the timestamp of the exchange rate is within our specified range that we bet on:

assert(
exchangeRate.timestamp >= this.timestampFrom,
'Timestamp too early.'
)
assert(
exchangeRate.timestamp <= this.timestampTo,
'Timestamp too late.'
)

Additionally, we check if the exchange rate is actually for the correct token pair:

assert(exchangeRate.symbol == this.symbol, 'Wrong symbol.')

Lastly, upon having all the necessary information, we can choose the winner and check their signature:

const winner =
exchangeRate.price >= this.targetPrice
? this.alicePubKey
: this.bobPubKey
assert(this.checkSig(winnerSig, winner))

As we can see, if the target price is reached, only Alice is able to unlock the funds, and if not, then only Bob is able to do so.

Conclusion

Congratulations! You have completed the oracle tutorial!

The full code along with tests can be found in sCrypt's boilerplate repository.