Contract Source
🪓 What do we need in contract source?
Let's talk about contract source. It exports one function - handle
- which accepts two arguments:
state
- contract's current state.action
- contract interaction with two properties:caller
- wallet address of user interacting with the contract.input
- user's input to the contract.
Handle
function should end by:
- returning
{ state: newState }
- when contract state is changing after specific interaction. - returning
{ result: someResult }
- when contract state is not changing after interaction. - throwing
ContractError
exception.
📃 Contract source types
We will start by writing some additional types. Head again to warp-academy-pst/challenge/src/contracts/types/types.ts and write following types:
export interface PstAction {
input: PstInput;
caller: string;
}
export interface PstInput {
function: PstFunction;
target: string;
qty: number;
}
export interface PstResult {
target: string;
ticker: string;
balance: number;
}
export type PstFunction = 'transfer' | 'mint' | 'balance';
export type ContractResult = { state: PstState } | { result: PstResult };
Time for an explanation.
PstAction
represents contract's interaction. As mentioned earlier it has two properties - caller and input. In our contract user will have an ability to write three types of inputs (PstInput
):
function
- type of interaction (in our case - it can be transfering tokens, minting tokens or reading balances -PstFunction
)target
- target address.qty
- amount of tokens to be transferred/minted.
PstResult
- object possible to be returned by interacting with the contract when the state is not being changed:
target
- target address.ticker
- an abbreviation used to uniquely identify the token.balance
- specific address balance.
ContractResult
- contract's handler function should be terminated by one of those:
state
- when the state is being changedresult
- when the interaction was a read-only operation
🎬 Actions
Let's prepare all the interactions that will be possible within our contract. We will put them in separate files, each of the files in a dedicated folder - either read
(actions responsible for reading state) or write
(which change current state). All the folders and files are already prepared, you just need to fill them with some code.
📖 Read
warp-academy-pst/challenge/src/contracts/actions/read/balance.ts
declare const ContractError;
export const balance = async (
state: PstState,
{ input: { target } }: PstAction
): Promise<ContractResult> => {
const ticker = state.ticker;
const balances = state.balances;
if (typeof target !== 'string') {
throw new ContractError('Must specify target to get balance for');
}
if (typeof balances[target] !== 'number') {
throw new ContractError('Cannot get balance, target does not exist');
}
return { result: { target, ticker, balance: balances[target] } };
};
The above function will help us read the balance of the inidicated target address. It takes two arguments - contract computed state and destructured contract action which give us input to the interaction. Remember that we have three possible options to be returned from the interactions? In the above interaction we added two of them - thanks to simple error handling we can return ContractError
or result.
🖊️ Write
Now let's add two write
interactions which will change our contract's state:
warp-academy-pst/challenge/src/contracts/actions/write/mintTokens.ts
declare const ContractError;
export const mintTokens = async (
state: PstState,
{ caller, input: { qty } }: PstAction
): Promise<ContractResult> => {
const balances = state.balances;
if (qty <= 0) {
throw new ContractError('Invalid token mint');
}
if (!Number.isInteger(qty)) {
throw new ContractError('Invalid value for "qty". Must be an integer');
}
balances[caller] ? (balances[caller] += qty) : (balances[caller] = qty);
return { state };
};
This one will help us minting some tokens to the caller's address. It takes two arguments - contract state and the destructured caller of the interaction as well as the input to the interaction. It adds tokens to the caller's address. It can throw ContractError
exception or return contract's state.
warp-academy-pst/challenge/src/contracts/actions/write/transferTokens.ts
declare const ContractError;
export const transferTokens = async (
state: PstState,
{ caller, input: { target, qty } }: PstAction
): Promise<ContractResult> => {
const balances = state.balances;
if (!Number.isInteger(qty)) {
throw new ContractError('Invalid value for "qty". Must be an integer');
}
if (!target) {
throw new ContractError('No target specified');
}
if (qty <= 0 || caller === target) {
throw new ContractError('Invalid token transfer');
}
if (!balances[caller]) {
throw new ContractError(`Caller balance is not defined!`);
}
if (balances[caller] < qty) {
throw new ContractError(
`Caller balance not high enough to send ${qty} token(s)!`
);
}
balances[caller] -= qty;
if (target in balances) {
balances[target] += qty;
} else {
balances[target] = qty;
}
return { state };
And the last one - the core function of our contract which will be responsible for transfering tokens between addresses. It takes two arguments - state and destructured caller of the interaction as well as the input to the interaction. It subtract's the indicated amount of tokens from caller's address and adds them to the target address. It can throw ContractError
exception or return contract's state.
⚓ Handle function
Wow, a lot of work done. Now the cherry on top. We will put all the interactions together to write the final handle
function which will be exported from the contract source.
warp-academy-pst/challenge/src/contracts/contract.ts
import { balance } from './actions/read/balance';
import { mintTokens } from './actions/write/mintTokens';
import { transferTokens } from './actions/write/transferTokens';
import { PstAction, PstResult, PstState } from './types/types';
declare const ContractError;
export async function handle(
state: PstState,
action: PstAction
): Promise<ContractResult> {
const input = action.input;
switch (input.function) {
case 'mint':
return await mintTokens(state, action);
case 'transfer':
return await transferTokens(state, action);
case 'balance':
return await balance(state, action);
default:
throw new ContractError(
`No function supplied or function not recognised: "${input.function}"`
);
}
}
Handle
function is an asynchronous function and it returns a promise of type ContractResult
. As mentioned above, it takes two arguments - state and action. It waits for one of the interactions to be called and returns the result of matching functions - the ones that we prepared earlier.
🎨 Bundling contract
Now comes the tricky part. We need to find a way to bundle our contract source so its output code is in Javascript and not typescript. We will use esbuild tool to achieve that result but of course you can use whichever bundler you'd like. We will not go into the details but you can view the bundling script here https://github.com/warp-contracts/academy/blob/main/warp-academy-pst/challenge/build.js.
It takes the contract source file as an esbuild source file, bundles it and put's its compiled Javascript version in a dist
folder.
const { build } = require('esbuild');
const replace = require('replace-in-file');
const contracts = ['/contracts/contract.ts'];
build({
entryPoints: contracts.map((source) => {
return `./src${source}`;
}),
outdir: './dist',
minify: false,
bundle: true,
format: 'iife',
})
.catch(() => process.exit(1))
.finally(() => {
const files = contracts.map((source) => {
return `./dist${source}`.replace('.ts', '.js');
});
replace.sync({
files: files,
from: [/\(\(\) => {/g, /}\)\(\);/g],
to: '',
countMatches: true,
});
});
Now we just need to add a few commands to our package.json
file that will simply remove everything from dist
folder (which contains the minimized version of the source code), run the bundling script and additionally - copy initial-state.json
file to the dist
folder so we'll have all the files we need to deploy the contract in one place. Just remember that you need to have rimraf
installed - either globally or as a devDependency
.
"build:contracts": "yarn run clean && yarn run build-ts && npm run cp",
"build-ts": "node build.js",
"clean": "rimraf ./dist",
"cp": "copyfiles -u 1 ./src/**/*.json dist"
🎉 Conclusion
Well done! We have all the parts which are needed to deploy our contract. But before that we need to test it out to see if it works correctly.