Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
Tutorial 7: Oracles
Overview
If your smart contract needs to consume data from the outside world, you can use an oracle.
In this tutorial, we’ll build an oracle that retrieves data from a REST API. We’ll also write a smart contract that consumes information from this oracle.
Specifically, our oracle will retrieve data from a REST API that provides mock credit score information for two users–one with a high credit score and one with a low credit score. Our smart contract will consume this information and allow the user to prove their credit score is above a certain threshold (e.g. >700).
Using our smart contract, an end user will be able to generate an attestation that their credit score is above a certain value, which they can present to a third party to prove this fact without needing to share the exact credit score or other personal info with this 3rd party, maintaining their privacy.
Although we’re using a mock credit score API as our data source, you can easily swap out the oracle’s HTTP request to use any REST API or GraphQL API source of your choice, to create an oracle for any type of data.
Oracle
How it works
Let’s start by understanding how an oracle works.
Mina smart contracts run off-chain and make it possible to prove that the expected computation was run on private data without revealing the data itself. But if the smart contract consumes data from a 3rd party source, we need a way to verify that this data is authentic–i.e. that it was provided by the expected source.
In the future, Mina’s zkOracles will allow a zkApp to consume data trustlessly from any HTTPS data source. But for now, the oracle design described in this tutorial will typically be operated by the zkApp developer, it will fetch and sign the desired data, and then a zkApp can consume this data and will verify the signature to ensure that the data was provided by the expected source.
Data providers can also operate response signers (like the one described above) to provide users with an oracle that does not require them to trust an intermediary. In other words, if a credit score or other data provider chooses to sign response data themselves, users can consume data from that source without trusting anybody besides the data provider (who users are already trusting to provide correct data).
Design
Our oracle design will be quite simple.
We will fetch data from the desired source, sign it using a Mina-compatible private key, and return the data, signature, and public key associated with our private key, so that our signature can be verified by the zkApp.
Code
You can view the complete oracle code here and can generate a Mina-compatible public/private key pair for your oracle by running npm run keygen
(which will run the code in this file).
For our oracle, we’re using a Koa server hosted on DigitalOcean. It’s short, but you don’t have to dive into the code now. It’s commented to explain each step so you can build something similar for yourself!
You can easily adapt this code to create oracles for other API sources. For example, if you wanted your smart contract to ingest price feed data from an exchange, you could simply query the exchange API, sign the results, and return a response formatted as described below.
Response Format
The oracle returns its response in JSON with three, top-level properties: data
, signature
, & publicKey
.
data
is an object of the information we are interested in and can have any form.signature
is a signature for thedata
created using the oracle operator’s private key. Smart contracts will use this to verify that data was provided by the expected source.publicKey
is the public key of the oracle. This will be the same for all requests to this oracle.
For example, below is a response from our oracle for the user with an id
of 1
. In the real world, this id
might be a social security number or a similar identifier. Notice that the data property contains their credit score and user id.
{
"data": { "id": "1", "creditScore": "787" },
"signature": {
"r": "6879645159505819706680368079573694250155734132188077159564484773379936889926",
"s": "25770716061409035848137554965765890473013735453379104563619678415983125445906"
},
"publicKey": "B62qoAE4rBRuTgC42vqvEyUqCGhaZsW58SKVW4Ht8aYqP9UTvxFWBgy"
}
Our demo oracle also provides a response for a user id of 2
, who has a credit score that will be below our specified threshold when we write our smart contract further below.
{
"data": { "id": "2", "creditScore": "536" },
"signature": {
"r": "17330964553212655684849406067090021752962217822408913431446690101683066224611",
"s": "26366190712354094401916556126787229546643001327044048354949322240615669643867"
},
"publicKey": "B62qoAE4rBRuTgC42vqvEyUqCGhaZsW58SKVW4Ht8aYqP9UTvxFWBgy"
}
While the first user has a credit score of 787
, the second user only has a credit score of 536
. We can also see that the signature
has changed. This makes sense because the payload is different from what we received in the first response. Finally, notice that the publicKey
is the same because in each case we are querying data from the same provider.
For purposes of this tutorial below, you can access our demo oracle at https://mina-credit-score-signer-pe3eh.ondigitalocean.app/user/1 and https://mina-credit-score-signer-pe3eh.ondigitalocean.app/user/2, if you don’t want to set up your own oracle right now.
Smart Contract
Now that we have an oracle that returns signed data, we will write a smart contract that uses this data.
Setup
The following steps assume you've installed dependencies to your machine as described in Tutorial 1: Hello World — if not, please do that first.
Now, set up a new project with:
$ zk project oracle
Delete the default generated files by running:
$ rm src/Add.ts
$ rm src/Add.test.ts
$ rm src/interact.ts
And create new files:
$ zk file CreditScoreOracle
And lastly, change index.ts
to:
import { CreditScoreOracle } from './CreditScoreOracle.js';
export { CreditScoreOracle };
Writing our Smart Contract
Open up /src/CreditScoreOracle.ts
and paste in the following:
import {
Field,
SmartContract,
state,
State,
method,
DeployArgs,
Permissions,
PublicKey,
Signature,
PrivateKey,
} from 'snarkyjs';
// The public key of our trusted data provider
const ORACLE_PUBLIC_KEY =
'B62qoAE4rBRuTgC42vqvEyUqCGhaZsW58SKVW4Ht8aYqP9UTvxFWBgy';
export class OracleExample extends SmartContract {
// Define contract state
// Define contract events
deploy(args: DeployArgs) {
super.deploy(args);
this.setPermissions({
...Permissions.default(),
editState: Permissions.proofOrSignature(),
});
}
@method init(zkappKey: PrivateKey) {
super.init(zkappKey);
// Initialize contract state
// Specify that caller should include signature with tx instead of proof
this.requireSignature();
}
@method verify(id: Field, creditScore: Field, signature: Signature) {
// Get the oracle public key from the contract state
// Evaluate whether the signature is valid for the provided data
// Check that the signature is valid
// Check that the provided credit score is greater than 700
// Emit an event containing the verified users id
}
}
This just adds the basic setup for our smart contract. For more details on the deploy()
method — see Tutorial 1: Hello World.
On-Chain State
Our smart contract will store the public key for the oracle that we choose to retrieve data from as on-chain state so that it is available when end users run the smart contract. The smart contract will then use this to verify the signature of the data to confirm it came from the expected source.
// Define contract state
@state(PublicKey) oraclePublicKey = State<PublicKey>();
We’ll use the init
method to initialize oraclePublicKey
to the credit score oracle’s public key.
@method init(zkappKey: PrivateKey) {
super.init(zkappKey);
// Initialize contract state
this.oraclePublicKey.set(PublicKey.fromBase58(ORACLE_PUBLIC_KEY));
// Specify that caller should include signature with tx instead of proof
this.requireSignature();
}
init
is a method that the contract developer can run after the contract is deployed, but before users have the chance to interact with it, to set the initial on-chain state and other configuration. You can think of it like a constructor in a Solidity contract.
Emitting Events
Our smart contract will check that a user has a credit score above a certain threshold, but how can we expose the result to the outside world? We can emit events! Events allow smart contracts to publish arbitrary messages that anybody can verify without requiring that we store them in the state of a zkApp account. This property makes them ideal for communication with other parts of your application that don’t live on-chain (like your UI, or even an external service).
Let’s add an events
object to our Smart Contract class to define the names and types of the events it will emit.
// Define contract events
events = {
verified: Field,
};
Defining our verify()
Method
Now, let’s add a method to verify a user’s credit score is above 700.
This is defined the same as any other TypeScript method, except that it must have the @method
decorator in front of it, which tells SnarkyJS that this method can be invoked by users when they interact with the smart contract.
@method verify(id: Field, creditScore: Field, signature: Signature) {
We will pass in a few arguments.
userId
: The id of the users whose credit score we requested. Necessary to prevent bad actors from querying somebody else data and claiming it as their own.creditScore
: The (fake) credit score of the user (a number between 350 and 800).signature
: A cryptographic signature of ouruserId
andcreditScore
. This is what our smart contract will use in order to verify that the data was provided by the expected source.
The verify()
method will not return any values or change any contract state, instead it will emit an id
event with the user’s id if their credit score is above 700.
Fetching the Oracle’s Public Key
Now let’s get the oracle’s public key from the on-chain state. We will need this to verify the signature of data from the oracle.
// Get the oracle public key from the contract state
const oraclePublicKey = this.oraclePublicKey.get();
this.oraclePublicKey.assertEquals(oraclePublicKey);
We use assertEquals()
to ensure that the public key we retrieved at execution time is the same as the public key that exists within the zkApp account on the Mina network when the transaction is processed by the network.
Verify the Signature
Next, we’ll verify that the signature on the data (id
and creditScore
) is valid for the expected public key, to ensure it was from our expected source. This will return true if the signature is valid, and false if it is not.
// Evaluate whether the signature is valid for the provided data
const validSignature = signature.verify(oraclePublicKey, [id, creditScore]);
We also want it to make it impossible to generate a valid zero-knowledge proof if validSignature
is false. We can do this with assertTrue()
. If the signature is invalid, this will throw an exception and make it impossible to generate a valid ZKP and transaction.
// Check that the signature is valid
validSignature.assertTrue();
Check that the Users Credit Score Is Above 700
We want our verify()
method to only emit an event if the user’s credit score is above 700. We can ensure that this condition is met by calling the assertGte()
(i.e. assert greater than or equal to) on the creditScore
.
// Check that the provided credit score is greater than 700
creditScore.assertGte(Field(700));
These assert methods create a constraint that makes it impossible for users to generate a valid zero-knowledge proof unless their condition is met. Without a valid zero-knowledge proof (or a signature) it’s impossible to generate a valid Mina transaction. So, we can now rest assured that users can only call our smart contract method and send a valid transaction if they have a valid signature from our expected oracle and a credit score above 700.
Emitting our verified
Event
Now that we are sure everything checks out, we can emit an event to indicate this. The first argument to emitEvent()
is an arbitrary string name chosen by the developer (because a smart contract could emit more than one type of event) and the second argument can be any value, as long as it matches the type defined for our event earlier. In this case, our event is Field
, but it could be a more complicated type built on Fields, if the situation called for it. Emitted events are stored and available on archive nodes in the Mina network.
// Emit an event containing the verified users id
this.emitEvent('verified', id);
Testing it Out
The zkApp CLI automatically generated a file called CreditScoreOracle.test.ts
for us when we ran zk file CreditScoreOracle
. Let’s add some tests. Open it and paste in the following:
import { OracleExample } from './CreditScoreOracle';
import {
isReady,
shutdown,
Field,
Mina,
PrivateKey,
PublicKey,
AccountUpdate,
Signature,
} from 'snarkyjs';
// The public key of our trusted data provider
const ORACLE_PUBLIC_KEY =
'B62qoAE4rBRuTgC42vqvEyUqCGhaZsW58SKVW4Ht8aYqP9UTvxFWBgy';
let proofsEnabled = false;
function createLocalBlockchain() {
const Local = Mina.LocalBlockchain({ proofsEnabled });
Mina.setActiveInstance(Local);
return Local.testAccounts[0].privateKey;
}
async function localDeploy(
zkAppInstance: OracleExample,
zkAppPrivatekey: PrivateKey,
deployerAccount: PrivateKey
) {
const txn = await Mina.transaction(deployerAccount, () => {
AccountUpdate.fundNewAccount(deployerAccount);
zkAppInstance.deploy({ zkappKey: zkAppPrivatekey });
zkAppInstance.init(zkAppPrivatekey);
});
await txn.prove();
txn.sign([zkAppPrivatekey]);
await txn.send();
}
describe('CreditScoreOracle', () => {
let deployerAccount: PrivateKey,
zkAppAddress: PublicKey,
zkAppPrivateKey: PrivateKey;
beforeAll(async () => {
await isReady;
if (proofsEnabled) OracleExample.compile();
});
beforeEach(async () => {
deployerAccount = createLocalBlockchain();
zkAppPrivateKey = PrivateKey.random();
zkAppAddress = zkAppPrivateKey.toPublicKey();
});
afterAll(async () => {
// `shutdown()` internally calls `process.exit()` which will exit the running Jest process early.
// Specifying a timeout of 0 is a workaround to defer `shutdown()` until Jest is done running all tests.
// This should be fixed with https://github.com/MinaProtocol/mina/issues/10943
setTimeout(shutdown, 0);
});
it('generates and deploys the `CreditScoreOracle` smart contract', async () => {
const zkAppInstance = new OracleExample(zkAppAddress);
await localDeploy(zkAppInstance, zkAppPrivateKey, deployerAccount);
const oraclePublicKey = zkAppInstance.oraclePublicKey.get();
expect(oraclePublicKey).toEqual(PublicKey.fromBase58(ORACLE_PUBLIC_KEY));
});
describe('actual API requests', () => {
it('emits an `id` event containing the users id if their credit score is above 700 and the provided signature is valid', async () => {
const zkAppInstance = new OracleExample(zkAppAddress);
await localDeploy(zkAppInstance, zkAppPrivateKey, deployerAccount);
const response = await fetch(
'https://mina-credit-score-signer-pe3eh.ondigitalocean.app/user/1'
);
const data = await response.json();
const id = Field(data.data.id);
const creditScore = Field(data.data.creditScore);
const signature = Signature.fromJSON(data.signature);
const txn = await Mina.transaction(deployerAccount, () => {
zkAppInstance.verify(
id,
creditScore,
signature ?? fail('something is wrong with the signature')
);
});
await txn.prove();
await txn.send();
const events = await zkAppInstance.fetchEvents();
const verifiedEventValue = events[0].event.toFields(null)[0];
expect(verifiedEventValue).toEqual(id);
});
it('throws an error if the credit score is below 700 even if the provided signature is valid', async () => {
const zkAppInstance = new OracleExample(zkAppAddress);
await localDeploy(zkAppInstance, zkAppPrivateKey, deployerAccount);
const response = await fetch(
'https://mina-credit-score-signer-pe3eh.ondigitalocean.app/user/2'
);
const data = await response.json();
const id = Field(data.data.id);
const creditScore = Field(data.data.creditScore);
const signature = Signature.fromJSON(data.signature);
expect(async () => {
await Mina.transaction(deployerAccount, () => {
zkAppInstance.verify(
id,
creditScore,
signature ?? fail('something is wrong with the signature')
);
});
}).rejects;
});
});
describe('hardcoded values', () => {
it('emits an `id` event containing the users id if their credit score is above 700 and the provided signature is valid', async () => {
const zkAppInstance = new OracleExample(zkAppAddress);
await localDeploy(zkAppInstance, zkAppPrivateKey, deployerAccount);
const id = Field(1);
const creditScore = Field(787);
const signature = Signature.fromJSON({
r:
'13209474117923890467777795933147746532722569254037337512677934549675287266861',
s:
'12079365427851031707052269572324263778234360478121821973603368912000793139475',
});
const txn = await Mina.transaction(deployerAccount, () => {
zkAppInstance.verify(
id,
creditScore,
signature ?? fail('something is wrong with the signature')
);
});
await txn.prove();
await txn.send();
const events = await zkAppInstance.fetchEvents();
const verifiedEventValue = events[0].event.toFields(null)[0];
expect(verifiedEventValue).toEqual(id);
});
it('throws an error if the credit score is below 700 even if the provided signature is valid', async () => {
const zkAppInstance = new OracleExample(zkAppAddress);
await localDeploy(zkAppInstance, zkAppPrivateKey, deployerAccount);
const id = Field(2);
const creditScore = Field(536);
const signature = Signature.fromJSON({
r:
'25163915754510418213153704426580201164374923273432613331381672085201550827220',
s:
'20455871399885835832436646442230538178588318835839502912889034210314761124870',
});
expect(async () => {
await Mina.transaction(deployerAccount, () => {
zkAppInstance.verify(
id,
creditScore,
signature ?? fail('something is wrong with the signature')
);
});
}).rejects;
});
it('throws an error if the credit score is above 700 and the provided signature is invalid', async () => {
const zkAppInstance = new OracleExample(zkAppAddress);
await localDeploy(zkAppInstance, zkAppPrivateKey, deployerAccount);
const id = Field(1);
const creditScore = Field(787);
const signature = Signature.fromJSON({
r:
'26545513748775911233424851469484096799413741017006352456100547880447752952428',
s:
'7381406986124079327199694038222605261248869991738054485116460354242251864564',
});
expect(async () => {
await Mina.transaction(deployerAccount, () => {
zkAppInstance.verify(
id,
creditScore,
signature ?? fail('something is wrong with the signature')
);
});
}).rejects;
});
});
});
Now save it and run npm run test
.
You might have to try it twice in order for all the tests to pass because we're calling our live API directly. Note that writing a test that calls an API is generally not a best practice, but it’s convenient for the sake of this tutorial. You can also mock your HTTP requests.
Congratulations! You have just built a simple oracle using SnarkyJS and the Mina Protocol. You can find the complete code for this example here.
Checkout Tutorial 8 to learn how to launch and use custom tokens.