Tutorial: Implementing CCIP Read with the Notion API
If you own a domain, Starknet ID allows you to create as many subdomains as you want. You can even create a smartcontract which manages your subdomains (it is called a resolver contract). This tutorial shows you how to connect such a smartcontract to off chain data. In this example we do it with Notion so you can create free subdomains like iris.notion.stark in a notion table without having to send any transaction. To know more about how it works, check out offchain resolving (opens in a new tab)
Initial Setup
First, we need to prepare our data source and domain:
- Create a Public Page on Notion: Begin by setting up a public page in Notion. For example, here's a sample page (opens in a new tab) I've prepared. This page includes a table with two columns:
domain
andaddress
. This structure allows us to map domain names to specific addresses. - Capture Your Database ID: After setting up your table, note the
database_id
; it's crucial for our API to interact with this Notion page later on. - Integrate with Notion: Create an integration on Notion to enable our resolver to query the Notion database programmatically. You can find the steps to do this in the Notion Developers Guide (opens in a new tab).
- Domain Registration: Purchase the
notion.stark
domain through Starknet ID (opens in a new tab). This domain will serve as the root for any subdomains we wish to resolve using our system.
The objective here is to directly add domains and addresses into our Notion page and use them onchain. For instance, enabling transactions to test.notion.stark
without requiring onchain confirmation of domain ownership simplifies managing and updating domain records.
Resolver Contract
At the heart of our system is the resolver contract. It will implement a resolve
function, invoked by the Starknet ID
naming contract to map a domain to its corresponding address onchain.
Setting Up Your Cairo Project
Begin by creating a new package using Scarb:
scarb new resolver_contract
Our contract will be pretty simple, it will only have one function : resolve
#[starknet::interface]
trait IResolver<TContractState> {
fn resolve(
self: @TContractState, domain: Span<felt252>, field: felt252, hint: Span<felt252>
) -> felt252;
}
The resolve
Function
The resolve function will handle two scenarios when attempting to resolve a domain:
- No Hints Provided: When the
resolve
function is called without any hints, the contract will trigger a panic, returning an error that signals the need for offchain resolving. This error is an array of short strings. The first element should always be the stringoffchain_resolving
followed by the domain you received in the resolve function as a Span, and finally the URLs of your apis.
For example, an error message for iris.notion.stark
and two URLs would look like this:
["offchain_resolving", 1, 999902, 2, "https://api.ccip-demo.starknet.", "id/resolve?domain=", 2, "https://api2.ccip-demo.starknet", ".id/resolve?domain="]
- Hints Provided: In scenarios where hints are provided, the contract will expect four hints: the address the domain resolves to, a signature (
r
ands
), and a max validity timestamp. The contract will then perform the following checks:- Timestamp Verification: Ensures the current block timestamp is less than the provided max validity timestamp, ensuring the data is not outdated
- Message Hash Construction: Constructs a message hash using the domain, field, max validity timestamp, and the resolved address. This hash serves as the basis for signature verification.
- Signature Verification: Verifies the provided signature against the constructed message hash and a public key stored in the contract. This public key is added to the contract at the time of deployment.
Code Implementation
Here's a more detailed implementation of the resolve
function:
fn resolve(
self: @TContractState, domain: Span<felt252>, field: felt252, hint: Span<felt252>
) -> felt252 {
if hint.len() != 4 {
panic(self.get_error_array(array!['offchain_resolving']));
}
// Ensure the timestamp is valid and not expired
let max_validity = *hint.at(3);
assert(get_block_timestamp() < max_validity.try_into().unwrap(), 'Signature expired');
// Hash the domain and construct the message hash for signature verification
let hashed_domain = self.hash_domain(domain);
let message_hash: felt252 = hash::LegacyHash::hash(
hash::LegacyHash::hash(
hash::LegacyHash::hash(
hash::LegacyHash::hash('ccip_demo resolving', max_validity), hashed_domain
),
field
),
*hint.at(0)
);
// Verify the signature with the provided public key
let public_key = self.public_key.read();
let is_valid = check_ecdsa_signature(
message_hash, public_key, *hint.at(1), *hint.at(2)
);
assert(is_valid, 'Invalid signature');
// Return the address the domain resolves to
return *hint.at(0);
}
URL Management
Within our contract we will add methods to management our api URIs. You can define different functions to add, remove and retrieve URIs.
fn get_uris(self: @TContractState) -> Array<felt252>;
fn add_uri(ref self: TContractState, new_uri: Span<felt252>);
fn remove_uri(ref self: TContractState, index: felt252);
It is crucial to emit an event of type StarknetIDOffChainResolverUpdate
whenever a URI is added or removed. Starknet ID uses this event to discover your resolver and track any changes associated with it. This allows Starknet ID to resolver offchain resolver in its api through the endpoint domain_to_addr
(opens in a new tab).
Here is the structure of the event:
#[derive(Drop, starknet::Event)]
struct StarknetIDOffChainResolverUpdate {
uri_added: Span<felt252>,
uri_removed: Span<felt252>,
}
Please note that the Starknet ID indexer fetches new StarknetIDOffChainResolverUpdate
events at an interval of every 10 minutes.
🔗 For a comprehensive overview of the contract's code, visit the repository: lfglabs-dev/resolver_ccip (opens in a new tab)
Building the Offchain Resolver API with Rust
Setting Up Your Rust Project
Initialize a new Rust project if you haven't already:
cargo new offchain_resolver_api
Add the necessary dependencies to your Cargo.toml
file:
[dependencies]
axum = "0.5"
reqwest = "0.11"
chrono = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
starknet = "0.1.0"
lazy_static = "1.4"
Define Your Application State and Models
AppState Configuration: Create structures to hold your application's configuration, including the Notion API key and database ID. See the config.toml
template file (opens in a new tab) for reference.
// Inside src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct AppState {
pub conf: AppConfig,
}
#[derive(Clone)]
pub struct AppConfig {
pub notion: NotionConfig,
pub starknet: StarkNetConfig,
}
#[derive(Clone)]
pub struct NotionConfig {
pub secret: String,
pub database_id: String,
}
#[derive(Clone)]
pub struct StarkNetConfig {
pub private_key: String,
}
ResolveQuery: Represents the incoming query parameters for your API.
// Continue in src/models.rs
#[derive(Deserialize)]
pub struct ResolveQuery {
pub domain: String,
}
Implementing the Resolve Endpoint
This endpoint's function is to take a subdomain (e.g., test.notion.stark
) as input, query the Notion database for corresponding entries, and return the resolved address along with a signature and a max validity timestamp.
Querying the Notion Database: First, construct a request to the Notion API, targeting the database you created earlier where your domain data is stored. This request filters entries by the domain name provided in the query to find the matching entry.
let url = format!("https://api.notion.com/v1/databases/{}/query", state.conf.notion.database_id);
let client = reqwest::Client::new();
let mut headers = HeaderMap::new();
headers.insert("Notion-Version", HeaderValue::from_static("2022-06-28"));
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let token = format!("Bearer {}", state.conf.notion.secret.clone());
headers.insert(AUTHORIZATION, HeaderValue::from_str(&token).unwrap());
// Create the JSON payload
let payload =
json!({
"filter": {
"property": "Domain",
"title": {
"equals": query.domain
}
}
});
match client.post(&url).headers(headers).json(&payload).send().await {
Ok(response) =>
match response.json::<ApiResponse>().await {
// Extract the address from the response
}
}
Upon receiving a response from the Notion API, the endpoint checks for the existence of the queried domain within the returned data. If found, it extracts the address associated with this domain.
Signing the Response: With the address in hand, the endpoint prepares a message hash using pedersen_hash
including a predefined string ( ccip_demo resolving
), the maximum validity in seconds of the response, the hashed subdomain, the field (here we use starknet
) and the address obtained from Notion.
let hash = pedersen_hash(
&pedersen_hash(
&pedersen_hash(
&pedersen_hash(
&HASH_NAME,
&FieldElement::from_dec_str(max_validity_seconds.to_string().as_str()).unwrap()
),
&hashed_domain
),
&FIELD_STARKNET
),
&FieldElement::from_hex_be(address).unwrap()
);
This message is then signed using the server's private key.
Finally we return the address, the signature (r
and s
) and the max_validity
timestamp. The response is sent back to the caller, who can then use it to complete the offchain resolving process.
match ecdsa_sign(&state.conf.starknet.private_key, &hash) {
Ok(signature) =>
(
StatusCode::OK,
Json(
json!({"address": address, "r": signature.r, "s": signature.s, "max_validity": max_validity_seconds})
),
).into_response(),
Err(e) => get_error(format!("Error while generating signature: {}", e)),
}
🔗 For the complete code and further details, visit the repository: lfglabs-dev/api.ccip-demo.starknet.id (opens in a new tab)
Deploy & Set Up Your Domain & Resolver
Generating your private and public key
Begin by generating a private key and a corresponding public key. You can use the following Python script:
#!/usr/bin/env python3
from starkware.crypto.signature.signature import private_to_stark_key, get_random_private_key
priv_key = get_random_private_key()
print("priv_key:", hex(priv_key))
pub_key = private_to_stark_key(priv_key)
print("pub_key:", hex(pub_key))
Api deployment
Deploy your API using the generated private key along with your Notion credentials (database ID and secret).
Resolver Contract Deployment
Deploy your resolver contract with the public key of your API endpoint and add your API url to your contract. Ensure your API's endpoint is structured to append the domain name directly to the query, such as https://sepolia.api.ccip-demo.starknet.id/resolve?domain=
. This setup enables the getAddressFromStarkName
function from starknetid.js
, used later in the frontend, to function seamlessly.
Domain Resolver Configuration
With your resolver contract deployed, you can now set it as the resolver for your domain name. This configuration can be done directly through Voyager (opens in a new tab) by specifying the set_domain_to_resolver
function with the appropriate raw calldata:
- Domain as an Array: Include your domain's length and its encoded value. StarkNet ID utilizes a specific encoding algorithm. To encode your domain, you can use Starknet ID encoding playground (opens in a new tab). For example, if encoding
notion.stark
results in1059716045
, you would send1
as the array length and1059716045
as the content. - Resolver Contract Address: Add the address of the deployed resolver contract.
Example calldata input:
1, 1059716045, 0x0153be68cf8fc71138610811dd2b4fa481eb99f3eedcb3fce7369569055be275
From a contract perspective we’re all set. If you try to call domain_to_address
of the naming contract on a subdomain test.notion.stark
it will fail with an error starting by offchain_resolving
error LibraryError: RPC: starknet_call with params {"request":{"contract_address":"0x0707f09bc576bd7cfee59694846291047e965f4184fe13dac62c56759b3b6fa7",
"entry_point_selector":"0x2e269d930f6d7ab92b15ce8ff9f5e63709391617e3465fff79ba6baf278ce60","calldata":["0x2","0xf41de","0x3f29fbcd","0x0"]},"block_id":"pending"}
40: Contract error: {"revert_error":"Error in the called contract (0x0707f09bc576bd7cfee59694846291047e965f4184fe13dac62c56759b3b6fa7):\nError at pc=0:16478:
\nGot an exception while executing a hint: Execution failed. Failure reason: (0x6f6666636861696e5f7265736f6c76696e67 ('offchain_resolving'),
0x1, 0xf41de, 0x2, 0x687474703a2f2f302e302e302e303a383039302f7265736f6c76653f646f6d ('http://0.0.0.0:8090/resolve?dom'), 0x61696e3d ('ain='),
0x2, 0x68747470733a2f2f6170692e636369702d64656d6f2e737461726b6e65742e ('https://api.ccip-demo.starknet.'), 0x69642f7265736f6c76653f646f6d61696e3d ('id/resolve?domain=')).\n"}
Setting Up the Frontend
Integrating offchain resolving into your frontend application is streamlined and efficient, thanks to the getAddressFromStarkName
function provided in starknetid.js
. This function seamlessly manages the offchain resolving process, ensuring a smooth user experience.
Reverse resolving is also supported with the getStarkName
function. Note that for it to work you first need to call set_address_to_domain
of the naming contract (opens in a new tab).
🔗 You can refer to the starknetid.js documentation (opens in a new tab)
Testing the Demo
🔗 To see the offchain resolver in action, visit the live demo (opens in a new tab)