Reporting & Analytics
Introduction
Cirrus is a STRATO component that allows for rapid retrieval of blockchain contract data. Cirrus provides secure, read-only access to indexed data in a tabular format according to a contract's name. It allows for users to make powerful requests to the API by using SQL-like queries that will be familiar to business and technical users alike.
Cirrus is what enables easy access to information like contract state data, Contract History/Audit Log, and Solidity Event Logs.
Support for EVM contracts in Cirrus is being deprecated
Starting in STRATO v7.5, the indexing of contracts which use the Ethereum Virtual Machine (EVM) is deprecated. By default STRATO will not index these contracts. Long term support for this feature should be expected and should be expected to be completely removed in future versions.
Developers have the option to enable EVM indexing on the expense of higher memory usage and slower overall performance. To enable this feature, use the flag:
--indexEVM=true
when starting STRATO.
Visit the Pluggable VM page for more information on the difference between the two VMs.
Data Indexing
All state variables from a contract are recorded as a separate column within a contract's table. A contract's name is its corresponding Cirrus table name. Along with state data, Cirrus records contract and transaction metadata for each update. Every unique contract account (address and chain ID pair) will be a separate entry in the table.
Inherited Columns
All contracts indexed in Cirrus have the following fields:
address
- The address of this contract.
chainId
- The chain ID of the chain that this contract is on.
- Empty string if this contract is on the Main Chain.
record_id
- The
address
appended by the chain ID with a colon (":"). - If the chain ID is empty, than this is just the address of this contract.
- Used to provide a unique ID for each row in the contract index table.
- The
block_hash
- The blockhash of the block where this contract was last updated.
block_timestamp
- The block timestamp of the block where this contract was last updated.
- Format:
YYYY-MM-DD HH:MM:SS UTC
block_number
- The block number of the block where this contract was last updated.
transaction_hash
- The hash of the transaction in which this contract was last updated.
transaction_sender
- The STRATO address of the account that sent the transaction in which this contract was last updated.
transaction_function_name is deprecated and removed
Contract tables in STRATO v7.2 and older contained the column "transaction_function_name" which was removed in v7.3
All instances of the same contract will exist within the same Cirrus table. This makes it easy to search across all occurrences of a contract to allow for rich data analytics and retrieval.
Contracts deployed to private chains are also indexed in Cirrus within the corresponding contract table.
Data Types
Values of variables that are of complex data types are not represented in Cirrus since they are not "flat" data types. These include arrays, structs, mappings and enums. Below is a table of data types in Solidity and how the corresponding type is represented in Cirrus:
Solidity Type | Cirrus Type/Format |
---|---|
string |
string |
uint/int |
integer |
bytes |
string |
bool |
boolean |
enum |
integer - Index of enum value |
address |
string of address |
account(address) |
string of address |
account(address, chainId) |
string of <address>:<chainId> |
Contract(address) |
string of contract <address> |
Contract(account(address, chainId)) |
string of contract <address>:<chainId> * |
new Contract(args) |
string of contract <address> |
array |
Not Indexed |
struct |
Not Indexed |
mapping(<T> => <V>) |
"0000000000000000000000000000000000000000" |
Note
Any chain ID in an account
-type that resolves to the Main Chain will be recorded as <address>:main
.
- Any Contract type that references a non-existent contract will be recorded as NULL.
As an example, let's say there is a smart contract as shown below:
contract SimpleStorage {
enum Decision { YES, NO, MAYBE }
string message;
uint number;
bool isEven;
account myAccount;
int[] myArr;
Decision myDecision;
constructor(string _message, uint _number, address _address, uint _chainId, int[] _myArr, Decision _decision) {
message = _message;
number = _number;
isEven = _number % 2 == 0;
myAccount = account(_address, _chainId);
myArr = _myArr;
myDecision = _decision;
}
}
If two instances of this contract were created with different values, the corresponding SimpleStorage
table would contain the following data (block and transaction data not shown.)
SimpleStorage
...defaultColumns | message (string) | number (integer) | isEven (boolean) | myAccount (string) | myDecision (integer) |
---|---|---|---|---|---|
...values | "Hello World" | 4 | true | "...0000123 : ...0000456" | 0 |
...values | "Good Morning" | 37 | false | "...0000abc : ...0000def" | 1 |
The results from the query are returned from the API as an array of JSON objects. Each element of the array is a row from the query. Each column returned from the query is a property of the object.
Returned Data
[
{
"address": "<contract-address>",
"chainId": "<chain-id>",
"record_id": "<contract-address>:<chain-id>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "Hello World",
"number": 4,
"isEven": true,
"myAccount": "0000000000000000000000000000000000000123:0000000000000000000000000000000000000000000000000000000000000456",
"myDecision": 0
},
]
Table Naming
A contract's table name as stored in Cirrus is prepended by the organization name of the user who created the contract, and additionally the application contract name that created this contract instance.
See the X.509 & User Identity Documentation for more details.
Querying Cirrus
STRATO uses the PostgREST v9.0 API to allow users to interface with the Cirrus database. The PostgREST API allows users to write SQL-like queries directly as query parameters in a STRATO API call. Because there are many powerful functions of PostgREST, this guide will only serve to show the most common and basic requests possible. When using the BlockApps Rest SDK, query parameters are given via the query
property of the options
object. Each key-value of the query
object is the corresponding key-value of the Cirrus API request.
The Cirrus endpoint on STRATO is:
GET https://<strato_address>/cirrus/search/<contract-name>
Select Columns
You may select a subset of rows to retrieve from Cirrus. By default all rows are returned.
This is done by using the select
query parameter. The columns to be returned are a comma separated string.
Example
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/SimpleStorage?select=address,message,isEven"
const query = {
select: 'address,message,isEven'
}
const contract = {
name: "SimpleStorage"
}
const newOptions = {
...options,
query
}
const rows = await rest.search(stratoUser, contract, newOptions)
Response
[
{
"address": "<contract-address>",
"message": "Hello World",
"isEven": "true"
},
{
"address": "<contract-address>",
"message": "Good Morning",
"isEven": "false"
}
]
Filter Rows by Values
Filter the results by using a column's name and a condition that needs to be met for it to be returned. A filter takes the format of:
?<column-name>=<operator>.<value>
An operator
is some type of logical qualifier in relation to the provided value
. PostgREST uses the following reserved keywords for operators:
eq
- equalcolumnValue == value
neq
- not equalcolumnValue != value
lt
- less thancolumnValue < value
lte
- less than or equalcolumnValue <= value
gt
- greater thancolumnValue > value
gte
- greater than or equalcolumnValue >= value
not
- negation!(condition)
- Used as a prefix to negate the proceeding operator.
like
- Case-sensitive text match. SQL
like
operator. - Use
*
as a wildcard to match zero or more characters. - Does not use regular expression pattern matching.
"blockapps" like "BlockApps" = false
- Case-sensitive text match. SQL
ilike
- Case-insensitive text match. SQL
ilike
operator. - Use
*
as a wildcard to match zero or more characters. - Does not use regular expression pattern matching.
"blockapps" ilike "BlockApps" = true
- Case-insensitive text match. SQL
See the PostgREST Filters for a complete list of supported filters.
Filters can also be used in conjunction with each other by using the or
and and
operators to form a clause.
and
- Returns true if all of the conditions within it are true.
condition1 && condition2
or
- Returns true if any of conditions within it are true.
condition1 || condition2
Each filter within an and/or clause must be separated by a comma, and the entire clause must be wrapped in parentheses. The separator between a column name and condition must now be a period (.
):
?<operator>=(<column-name>.<operator>.<value>,<column-name2>.<operator2>.<value2>)
Like other conditions, these conditions may also be prefixed by the not
operator to negate the entire statement.
Conjunction operators may contain other conjunctions:
?or=(and(<column-name>.<operator>.<value>,<column-name2>.<operator2>.<value2>),<column-name3>.<operator3>.<value3>)
// equivalent logical statement
(condition1 && condition2) || condition3
An and
condition on separate columns is equivalent to declaring separate filter conditions on each column:
?and=(message.eq.Hello,number.gt.10)
is equivalent to:
?message=eq.Hello&number=gt.10
Example
Return all rows where message
contains the string "Morning"
or number
is greater than 20
, and the contract is on the Main Chain (chain ID is the empty string).
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/SimpleStorage?or=(message.like.*Morning*,number.gt.20)&chainId=eq."
const query = {
or: '(message.like.*Morning*,number.gt.20)',
chainId: 'eq.'
}
const newOptions = {
...options,
query
}
const contract = {
name: "SimpleStorage"
}
const rows = await rest.search(stratoUser, contract, newOptions)
Returns
[
{
"address": "<contract-address>",
"chainId": "",
"record_id": "<contract-address>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "Good Morning",
"number": 37,
"isEven": false,
"myAccount": "0000000000000000000000000000000000000abc:0000000000000000000000000000000000000000000000000000000000000def",
"badArr": "0000000000000000000000000000000000000000"
},
]
Sort Rows
Sort the returned rows by column value.
?order=<column-name>.<ordering>
Multiple columns can be sorted on by specifying them as a comma-separated list. This allows rows with the same value of a column to fall back to the next sorting order.
?order=<column-name>.<ordering>,<column-name2>.<ordering2>
Ordering uses the following keywords:
asc
- default- Ascending order
- 0, 1, 2, 3...
desc
- Descending order
- 3, 2, 1, 0...
Example
Sort results by the number
column in descending order.
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/SimpleStorage?order=number.desc"
const query = {
order: 'number.desc',
}
const newOptions = {
...options,
query
}
const contract = {
name: "SimpleStorage"
}
const rows = await rest.search(stratoUser, contract, newOptions)
Returns
[
{
"address": "<contract-address>",
"chainId": "",
"record_id": "<contract-address>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "Good Morning",
"number": 37,
"isEven": false,
"myAccount": "0000000000000000000000000000000000000abc:0000000000000000000000000000000000000000000000000000000000000def",
"badArr": "0000000000000000000000000000000000000000"
},
{
"address": "<contract-address>",
"chainId": "",
"record_id": "<contract-address>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "Good Morning",
"number": 4,
"isEven": true,
"myAccount": "0000000000000000000000000000000000000123:0000000000000000000000000000000000000000000000000000000000000456",
"badArr": "0000000000000000000000000000000000000000"
},
]
Limit & Offset Results
If there are many instances of a single contract type, than it may be useful to only retrieve a subset of entries from the full table. This feature is complemented with offsetting, which allows the returned results to begin after a given offset.
Limit will restrict the results to contain a maximum of limit
entries.
Offset will start returned results from the entry at the given offset
(zero-indexed). If the offset is greater than or equal to the number of entries in the table, no results will be returned
Limit and offset use the same corresponding query parameters:
?limit=<limit>&offset=<offset>
Example
Retrieve a maximum of 50 entries, returned rows will begin from the 50th entry (first entry is the 0th entry).
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/SimpleStorage?limit=50&offset=50"
const query = {
limit: 50,
offset: 50
}
const newOptions = {
...options,
query
}
const contract = {
name: "SimpleStorage"
}
const rows = await rest.search(stratoUser, contract, newOptions)
Returns
[
{
"address": "<contract-address>",
"chainId": "",
"record_id": "<contract-address>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "Good Evening",
"number": 14,
"isEven": true,
"myAccount": "00000000000000000000000000000000001a2b3c:0000000000000000000000000000000000000000000000000000000000006c5e",
"badArr": "0000000000000000000000000000000000000000"
},
{
"address": "<contract-address>",
"chainId": "",
"record_id": "<contract-address>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "How do you do?",
"number": 55,
"isEven": false,
"myAccount": "0000000000000000000000000000000000e523ac:00000000000000000000000000000000000000000000000000000000000765",
"badArr": "0000000000000000000000000000000000000000"
},
{
"address": "<contract-address>",
"chainId": "",
"record_id": "<contract-address>",
"block_hash": "<block-hash>",
"block_timestamp": "<block-timestamp>",
"block_number": "<block-number>",
"transaction_hash": "<transaction-hash>",
"transaction_sender": "<transaction-sender-address>",
"message": "Good Afternoon",
"number": 12,
"isEven": true,
"myAccount": "0000000000000000000000000000000003ba144d:0000000000000000000000000000000000000000000000000000002b1f52cced",
"badArr": "0000000000000000000000000000000000000000"
},
// ... 47 more entries
]
Joining Multiple Tables
SQL allows for two or more tables to be joined together on common values from their columns to form a single table with the contents of each individual table together. In Cirrus, this can be done with contracts that contain address references to other contracts in a state variable. This allows for powerful data querying between both contract's index tables in a single query to Cirrus. This feature is useful when users need to isolate data about an object to a single contract, rather than including data that does not necessarily relate to what this contract is describing. This helps developers model their contracts in a relational model, following the "Normal Forms" commonly used in database design Database Normalization.
For security purposes, it is required that the type of the variable in a contract must be the name of the foreign contract. A state variable with the generic account
or address
type cannot be used in table joins.
Example
As an example, we have the following contracts Book
and Author
:
contract Author {
string first_name;
string last_name;
string birth_date;
constructor(string _first_name, string _last_name, string _birth_date) {
first_name = _first_name;
last_name = _last_name;
birth_date = _birth_date;
}
}
contract Book {
Author author;
string name;
string publish_date;
constructor(string _name, string _publish_date, address _author) {
name = _name;
publish_date = _publish_date;
author = Author(account(_author));
}
}
To perform a join query between the Book
and Author
contract, query the Book
cirrus table as normally, and in the select
query parameter, add the "Author
" contract name, followed by the columns in Author
that you wish to include in the response. Use the wildcard "*
" to select all of the columns of single table.
GET https://<strato_address>/cirrus/search/Book?select=name,publish_date,Author(first_name,last_name)
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/Book?select=name,publish_date,Author(first_name,last_name)"
const query = {
select: "name,publish_date,Author(first_name,last_name)",
}
const newOptions = {
...options,
query
}
const contract = {
name: "Book"
}
const rows = await rest.search(stratoUser, contract, newOptions)
Response
[
{
"name": "The Sea, the Sea",
"publish_date": "1978",
"Author": {
"first_name": "Iris",
"last_name": "Murdoch"
}
},
{
"name": "Never Let Me Go",
"publish_date": "2005",
"Author": {
"first_name": "Kazuo",
"last_name": "Ishiguro"
}
}
]
See the PostgREST documentation on Resource Embedding for further information.
Row Count
Easily query the number of entries in a table for analytics by selecting the count
of the returned rows from a query.
Use the following query parameters to get the count of rows:
?select=count
This query can be used in conjunction with other filters to select the count of rows that match that filter.
Example
Retrieve the number of entries in a table where number
is less than 25.
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/SimpleStorage?select=count&number=lt.25"
const query = {
select: 'count',
number: 'lt.25',
}
const newOptions = {
...options,
query
}
const contract = {
name: "SimpleStorage"
}
const rows = await rest.search(stratoUser, contract, newOptions)
Returns
The return result for a count query is an array with a single object with they key count
.
[
{
"count": 14,
}
]
PostgREST Reference
For a complete usage guide to the PostgREST v9.0 API, visit their documentation page.
Event Logs
Contracts written in SolidVM allow Cirrus to log Solidity Events with the exact data with which they were emitted. This is in great contrast to EVM, where events are not human readable and hard to access, due to the nature of how they are implemented. Recording events and their arguments in SolidVM allows them to be used as lightweight pseudo-contracts. They can be easily accessed from Cirrus and do not affect the state of the blockchain. They are useful for recording read-only data.
Info
Events are stored in Cirrus with the naming format of <ContractName>.<EventName>
.
As an example, below is a SolidVM contract that emits an event upon contract creation and then the event information is retrieved from Cirrus.
contract EmitEvent {
event MyEvent(string memo, uint timestamp, bool myBool);
constructor(string m, uint t, bool b) {
emit MyEvent(m, t, b);
}
}
To query for an event in Cirrus, make the following API call:
Request
curl -X GET \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
"https://<strato_address>/cirrus/search/EmitEvent.MyEvent"
[
{
"id": 1,
"address": "a1b2c3d4e5f67a1b2c3d4e5f67a1b2c3d4e5f67a",
"memo": "Hello World",
"timestamp": "12345",
"myBool": "True"
}
]
Info
Cirrus always stores Data from Solidity Events as strings, so remember to convert them to their proper types when working with event data through the API.