HollowDB itself is a SmartWeave contract. In particular, we use Warp Contracts which is like SmartWeave on steroids. We provide a utility tool to work with contracts, i.e. deploy a new one, evolve an existing one, or even create a boilerplate to write your own contracts!
To begin, clone the repo and install the packages:
# clone repo
git clone https://github.com/firstbatchxyz/hollowdb
# install packages
pnpm install
Our command-line tool can be called via pnpm contract
. You will see the message below if you type pnpm contract --help
:
pnpm contract <command>
Commands:
pnpm contract whoami Display information about your wallet
pnpm contract deploy Deploy a new contract
pnpm contract evolve Evolve an existing contract
pnpm contract create Create your own custom contract
pnpm contract build Build a contract
pnpm contract transfer Transfer keys & values from one contract to another
Options:
--help Show help [boolean]
--version Show version number [boolean]
-w, --wallet Path to Arweave wallet [string]
-n, --name Name of the contract [string]
-i, --init A specific initial state [string]
-t, --target Target network
[string] [choices: "main", "test"] [default: "main"]
-s, --sourceTxId Source transaction id [string]
-c, --contractTxId Contract transaction id [string]
As shown in the help message above, building, deploying, or evolving a contract is quite simple.
# build all contracts
pnpm contract build
# build a specific contract
pnpm contract build -n contract-name
# deploy to mainnet from a local source code
pnpm contract deploy -w ./secret/wallet.json -n contract-name
# deploy to mainnet from an existing contract source
pnpm contract deploy -w ./secret/wallet.json -s sourceTxId
# deploy to testnet from a local source code with a specific state
pnpm contract deploy -w ./secret/wallet.json -n contract-name -t test -i ./path/to/state.json
# evolve a contract on mainnet to a local source code
pnpm contract evolve -w ./secret/wallet.json -c contractTxId -n contract-name
# evolve a contract on mainnet to an existing contract source
pnpm contract evolve -w ./secret/wallet.json -c contractTxId -s sourceTxId
Thanks to the file structure we are using here, you do not need to worry about paths to your contracts or their initial states. When you provide a contract name with -n
option, the CLI knows to look for the contract source code at ./src/contracts/<name>.contract.ts
and such.
A SmartWeave contract for Warp Contracts is basically a single JS file that exports a handle
function. We write our contracts in TypeScript and then use esbuild to obtain the contract source code. The base HollowDB contract provides the necessary functions of a CRUD database, along with several admin operations such as changing the owner.
To begin creating your own contract, simply do:
pnpm contract create -n your-new-contract
Within your newly created contract, you can modify the existing functions or add your own.
Each function in the contract is handled as a case
of switch-case
, and has the following structure:
case 'functionName': {
const {/* inputs */} = await apply(caller, input.value, state, /* modifiers */);
/* function logic */
return {state}; // for write interactions; or,
return {result}; // for read interactions
}
For example, here is updateOwner
that updates the state of the contract, meaning that this is a "write interaction":
case 'updateOwner': {
// `onlyOwner` modifier ensures caller is owner
const {newOwner} = await apply(caller, input.value, state, onlyOwner);
// updates the owner
state.owner = newOwner;
// returns updated state
return {state};
}
Another simple example that returns a result, meaning that this is a "read interaction":
case 'get': {
const {key} = await apply(caller, input.value, state);
return {
result: await SmartWeave.kv.get(key)
};
}
The apply
function is a utility that enables you to add modifiers to your function, just like Solidity modifiers. The first 3 arguments to apply
must be the following:
caller
is like themsg.sender
in Solidity, it is the wallet address that is making the interactioninput.value
is the input value of this interactionstate
is the current contract state
All of these are available at the top of the contract already, so you do not have to worry about preparing them. The remaining arguments are modifiers, which always take three arguments, exactly in the same order that we have given them to apply
above:
- caller: a string that represents address of the caller account, similar to
msg.sender
- input: the input to this contract function
- state: contract state
Each modifier must return a value with the same type as input
, it can be a Promise too. This way, each modifier does their thing to input values, and apply
returns the final value; kind of like a reduce
operation.
Let us look at the onlyOwner
modifier that is used in the example:
export const onlyOwner = <I, S extends ContractState>(caller: string, input: I, state: S) => {
if (caller !== state.owner) {
throw NotOwnerError;
}
return input;
};
We provide the generic parameters so that TypeScript can infer the input
type depending on which function we are implementing. Writing your own modifiers is a great way to change the functionality of existing contracts.
When you are adding a new function, you may notice that TypeScript will give errors to your newly added case
. This is because it is not yet registered as a contract input for the handle
function yet. All the functions at the start are defined by default within the ContractHandle
type; to define our own inputs we must pass them to the handle type.
For example, let's say we have a function foo
with a number input and bar
with some other input:
type FooInput = {
function: 'foo';
value: number;
};
type BarInput = {
function: 'bar';
value: {
barbar: string;
};
};
We can give these inputs as the third argument to our ContractHandle
type:
export const handle: ContractHandle<Value, Mode, FooInput | BarInput> = async (state, input) => {
// ...
};
Now you can add the respective cases without any type errors, and also type-inference will understand the type of your input.value
based on which case you are handling!
When
esbuild
builds the contract, it will put all necessary files within the build file. If you are using an NPM package within your contract, the entire package will be written into the output! This will cause the contract to be unreadable with huge lines of code. To avoid this issue, simply try to be 0-dependency, or use Warp Plugins if convenient.
When you are done writing the contract, you can build it
If you've added new contract functions, and would like to be able to call them using the HollowDB SDK, you have to extend the SDK with your custom functions.
HollowDB provides a BaseSDK
which implement all core functionalities. To make them type-safe, we provide the template parameters. As an example, here is the actual HollowDB SDK class:
import {SDK as BaseSDK} from './base';
type Mode = {proofs: ['auth']; whitelists: ['put', 'update']};
export class SDK<V = unknown> extends BaseSDK<V, Mode> {}
By providing the Mode
type, we get type-safety for our whitelist names and circuit names; and, we provide the option to define a type for the values V
to be stored in the database.
Let's consider the FooInput
example from above:
type FooInput = {
function: 'foo';
value: number;
};
We can handle this function as we extend the BaseSDK
:
import {SDK as BaseSDK} from './base';
type Mode = {
/* your contract mode, if you have any */
};
export class FooSDK<V = unknown> extends BaseSDK<V, Mode> {
async foo(value: number) {
return this.base.dryWriteInteraction<FooInput>({
function: 'foo',
value,
});
}
}
Within this directory:
errors
contain errors that we may throw within the contract.modifiers
contain custom logic that is executed before a function within the smart contract, similar to Solidity function modifiers.states
has the initial state for each contract.types
has types, as usual in TypeScript.utils
has common utility functions, such as proof verification.- the remaining files with
.contract.ts
extension are the smart-contracts, and when you runpnpm contract build
they will be detected and built.