30 KiB
title, date, description, author, tags
| title | date | description | author | tags | |||
|---|---|---|---|---|---|---|---|
| P2PKH in XO - From vision to reality | 2025-10-16 | This is an XO article! | Jonathan Silverblood |
|
P2PKH in XO: From vision to reality
mindmap
root )Responsibilities(
[INVITATIONS]
(Connects)
📱 Apps
👛 Wallets
👥 Users
(Coordinates)
⚡ Actions
🧩 Variables
📥 Inputs
📤 Outputs
[TEMPLATES]
(Describes)
🧩 Variables
💸 Transactions
📥 Inputs
📤 Outputs
🏷️ Lockscripts
(Defines)
⚡ Actions
💸 Transactions
💾 Data
[ENGINE]
(Generates)
🔑 Keys
🪪 Identities
✉️ Invitations
💸 Transactions
(Manages)
🤫 Secrets
🧩 Variables
📤 Outputs
🏷️ Lockscripts
In this article, we are going to go over how a templating engine might function in the context of a P2PKH template.
We will go over both the expected flow for a user interaction, as well as explaining how the engine gets all data it requires to function.
Finally, we will cover some of the rationale for why this system is valuable.
The Templating System
Engine
At the center of the templating system is an engine that together with templates is responsible for the full lifecycle of transactions, relevant state and private keys.
Templates
The templates are immutable and only contain detailed descriptions and instructions on how to create and manage blockchain and use-case related data structures, such as transactions and lockscripts.
Templates are organized around actions which defines when and what data to create.
Invitations
When a template action is requested, the engine follows a consistent and re-usable invitation process.
First an invitation is created that defines what template and action has been requested. This invitation is then accepted by entities wanting to participate in that action, including which of the available roles they want take.
Then, the invitation is extended with any missing information by the participants until it reaches a state where the action defined in the invitation can be executed.
This process is the same regardless of the complexity of the template and allows for both single-user and multi-user participation.
Identity
When we are working with templates and interactions using invitations, it is important that each entity that is providing data or is taking part in an action has a cryptographic identity. This helps provide a clear log and history, allows us to assign blame and to recover when something has gone wrong.
The engine will deterministically create an identity based on the root entropy provided during initialization. If no entropy is provided it will generate a temporary root entropy and will not have a stable identity.
More information on identities will be covered in a future article.
Templates
Dynamic content
The templating system described here uses CashASM to provide dynamic descriptions, properties and other values.
This article will not go into depth on how CashASM works, but here's what you need to know:
$(expression)evaluates the expression using the BCH VM opcodes, then return the top stack item.<something>push something to the stack.
Here's an example evaluating to BitcoinCash, for reference:
$(<'Bitcoin'> <'Cash'> OP_CAT)
Detailed explanation of a P2PKH interaction
We will now go over a very detailed example of how the engine, a template and invitations all go together to make a request for 100.00 MUSD fungible tokens.
Starting the engine
Before the engine can be used, it needs to be initialized.
This process provides the engine an initial root entropy, storage facilities and ways to monitor the bitcoin cash blockchain:
// Create seed.
// NOTE: this should be randomized the first time, then reused when the engine is setup.
const seed = '<some random stuff>';
// Set up a way to monitor block chain status
const blockchainMonitor =
'<some code to set up a blockchain monitoring solution>';
// Set up ways to store state and history
const browserIDBState = '<some code to store state in your browsers indexeddb>';
const privateServerState =
'<some code to store state with a private state service>';
// Initialize the engine using the seed, blockchain and state providers.
const xoEngine = new Engine(seed, blockchainMonitor, [
browserIDBState,
privateServerState,
]);
Importing template
Before we can use the P2PKH template, we need to import it into the engine:
const p2pkhTemplateIdentifier = await xoEngine.importTemplate(TemplateP2PKH);
Requesting Fungible Tokens
In the template there is list of starting actions that are available at any time:
start:
{
owner: [ 'receive', 'requestSatoshis', 'requestFungibleTokens', 'requestNonfungibleTokens' ],
},
Note that these actions are grouped by role, in this case the P2PKH owner can receive and request funds. Other templates might have different starting actions for different roles.
Since we are looking to specifically request 100.00 MUSD fungible tokens, we will need to take a look at the requestFungibleTokens action:
requestFungibleTokens:
{
name: 'Request Fungible Tokens',
description: 'Requests a specific amount of a fungible tokens from one or more senders.',
icon: 'request',
variables: [ 'requestedTokenCategory', 'requestedTokenAmount' ],
transactions: [ 'requestFungibleTokensTransaction' ],
},
The name and description here describes the action itself and is intended to help user find the right action for what they want to do.
The action will in the end produce a requestFungibleTokensTransaction transaction, but to do so it needs some variables like the requestedTokenCategory and the requestedTokenAmount.
Here's the definition of these variables in the template:
requestedTokenCategory:
{
name: 'Requested Token Category',
description: 'The token category requested',
type: 'bytes',
hint: 'token_category',
},
requestedTokenAmount:
{
name: 'Requested Token Amount',
description: 'The fungible token amount requested',
type: 'integer',
hint: 'token_amount',
},
Note that there is a technical type, the bytes, and a type hint.
The type hint is there so that apps and wallets can provide better user experiences, and lets the engine do some helpful validation.
For example, rather than have the user provide the MUSD token category like a hex string: b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366, the wallet might show a dropdown with known tokens the user can choose from with the option of manual input as needed.
In our example we will simply hard-code 100.00 worth of MUSD:
const moriaTokenCategory =
'b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366';
const moriaDecimals = 2;
const requestVariables = {
requestedTokenCategory: hexToBin(moriaTokenCategory),
requestedTokenAmount: BigInt(100.0 * 10 ** moriaDecimals),
};
const requestInvitation = await xoEngine.createStartingInvitation(
p2pkhTemplateIdentifier,
'requestFungibleTokens',
{ variables: requestVariables },
);
This code will return an invitation that contains information such as the template identifier, what action is in progress and any variable values for that action. Additionally every value in the invitation is covered by a signature from the entity that provided it.
In this example, we will use this invitation to coordinate the details of the request between the sender and the receiver.
Under the hood
As part of creating the starting invitation, the engine also needs to provide all information that should come from the current user. Since the starting action here was grouped under the owner role, it also automatically accepts the invitation in order to take on that role.
The next steps are going to drill down through the template in order to demonstrate how the engine finds the requirements of the action and how it generates the necessary data to satisfy those requirements without any further user interactions.
Here's an overview of the P2PKH template structure:
flowchart RL
subgraph Template
Transaction[requestFungibleTokensTransaction]
Output[requestFungibleTokensOutput]
Lock[receivingLockingScript]
Hash --> Lock
Lock --> Output
Output --> Transaction
end
subgraph Engine
Hash[["$(<ownerKey.public_key> OP_HASH160)"]]
Public[ownerKey.publicKey]
Private[ownerKey]
Secret[Seed]
Public --> Hash
Private --> Public
Secret --> Private
end
Here is the requestFungibleTokensTransaction that the action needs to produce:
requestFungibleTokensTransaction:
{
name: 'Completed request',
description: 'Completed request for $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>).',
icon: 'request',
// Standard transaction without a locktime.
version: 2,
locktime: 0,
// Inputs and outputs that must exist in the transaction.
inputs: [ ],
outputs [ 'requestFungibleTokensOutput' ],
// ...
composable: true,
},
As you can see, it's a simple transaction construction, with a requirement set for a single requestFungibleTokensOutput output.
The transaction also has a description that might not look very human readable in here, but once you take the description provided and apply the CashASM evaluations in it, it transforms to a much more human friendly:
Completed request for 100.00 MUSD.
The engine now needs to determine how to create the requestFungibleTokensOutput:
requestFungibleTokensOutput:
{
name: 'Requested',
description: '$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) requested $(<requestedTokenCategory.symbol>) tokens.',
icon: 'request',
// Defines how the requested funds should be locked.
lockscript: 'receivingLockingScript',
// Require a flat 1000 satoshis to ensure the fungible tokens remains transferrable.
valueSatoshis: 1000n,
// Require only the specified amount and type of fungible tokens.
// NOTE: This can be composed with a request for a non-fungible token, but will always result in two separate outputs.
token:
{
category: '$(<requestedTokenCategory>)',
amount: '$(<requestedTokenAmount>)',
nft: null,
},
},
Like the transaction, the output has a human readable name and description, but also requires a flat 1000 satoshis as well as the requested amount and category, and is managed by the receivingLockingScript.
If we follow that lockscript we will get to a quite long section, but for now the part that we care about is determining how the engine will resolve this to get an actual lockscript it can use in this output.
Here's a shortened version showing a deeper reference to the lockP2PKH script:
receivingLockingScript:
{
// Defines how spending future received funds should be authorized.
lockingType: 'standard',
lockingScript: 'lockP2PKH',
}
Following that reference shows the lockP2PKH script itself:
lockP2PKH: 'OP_DUP OP_HASH160 <$(<ownerKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG',
As some of you might've noticed, this is where the actual P2PKH part comes in, and the lockscript as it will be used on-chain is constructed.
However, we're not quite done yet as the lockP2PKH is defined with a dynamic CashASM expression: <$(<ownerKey.public_key> OP_HASH160)>.
This part takes the ownerKey, produces the corresponding public key and pushes it to the stack, then runs OP_HASH160 and return the hash.
In order to resolve this it needs to know how to get to the ownerKey:
// Describe the secret private key.
ownerKey:
{
name: 'Owners Private Key',
description: 'The private key used to authorize spending of received funds.',
type: 'bytes',
hint: 'private_key',
},
This key is not provided by the action, but since the engine knows what a private key is and no key was provided it will create a new key on-demand every time such a key is needed, avoiding unnecessary address re-use.
The engine can now evaluate the CashASM part of the lockP2PKH script to create the receivingLockingScript that completes the requestFungibleTokensOutput, which is added to the invitation.
The ownerKey is not added to the invitation though, and we will go into more details when discussing state and secrets later in the article.
At this point, we have gone through all the requirements for setting up a request to receive 100.00 MUSD and we have created an invitation that can be shared to complete the request.
Handling the fungible tokens request
Let's assume that we have received the request invitation above via an XO compatible transport, and that we have an XO compatible wallet with sufficient funds in it.
In order to handle the request, there is a number of questions that will need to be answered:
- what part do I take in this action?
- what information will I need to provide?
- which of my funds will be used for this?
- do I actually even want to do this?
Ideally, we also want to interact with the wallet user as few times as possible, so the first thing we would need to do is figure out what is required of the user.
Roles
We start by checking if there is any open roles the user can take:
const availableRoles = await xoEngine.listAvailableRoles(requestInvitation);
This step is needed because taking on a role defines which of the variables and resources needs to be provided later.
In this case, the only role that for the action was the owner, and that is already taken, so the only thing for us to do here is to accept the request without taking on a specific role in it:
const acceptedRequestInvitiation = await xoEngine.acceptInvitation(
requestInvitation,
[],
);
In a more complex setup, this would require showing the user a list of available roles that they can choose from, before accepting the invitation
Variables
Next step is to check what variables are missing to complete the request:
const { requiredVariables } =
await xoEngine.listRequirements(requestInvitation);
This step is needed because the required variables could be used to define the resources needed to be provided later.
The action in this invitation only required two variables, the requestedTokenAmount and requestedTokenCategory. Both of these were already set by the owner, so there's nothing for us to do here.
In a more complex setup, this would require asking the user to input values for their required variables before moving on.
Inputs and outputs
The last step is to determine what resources are required in terms of blockchain assets.
For satoshis, we look at the the requestFungibleTokensOutput to see 1000:
valueSatoshis: 1000n,
For tokens we also look at the requestFungibleTokensOutput and here we can see that it has a fungible token amount that was evaluated to 10000n and a token category of b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366:
token:
{
category: '$(<requestedTokenCategory>)',
amount: '$(<requestedTokenAmount>)',
nft: null,
},
Now that the engine knows what resources must exist in the output of the transaction, we can infer this as the required resources to provide if we want to complete this action.
Now that the engine knows what resources must exist in the output of the transaction, it can infer the required inputs. In this case to cover the required output and mining fee.
The next step would be to select some UTXOs to add as inputs, create any needed change outputs and then update the invitation with this information, which can be done automatically by the engine:
const requirements = {
requiredSatoshis,
requiredFungibleTokens,
requiredNonFungibleToken,
};
const preparedRequestInvitation = await xoEngine.reserveResources(
acceptedRequestInvitiation,
requirements,
);
In a later example we will show how to do this manually, to achieve coin control, choice in coin selection strategy and using different lockscripts for different change outputs to maintain privacy.
Authorization
Now that we know all roles, variables and resources the user would provide, we can show this information to the user and ask for a confirmation.
Assuming the user then agrees to take part in the action we go ahead and sign our inputs, marking our participation in this invitation complete:
const signedRequestInvitation = await xoEngine.signInvitation(
preparedRequestInvitiation,
);
The invitation can then be sent back, or if signed by all relevant parties, we can execute the action in the template:
const requestTransaction = await xoEngine.executeAction(
signedRequestInvitation,
BROADCAST_ON_EXECUTION,
);
The request for 100.00 MUSD fungible tokens is now complete and a transaction exist on-chain.
Managing state
Now that the easy part is done, let's dig into the heart of the engine and the templating system.
Now that a transaction has been broadcast on chain that has an output matching the locking script we used for our request, the engine will automatically detect this output and internally connect it with the template and action, keep track of the original variables.
However, if you remember from earlier the ownerKey was generated on-demand as-needed, but in order to spend this new output we would need the same private key as was used to create the lockscript, so let's revisit the receivingLockingScript, this time without shortening it:
receivingLockingScript:
{
name: 'Received',
description: 'Funds received without wallet coordination.',
icon: 'address',
// Defines how spending future received funds should be authorized.
lockingType: 'standard',
lockingScript: 'lockP2PKH',
// Define a default unlocking script to be used when no action provided script is present.
unlockingScript: 'unlockP2PKH',
roles:
{
owner:
{
// The only state that is required to be persisted when receiving funds is the owners private key.
state: [],
secrets: [ 'ownerKey' ],
// List actions that can be taken with the ownerKey for each address/lockscript.
actions: [ 'sign', 'verify', 'sendSatoshis', 'sendFungibleTokens', 'sendNonfungibleTokens', 'burnFungibleTokens', 'burnNonfungibleTokens' ],
// Indicates how much of received funds should be part of a wallets total balance.
balance:
{
satoshis: true,
fungibleTokens: true,
nonFungibleToken: true,
},
// Indicate when received funds should be considered in automatic coin selection to meet input requirements of other transactions.
selectable: true,
// Indicate that received funds are not considered good privacy.
privacy: false,
},
},
}
}
The description here might seem a little odd at first, as it does not match the action we had earlier, but this is intentional.
At any time when the lockscript is generated, it is the engines responsibility to start tracking that lockscript and all activity related to it. When such activity can be linked to an action, it will use the descriptions provided by that action, but sometimes there is activity that the engine is not aware of, such as when you receive an airdrop or someone dusts your address.
The name and description of the lockscript are then used for such outputs that cannot be linked to an intentional action, allowing us to provide human meaningful context even for uncoordinated activity.
You can also see that there is a default unlocking script, which is used when the engine needs to use an output on this lockscript and have not received any explicit instructions on how to unlock it.
The important part for us right now though, is the state/secrets.
// The only state that is required to be persisted when receiving funds is the owners private key.
state: [],
secrets: [ 'ownerKey' ],
When the engine is asked to create either an output of a lockscript, the template can define a set of variables that are to be automatically managed. The engine would then take this state and internally tie it to the output, or any output using the specific lockscript.
When the output is later spent, the state attached to it will be made available to satisfy any constraints originating from the same template. In this P2PKH example, the ownerKey is marked as part of the secrets which is a subset of the full state that will not be shared when doing invitations or similar actions.
In more advanced uses, the state might be defined at one transaction, but only be needed several transactions later. In these cases, it is up to the template authors to define outputs to carry the state from one transaction to the next until it is no longer needed.
Note that a result of this behaviour is that state has a lifecycle and once an output is spent, any state attached to that output can be archived into history or even pruned entirely, depending on the needs of the wallet or application.
Managing state is likely to most complex and valuable part of the templating system, and much more can be said on the matter. We will go into more detail in later examples.
What is ownership?
There are a few more parts of the receivingLockingScript left to go over that relates to ownership and control of outputs: actions, balance, selectable and privacy.
These properties provide information to an app or wallet that uses the engine. Let's go over each of them to get a better understanding of what they do:
// List actions that can be taken with the ownerKey for each address/lockscript.
actions: [ 'sign', 'verify', 'sendSatoshis', 'sendFungibleTokens', 'sendNonfungibleTokens', 'burnFungibleTokens', 'burnNonfungibleTokens' ],
The actions list provides references to actions that you might be able to take with a given output or the state attached to an output.
In the P2PKH template, these can be summarized as actions to sign and verify messages as well as sending and burning value.
This allows an app or wallet to present possible actions when the user is looking at a given output or address. Some of the actions listed here may have additional constraints, for example:
// Sending is only available for outputs that have fungible tokens on them.
condition: '$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)',
The sendFungibleTokens action has a condition that makes it unavailable if the input does not have any fungible tokens on it.
Apart from potential actions, there is also a balance property which can be used to get a better understanding of how much of an output is estimated to owned by the user:
balance:
{
satoshis: true,
fungibleTokens: true,
nonFungibleToken: true,
},
In the P2PKH example, these are set to true to indicate that the full output is estimated to be owned by the owner. However, these fields can also be set to false, or a number.
In more advanced cases, these fields would be CashASM evaluations that would be able to determine values based on the variables in the state, and in some cases they would be impossible to define and would be unknown.
It is up to the app or wallet to determine how they want to use, or not use, this information.
Finally, there are two properties relating to coin selection: selectable and privacy:
// Indicate when received funds should be considered in automatic coin selection.
selectable: true,
// Indicate that received funds are not considered good privacy.
privacy: false,
Coin selection is a vast field with no best answer, so these are provided as additional information that a coin selection algorithm may use, and not as strict directives for when and how to do coin selection.
The selectable property allows the template authors to indicate if it is sensible to use an output in automatic coin selection and can be either true (may be used), false (should not be used) or can be left undefined.
The privacy property allows the template authors to indicate what level of privacy an output has, which can be helpful to avoid mixing of inputs of different levels of privacy.
Other actions
Before we wrap up, let's take a deeper look at the sign and verifyactions:
sign:
{
name: 'Sign Message',
description: 'Signs a provided message using the Bitcoin message signing protocol.',
icon: 'sign',
variables: [ 'message' ],
data: [ 'messageSignature' ],
},
verify:
{
name: 'Verify Message Signature',
description: 'Verifies a provided message signature according to the Bitcoin message signing protocol.',
icon: 'verify',
variables: [ 'messageSignature', 'message' ],
data: [ 'messageSignatureValidity' ],
},
Contrary to previous actions we have looked at, these do not result in transactions, but instead provides arbitrary data.
Let's look at the messageSignature definition, and the related messagePrefix constant:
messageSignature:
{
// Evaluate CashASM expression to get the signature needed.
value: '$(<messagePrefix> <message> OP_CAT <ownerKey.data_signature.top_stack_element>)',
type: 'bytes',
hint: 'signature',
},
messagePrefix:
{
name: 'Message Prefix',
description: 'Standard message prefix used in the bitcoin signed message protocol.',
type: 'bytes',
// Value is enforce to the bitcoin signing magic string:
// "\x18Bitcoin Signed Message:\n"
value: '0x18426974636f696e205369676e6564204d6573736167653a0a',
},
Together, these encode the standard bitcoin message signing procedure to safely sign an arbitrary message using the ownerKey from any given address or output, without exposing the keys to 3rd parties or allowing them to sign potentially dangerous messages.
The validation of a signature is done in a similar way:
messageSignatureValidity:
{
// Evalute the validity of the message with the owners public key.
value: '$(<messageSignature> <messagePrefix> <message> OP_CAT <ownerKey.publicKey> OP_CHECKDATASIG)';
type: 'integer',
hint: 'script_boolean',
},
This means that any wallet that uses this P2PKH template would be have this feature without needing to add any special handling or code for it, simply find any address/lockscript or output, look at the associated actions and initiate an action to sign or verify a message.
Rationale
After reading this article, you might rightfully feel that it's all a bit much, and wonder why someone would go through the effort and spend the resources required to build such a complicated machinery.
The answer is that it allows us to build new applications both faster and safer, while providing a significantly better user experience.
Safety
In the traditional wallet and app ecosystem today, the default is to run on HD wallets using seed words for backup and recovery.
With every new feature added to the Bitcoin Cash blockchain that empowers smart contract developers, the number of use cases where the wallet seed is insufficient grows.
Contracts by nature are stateful and loss of state is often equivalent to loss of funds. While this can be mitigated to some degree by carrying state in token commitments, it has the tradeoff of exposing that state to the world. Further, some state, like your prediction in a guru bet, is required to be kept secret, and cannot be safely stored in a token commitment.
By addressing the need for state management in modern smart contracts we reduce the risk of state loss and therefor improve safety for users.
Further, by creating an engine that is capable of both generating and storing keys, and keeping them isolated and restricting their use to template defined actions, we provide additional safety in terms of safe key management. This is also a missing piece in order to achieve fully non-custodial applications, where both control of all keys, as well as decisionmaking for what those keys are used for resides entirely with the users wallet.
Finally, by creating an invitation and coordination mechanic that doesn't rely on single-key operation and that does template based validation of provided data, we improve safety for users when interacting with 3rd party distributed applications.
User experience
With templates, every transaction, input, output, lockscript and address can be described with dynamically evaluated CashASM names and descriptions. Further, every variable or input required by the user can be described as well, and their type and data can be validated.
With templates, the need for technical identifiers is massively reduced.
Further, by having contextual actions, external links and other metadata it becomes much easier for users to be empowered and stay in control.
Finally, with templates it becomes possible to estimate a users aggregate net worth across a wide variety of contracts, as well as show how much of that value is readily accessible for spending and how much is currently locked.
Higher productivity
With templates, the correct use of a contract is directly encoded. Importing a template that someone else has made can be done to provide new features at almost no cost.
Compared to some alternatives, templates also provide a consistent way to adopt new features where previously app and wallet makers would need to import specific libraries for each feature they want, or do full bespoke implementations.
For reference, the AnyHedge library, excluding tests and examples, is 262.8 KiB large.
While it can do much more than a corresponding template, a lot of it is re-inventing the BitcoinCash domain knowledge, working with inputs and outputs etc, and my estimate is that less than a third of it would been needed if we had full template support.