Skip to content

Getting started ​

⚠️ This project is in early development phase. Use it only if you understand what you are doing and accept the risks. Do not use funds you are not willing to lose. Minor version bumps may introduce breaking changes.

This guide takes you from an empty project to your first XO transaction on chipnet. You create a receive address, fund it from a wallet you already have, and then send part of those funds to yourself through an invitation. Each step shows which engine calls to make and in which order. By the end you will know the packages you need, the functions you call most, and how an invitation goes from created to broadcast.

Before you begin ​

You need:

  • A BIP39 mnemonic for a test account. (Never use a mnemonic that holds real funds while you build.)
  • Chipnet coins to fund the new account, from a wallet you already have or from a chipnet faucet. Known working faucet: https://tbch.googol.cash/

Install ​

bash
npm install @xo-cash/engine@0.1.0-next.17612568780 @xo-cash/state @xo-cash/templates @xo-cash/utils "@bitauth/libauth@^3.1.0-next.8"

Note that the engine has no stable 0.1 release yet. Use the version tagged next, as the command above does.

Key concepts ​

Account. An account is one mnemonic on one network. Each Engine instance opens one account.

Template. A template describes a Bitcoin Cash use case in a standard format. It lists the roles, the actions each role can take, and the variables, transactions, outputs, and locking scripts those actions use. The engine builds every transaction from the template, so you trust the template rather than the app that uses it. The templateIdentifier is a hash of its content.

Role. A role is a participant position in an action, such as receiver or sender. The role you take decides which variables, inputs, and outputs you provide.

Action. An action is something a role can do with a template.

Intent. An intent is the request structure the engine starts from. It names an action, an output, or a locking script, then the role to take, the keys to generate, and any variable, constant, or secret values to start with. A template holds intents in three places.

  • template.start holds the actions available from the start, before the account holds any output of the template.
  • A locking script's actions, and the actions of each of its roles, hold the follow-up actions that spend an output locked to that script.
  • template.defaults holds the intents for change, receive outputs, and receive addresses.

Invitation. An invitation coordinates one action between its participants. Each participant adds commits until the action has everything it needs, and then any participant can execute it.

Commit. A commit is one signed addition by one participant. It can take a role, or add variables, inputs, outputs, key values, or unlocking bytecode. Commits only add. A later commit cannot change a value an earlier commit set.

Stored invitation. Engine calls return a stored invitation. storedInvitation.invitation is the part you share. The rest stays with the account.

Tracked scripts. Tracked scripts. The engine stores every locking script it creates for the account, or that the template declares as relevant to the account and watches it through Electrum. A payment to one of them shows up in the account's unspent outputs.

How the calls fit together ​

Once per account
  Engine.create ─► importTemplate ─► setDefaultPolicies

To get funds in
  generateLockingBytecodeFromDefaultPolicy('receive') ─► someone pays it ─► listSpendableUnspentOutputs

For each invitation
  createInvitation                       you start it
  acceptInvitation                       someone shared it with you
        │
        ▼
  listMissingRequirements ◄─────────────────────────────────────┐
        ├─ variables or outputs missing ─► appendInvitation ────┤
        ├─ a role has no participant ────► share the invitation ┤
        └─ a script needs others' keys ──► appendOwnOutputResolutions, share
        │
        ▼
  appendInvitation({ inputs }) ─► addChangeToInvitation
  listOwnInputIndexes ─► signInvitation for each index
  executeAction({ broadcastTransaction: true })
  share the invitation so the others see the result

Each time another participant sends a newer copy
  updateInvitation

The example ​

You fund a new account on chipnet, then send 5000 satoshis from it to a second address of your own with the sendSatoshis action of p2pkhTemplate. You are the only participant, so nothing needs to be shared.

Step 1. Open an account ​

typescript
import { Engine, XO_SUPPORTED_NETWORKS } from '@xo-cash/engine';

const engine = await Engine.create('your test mnemonic', {
    network: XO_SUPPORTED_NETWORKS.chipnet,
});

Engine.create opens storage, connects to Electrum, subscribes to every locking script the account tracks, and refreshes their unspent outputs. When it returns, the account is ready to use.

The network defaults to mainnet, so the example picks chipnet. The defaults cover every other option.

When you are done with the account, call engine.stop(). It closes Electrum and storage, and the instance cannot be used afterwards.

Note that you should keep one account open per process, because the process shares one Electrum connection.

Step 2. Import a template and set default policies ​

typescript
import { p2pkhTemplate } from '@xo-cash/templates';

const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);

await engine.setDefaultPolicies({
    change: { templateIdentifier },
    receive: { templateIdentifier },
    address: { templateIdentifier },
});

importTemplate takes a template object or its JSON. It validates the template, stores it, and returns it with its templateIdentifier. Every later call names the template by that identifier.

Note that importing checks the shape of the template, not whether it is safe. The template decides what your keys sign, so import only templates you have reviewed or trust.

setDefaultPolicies tells the engine which template to use when it has to make a locking script and the call does not say which one.

PolicyUsed by
changeaddChangeToInvitation, when the invitation's own template defines no change
receivegenerateLockingBytecodeFromDefaultPolicy('receive')
addressgenerateLockingBytecodeFromDefaultPolicy('address')

Each policy must name a stored template whose defaults defines that policy. p2pkhTemplate defines all three. listTemplatesForDefaultPolicy(policy) lists the stored templates that can back a policy.

Step 3. Fund the account ​

A new account has nothing to spend. Create a receive address.

typescript
import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth';

const fundingLockingBytecode = await engine.generateLockingBytecodeFromDefaultPolicy('receive');

const fundingAddress = lockingBytecodeToCashAddress({ bytecode: hexToBin(fundingLockingBytecode), prefix: 'bchtest' });

if (typeof fundingAddress === 'string') {
    throw new Error(fundingAddress);
}

console.log(fundingAddress.address);

generateLockingBytecodeFromDefaultPolicy derives a new key and starts watching the address, so a payment to it shows up in this account.

Send enough chipnet coins to that address to cover the 5000 satoshis you send in this guide and the fee. For a good experience, send 10000 satoshis or more. Use a wallet you already have, or paste the address into a chipnet faucet.

Wait for the funds to arrive. Once Electrum reports the payment, the output shows up in the account.

typescript
const [ fundedOutput ] = await engine.listSpendableUnspentOutputs();

listSpendableUnspentOutputs() lists the outputs that no invitation has reserved yet.

Step 4. Find the action to take ​

The address from step 3 is locked to the receivingLockingScript of p2pkhTemplate, for the receiver role. That role lists the follow-up actions you can take with the funds, as intents. Pick the one that sends satoshis.

typescript
import { getTemplateLockingScriptOrThrow } from '@xo-cash/engine';

const template = await engine.getTemplate(templateIdentifier);

if (template === undefined) {
    throw new Error('Import the template first');
}

const lockingScript = getTemplateLockingScriptOrThrow(template, 'receivingLockingScript');
const receiverIntents = lockingScript.roles?.receiver?.actions ?? [];

const sendIntent = receiverIntents.find((receiverIntent) => receiverIntent.action === 'sendSatoshis');

The sendSatoshis intent names the sender role.

Step 5. Create the invitation ​

Create a second receiving locking script as the recipient, then create the invitation from the intent.

typescript
const recipientLockingBytecode = await engine.generateLockingBytecodeFromDefaultPolicy('receive');

const createdInvitation = await engine.createInvitation({
    templateIdentifier,
    actionIdentifier: 'sendSatoshis',
    intent: sendIntent,
    variables: [
        { variableIdentifier: 'transferredSatoshis', roleIdentifier: 'sender', value: 5000n },
        { variableIdentifier: 'recipientLockingscript', roleIdentifier: 'sender', value: recipientLockingBytecode },
    ],
    inputs: [{ outpointTransactionHash: hexToBin(fundedOutput.outpointTransactionHash), outpointIndex: fundedOutput.outpointIndex }],
});

const { invitationIdentifier } = createdInvitation.invitation;

A role is taken by a commit. createInvitation reads the role from the intent, here sender, and puts it on the first commit. The same commit carries the amount, the recipient, and the funded output as an input. The recipient is your own second locking script, so the funds come back to this account.

  • A variable the action asks a role for carries that roleIdentifier.
  • Adding an input to the invitation, reserves it. Another invitation that tries to use it gets UnspentOutputReservationExternalConflictError.
  • When the action's transaction declares template inputs, also set inputIdentifier on the input. sendSatoshis declares none.
  • Without an intent, pass roleIdentifier to name the role directly. Naming the role in both places throws InvitationRoleDuplicatedError. listTemplateActionIntents(template) lists one intent per action.

Step 6. Fill in the requirements ​

Ask the engine what is still missing, add it, and ask again.

typescript
const missingRequirements = await engine.listMissingRequirements(invitationIdentifier);
FieldWhat it lists
templateRequirementsVariables, inputs, and outputs the action asks for that no commit has given yet. Grouped by role under roles
participantsRoles that still need someone to join. Each has filledSlots and minimumSlots
inputsMissingSignaturesInputs that are in the invitation and are not signed yet

The invitation is ready to execute when all three are empty. listRequirements(invitation) lists everything the action asks for, filled or not.

Here the action still needs the output that pays the recipient. appendInvitation adds it as a new signed commit.

typescript
await engine.appendInvitation(invitationIdentifier, {
    templateIdentifier,
    actionIdentifier: 'sendSatoshis',
    outputs: [{ outputIdentifier: 'transferSatoshisOutput' }],
});

The output names only the template output. The engine builds its value from transferredSatoshis and its locking bytecode from recipientLockingscript.

  • You can pass lockingBytecode and valueSatoshis on an output yourself instead.
  • An output that names a role on its locking script is built with this account's keys, and the engine starts tracking that script.
  • resolveInvitationOutputLockingBytecode(invitationIdentifier, outputIdentifier, roleIdentifier) returns the locking bytecode an output would get, and adds nothing.

Once templateRequirements and participants are empty, only the signatures are left.

Step 7. Add change and sign ​

Add the change first, and sign last.

Add change ​

typescript
import { DUST_LIMIT_SATOSHIS } from '@xo-cash/engine';

const leftover = await engine.calculateChange(invitationIdentifier);
const estimatedFee = await engine.estimateTransactionFee(invitationIdentifier);

const changeSatoshis = leftover.valueSatoshis - BigInt(estimatedFee);
const hasChange = changeSatoshis >= BigInt(DUST_LIMIT_SATOSHIS) || leftover.tokens.length > 0;

if (hasChange) {
    await engine.addChangeToInvitation(invitationIdentifier);
}

calculateChange returns the satoshis and fungible tokens left after the committed outputs. Whatever is not returned as change goes to the fee.

addChangeToInvitation adds a satoshi change output and one output per leftover token category. p2pkhTemplate defines a change intent, so the change goes to a new script of this account.

Sign ​

Sign once the inputs and outputs are final. A signature covers the outputs, so add the change before you sign.

typescript
const ownInputIndexes = await engine.listOwnInputIndexes(invitationIdentifier);

for (const inputIndex of ownInputIndexes) {
    await engine.signInvitation(invitationIdentifier, inputIndex);
}

listOwnInputIndexes returns the inputs this account can sign. signInvitation signs one of them and adds a commit with its unlocking bytecode.

Every input is signed when listMissingRequirements(invitationIdentifier) returns an empty inputsMissingSignatures.

Step 8. Execute the action ​

typescript
const { builtTransaction, storedInvitation } = await engine.executeAction(invitationIdentifier, {
    broadcastTransaction: true,
});

console.log(builtTransaction.transactionHash);

executeAction builds the transaction and records a signed result on the invitation. With broadcastTransaction: true it also sends the transaction to the network this engine was created for, records that too, and clears the reservations of the invitation. With false it only builds and records.

If the broadcast fails, executeAction throws TransactionBroadcastFailedError, and the created result may already be stored. Read the invitation again with getInvitation before you retry.

Step 9. Check the result ​

typescript
const unspentOutputs = await engine.listUnspentOutputs();

The payment and the change both pay scripts this account tracks. Once Electrum reports the transaction, they take the place of the funded output in the list.

That completes the flow. Every action follows the same loop, however complex its template is. Actions with more than one participant add one more thing, which is sharing the invitation.

Going further ​

Invite other participants ​

Some actions need more than one participant. You share the invitation with the others, they add what their role needs, and they share it back. serializeInvitation turns an invitation into a string, and deserializeInvitation turns it back. How you send that string is up to you.

typescript
import { serializeInvitation } from '@xo-cash/utils';

const shareableInvitation = serializeInvitation(storedInvitation.invitation);

The account that receives the invitation joins with a role the first time.

typescript
import { deserializeInvitation } from '@xo-cash/utils';

const receivedInvitation = deserializeInvitation(shareableInvitation);

await engine.acceptInvitation(receivedInvitation, { roleIdentifier: 'sender' });

Every newer copy after that goes through engine.updateInvitation(receivedInvitation), which adds the commits the account does not have yet.

  • Share when listMissingRequirements lists a role under participants, or when a script needs values from someone else.
  • listAvailableRoles(invitation) lists the roles that still have a free slot.
  • Both accounts must have imported the same template. The template identifier is a hash, so the template must match exactly.
  • listInvitationValidationIssues(invitation, template, network) checks a received invitation against its template. Look at the issues before you accept.
  • Note that acceptInvitation on an invitation you already hold returns the stored copy unchanged. Use updateInvitation for newer copies.
  • updateInvitation throws when the copy does not share your history.
  • When several participants add inputs, each one signs their own.

Follow-up actions of any output ​

Step 4 knows the locking script of the funded output. For any other output, look up its stored script first. The follow-up intents are in the actions of that locking script, and in the actions of the role this account holds on it.

typescript
import { getTemplateLockingScriptOrThrow } from '@xo-cash/engine';

const [ outputScript ] = await engine.listScriptHashData({ scriptHash: unspentOutput.scriptHash });

const outputTemplate = await engine.getTemplate(outputScript.templateIdentifier);

if (outputTemplate === undefined) {
    throw new Error('Import the template of this output first');
}

const lockingScript = getTemplateLockingScriptOrThrow(outputTemplate, outputScript.lockingScriptIdentifier);
const roleIntents = outputScript.roleIdentifier === undefined ? [] : lockingScript.roles?.[outputScript.roleIdentifier]?.actions ?? [];
const followUpIntents = [ ...(lockingScript.actions ?? []), ...roleIntents ];

An intent that names secrets, such as ownerKey, applies only to outputs whose script holds them. listSecretsForScriptHash(scriptHash) lists the secrets a script holds.

Scripts that need other participants' values ​

Some locking scripts are built from values that several participants hold, such as their public keys. Check before you add such an output.

typescript
const outputResolutions = await engine.listOutputResolutions(invitationIdentifier, outputIdentifier, roleIdentifier);
FieldMeaningWhat to do
ownResolutionsValues this account can give that the invitation does not holdCall appendOwnOutputResolutions with the same arguments, then share
missingIdentifiersValues other participants still oweShare and wait for their copy
providedIdentifiersValues the invitation already holdsNothing

When ownResolutions and missingIdentifiers are both empty, add the output with appendInvitation. Before that, adding it throws LockingScriptResolutionsMissingError.

Unlocking scripts work the same way. listInputResolutions(invitationIdentifier, inputIndex) reports the same three fields for an input. Add your part with appendOwnInputResolutions, share, and sign once the invitation holds the rest. Before that, signInvitation throws UnlockingScriptResolutionsMissingError. generateUnlockingBytecode(invitationIdentifier, inputIndex) compiles the unlocking bytecode without adding it.

Preview the transaction ​

typescript
const preview = await engine.buildInvitationTransactionFromMergedCommit(invitationIdentifier);

This merges the commits and encodes the transaction. It stores nothing and sends nothing. The result has serializedTransaction, transactionHash, transactionByteLength, and estimatedFeeSatoshis.

Change options ​

addChangeToInvitation picks the locking script for change in this order.

  1. A locking bytecode you pass as its second argument.
  2. The change intent in the defaults of the invitation's template.
  3. The account's change policy from step 2.

When none of them applies, it throws ChangeLockingBytecodeMissingError. Its other optional arguments are the values the change intent needs, and a ChangeSelection to add only some of the change.

Reservations ​

Adding an input reserves its output for that invitation, so two invitations on this account do not spend the same output. Reservations live in this account's storage only. They are cleared when the transaction is broadcast, or when you archive the invitation with archiveInvitation.

Commit expiry ​

Each commit carries an expiresAtTimestamp, five hours after the commit was made unless you pass another value. Note that the engine does not act on expiry yet.

Taking a second role ​

To take another role on an invitation you are already in, call joinInvitationRole(invitationIdentifier, roleIdentifier).

Note that an account can hold at most one role with a follow up. A role has a follow up when a locking script of the action's outputs declares it, with the state that role saves and the actions it can take next. If you already hold such a role, joining a second one throws EntityMultipleRolesWithFollowUpUnsupportedError. This is not supported yet.

Learn more ​

Read more about XO on the XO blog.