Local Development & Testing

Local Development & Testing

Use local-tableland to quickly interact with Tableland in a local environment

Developers can leverage a local-only instance of the Tableland network that spins up a local hardhat node in the process. This allows for quickly iterating while developing (create, write, and read local tables) in addition to integrating local-tableland into your testing flow.

Setup

Install

You can install the local-tableland via npm:

npm install --save-dev @tableland/local

Or yarn:

yarn add @tableland/local

Usage

Simply run the following command from your project:

npx local-tableland

Under the hood, a few things will then happen:

  1. A local hardhat node is spun up (at http://localhost:8545), along with 20 test accounts (public-private keypairs). Note the chainId is 31337 for the local blockchain.
  2. The TablelandTables.sol contract is deployed on the local network, allowing for smart contract calls for table creates and writes.
  3. A local Tableland network node is spun up, which will process the events from the TablelandTables.sol contract, materializes the SQL instructions, and allows for read queries locally (via REST API at http://localhost:8080).

SDK

If you’re using the SDK, connecting to the local Tableland network simply requires the chain option to be set to local-tableland upon connecting to the network.

import { connect } from "@tableland/sdk";

const tableland = connect({ chain: "local-tableland" })

Thus, creating, writing to, and reading from tables will all happen locally, without needing to connect to a testnet or mainnet chain. See the JavaScript SDK documentation for more SDK usage details.

Smart Contracts

Since running npx local-tableland starts a hardhat node, developers can bypass the usage of npx hardhat node. For example, if you’re following hardhat’s deployment instructions, the following steps would be used to deploy your smart contracts:

npx local-tableland
# In a separate window, deploy the contracts locally
npx hardhat run --network localhost scripts/deploy.js

REST API

Once the local node is running, tables can be accessed just as they are with the Tableland testnet and mainnet network. But, instead of the Tableland-hosted gateway, the local Tableland network is accessible at http://localhost:8080. The network will always create a healthbot table as the first table, so you can easily test out this fucntionality from the get go:

curl http://localhost:8080/chain/31337/tables/1
# Or, simply open your browser and paste the address

# This will provide the following response
{
  "name": "healthbot_31337_1",
  "external_url": "http://localhost:8080/chain/31337/tables/1",
  "image": "https://render.tableland.xyz/31337/1",
  "attributes": [
    {
      "display_type": "date",
      "trait_type": "created",
      "value": 1668659178
    }
  ]
}

# Another example, using the `query` endpoint with URI encoding
curl http://localhost:8080/query?s=select%20counter%20from%20healthbot_31337_1

# Returns the table data
[
  {
    "counter": 1
  }
]

All of the Tableland APIs are available at this URL, so anything that you’d like to develop and test out locally is available on testnet / mainnet chains (and vice versa). Check out the REST API docs for more details! And if you’re unfamiliar with the encoding used, see the docs on URI Encoding.

Wallet Imports & Nonce Issues

When testing locally, you may want to import an account created during the hardhat process. These are publicly known accounts such that exposing the private key is not an issue as they’re meant for testing purposes. Do not use the first account (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) since this can lead to nonce issues. This account is the one “owned” by the validator upon creating the first healthbot table on the network, so it should be reserved for that and that alone.

[Registry] Accounts
[Registry] ========
[Registry]
[Registry] WARNING: These accounts, and their private keys, are publicly known.
[Registry] Any funds sent to them on Mainnet or any other live network WILL BE LOST.
[Registry]
[Registry] Account #0: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
[Registry] Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
[Registry]
[Registry] Account #1: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
[Registry] Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

For example, with the second account created, import the private key value into your wallet, which will allow you to use the 10000 test ETH provided:

  • Public key: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
  • Private key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

If, for some reason, “nonce” errors do occur, there are a few resolution paths:

  1. Make sure that rpcRelay is set to false for any SDK write methods.
  2. If you’re using MetaMask, you can try to reset the transaction history by going to SettingsAdvancedReset Account.
    1. Resetting your account will clear your transaction history. This will not change the balances in your accounts or require you to re-enter your Secret Recovery Phrase.
    2. Again, this should only be done if you’re using some test account provided by hardhat.
  3. Enable a “custom nonce” to manually set the nonce upon sending a transaction. Go to SettingsAdvancedCustomize transaction nonce.

Logging & Startup

Starting local-tableland will provide logging for both the local hardhat blockchain and the local Tableland network. Note there are optional flags for silencing the logs or making them verbose:

npx local-tableland --silent
# Or
npx local-tableland --verbose

If no flags are passed, the default logging will resemble the following:

[Registry] Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/
[Registry]
[Registry]
[Registry] Accounts
[Registry] ========
---
[Registry] eth_sendTransaction
[Registry] Contract deployment: TablelandTables
[Registry] Contract address:    0x5fbdb2315678afecb367f032d93f642f64180aa3
---
[Validator] 8:26PM INF state hash block_number=6 chain_id=31337 component=eventprocessor elapsed_time=5 goversion=go1.19.1 hash=edde6a99dd8d30efb48f8a60de13f53b84a6c6f1 severity=Info version=7144099
[Validator]
[Validator] 8:26PM DBG executing create-table event chain_id=31337 component=txnscope goversion=go1.19.1 owner=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 severity=Debug statement="create table healthbot_31337 (counter integer);" token_id=1 txn_hash=0x8459cc42f646c40233966c3ff366f16a4b6678045f23536c3a1839e459a8cd05 version=7144099

First, you’ll notice that each line is prefixed with either [Registry] or [Validator], plus the local time at which the log occurred. The Registry logs are standard hardhat node interactions. For example, when the TablelandTables contract is deployed, it shows up on a line with [Registry] and includes information passed by hardhat; the same information available with npx hardhat node.

For lines prefixed with Validator, these are corresponding to the local Tableland validator node. Thus, a line like [Validator] 8:26PM DBG executing create-table event ... is an action executed by the local Tableland network node. In this case, it’s relating to a create table statement that was made, but for any table creation, mutation, or read, there will be a corresponding log with the [Validator] prefix.

Additional Context

Any time you start local-tableland, you’ll notice the same series of events that occur after the Tableland is running! message:

******  Tableland is running!  ******
             _________
         ___/         \
        /              \
       /                \
______/                  \______

[Validator] 8:26PM INF state hash block_number=6 chain_id=31337 component=eventprocessor elapsed_time=5 goversion=go1.19.1 hash=edde6a99dd8d30efb48f8a60de13f53b84a6c6f1 severity=Info version=7144099
[Validator]
[Validator] 8:26PM DBG executing create-table event chain_id=31337 component=txnscope goversion=go1.19.1 owner=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 severity=Debug statement="create table healthbot_31337 (counter integer);" token_id=1 txn_hash=0x8459cc42f646c40233966c3ff366f16a4b6678045f23536c3a1839e459a8cd05 version=7144099
[Validator]
[Validator] 8:26PM DBG call ValidateCreateTable goversion=go1.19.1 query="create table healthbot_31337 (counter integer);" severity=Debug version=7144099
[Validator]
[Validator] 8:26PM DBG saved receipts chain_id=31337 component=eventprocessor goversion=go1.19.1 height=6 receipts=1 severity=Debug version=7144099
[Validator]
[Validator] 8:26PM DBG executing run-sql event chain_id=31337 component=txnscope goversion=go1.19.1 severity=Debug statement="insert into healthbot_31337_1 values (1);" txn_hash=0x0fccfecb3b7a692d2cb4fbb8a79c069a63d0351e1fa32dc00a3e8011b0880835 version=7144099
[Validator]
[Validator] 8:26PM DBG call ValidateMutatingQuery goversion=go1.19.1 query="insert into healthbot_31337_1 values (1);" severity=Debug version=7144099
[Validator]
[Validator] 8:26PM DBG saved receipts chain_id=31337 component=eventprocessor goversion=go1.19.1 height=7 receipts=1 severity=Debug version=7144099
[Validator]

The Validator events preceding Tableland is running! are related to the node starting up:

  • Database (SQLite) is instantiated.
  • The node “owner” account is set to the 0th wallet provided by hardhat (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266).
  • Various daemons and event trackers are set up.

Then, the first table on the network — the healthbot table, consisting of a incrementing counter column — is created. Every testnet and mainnet chain that Tableland launches on actually follows the general logic in which this table is created as the first table on the new network. The following describes the steps that happen from this point onward:

  • With executing create-table event ..., you’ll notice this event comes along with the actual statement (create table healthbot_31337 (counter integer)) the table’s owner, and the transaction hash at which it was created (txn_hash).
  • The following line validates the create statement is valid (ValidateCreateTable) and saves the receipt of this create table event / transaction occurring.
  • From there, a run-sql event is processed due to the mutating query that is being sent with insert into healthbot_31337_1 values (1) (which also comes with a txn_hash).
  • The mutating query is validated (ValidateMutatingQuery) to ensure SQL compliance as well as the send of the query (who is the owner, in this case) has the process access.
  • Lastly, the table mutation is made, and the associated receipt is saved by the node; from there, the local Tableland network is ready to process new tables and queries!

Testing

It’s also useful to leverage local-tableland in test suites, such as with the mocha testing framework. It’s important to test if the table creation, writes, and reads are all behaving as expected. To accomplish this:

  1. Import the named LocalTableland export from @tableland/local.
  2. Create an instance of LocalTableland — here’s it’s stored as an lt variable.
  3. Call the start() method to launch the Tableland network; this launches all of the required local nodes (hardhat + Tableland) to allow for tests to be written for tables.
  4. Call the stop() method to stop the network; this should only happen once the tests finish.

A best practice is to only start a single network to run all of your tests against. In other words, do not create a LocalTableland instance for each test but only at the start. The following provides a basic skeleton of what this should look like with global setup fixtures:

// test/setup.js 

import { after, before } from "mocha";
import { LocalTableland } from "@tableland/local";

const localTbl = new LocalTableland({ silent: true }); 
// Set `silent` or `verbose` with boolean values
// You'll likely want to silence logging during tests

const lt = new LocalTableland({ silent: true });

before(async function () {
  this.timeout(15000);
  lt.start();
  await lt.isReady();
});

after(async function () {
  await lt.shutdown();
});

Within setup.js, the local Tableland network starts and stop with the global fixgures; these will run before and after the tests elsewhere are executed. For Tableland and crypto-specific tests, you’ll want to import the @tableland/sdk package and ethers:

// test/index.test.js

// Use the `assert` library for more testing features
import { strictEqual, deepStrictEqual } from "assert";
// Standard for `mocha` testing
import { describe, test } from "mocha";
// `getAccounts` is a useful utility method to get accounts on the local network
import { getAccounts } from "@tableland/local";
// Lastly, import the `connect` method for connecting to local Tableland
import { connect } from "@tableland/sdk";

describe("index", function () {
  const chain = "local-tableland";
  // Note that we're using the second account here
	// The first account is for the validator and can cause nonce issues if used
  const [, signer] = getAccounts();
	// Connect to the local Tableland node with the specified `signer`
  const sdk = connect({ signer, chain });

  test("create", async function () {
		// Create a table named `table` with a single `column` of type `integer`
    const { name } = await sdk.create("counter integer", { prefix: "table" });
		// The first table on the network is auto-created as `healthbot`
		// Thus, this should be the second table (name is equivalient to `prefix_chainId_tableId`)
    strictEqual(name, "table_31337_2");
  });

  test("insert", async function () {
    const { hash } = await sdk.write("insert into table_31337_2 values (1);");
    const txnReceipt = await sdk.receipt(hash);
    strictEqual(txnReceipt?.chainId, 31337);
  });

  test("update", async function () {
    const { hash } = await sdk.write("update table_31337_2 set counter=2;");
    const txnReceipt = await sdk.receipt(hash);
    strictEqual(txnReceipt?.chainId, 31337);
  });

  test("query", async function () {
    const { rows } = await sdk.read("select * from table_31337_2;");
    deepStrictEqual(rows[0], [2]);
  });
});

describe("index", function () {
  let signer: Signer;
  this.beforeAll(async function () {
		// Recall that `local-tableland` leverages `hardhat` and will create 20 test accounts
		// Get the first account, which is the one that created the `healthbot` table
    const [account] = getAccounts();
    const privateKey = account.privateKey.slice(2);
    const wallet = new Wallet(privateKey);
    const provider = new providers.JsonRpcProvider(); // Defaults to localhost
		// Set the `signer` who will be making the ensuing SQL calls
    signer = wallet.connect(provider);
  });
  test("update", async function () {
    this.timeout(5000);
		// Connect to the local Tableland node with the specified `signer`
    const sdk = connect({ signer, chain: "local-tableland" });
		// Mutate the `healthbot` table, since the account specified has the access rights to do so
    const { hash } = await sdk.write("update healthbot_31337_1 set counter=1;");
		// Get the transaction receipt of the action
    const txnReceipt = await sdk.receipt(hash);
		// Check if the transaction is correct, such as verifying the `chainId` in the response is the local `31377` ID
    assert.strictEqual(txnReceipt?.chainId, 31337);
  });

  test("query", async function () {
    this.timeout(5000);
		// Connect to the local Tableland node with the specified `signer`
    const sdk = connect({ signer, chain: "local-tableland" });
		// Read from the `healthbot` table, which had a value of `1` set as the `counter` column
    const { rows } = await sdk.read("select * from healthbot_31337_1;");
		// Validate the `counter` is now `1` -- the desctuctured `rows` from the response is: `[ [ 1 ] ]`
    assert.deepStrictEqual(rows[0], [1]);
  });
});



Next Steps

As you get started with testing, it may be helpful to check out the js-template repo, which comes packed with useful Tableland-specific features, including the example tests noted above.