Building txs
Posted on March 25, 2025
Intro
Last month we got the on-chain code done and benchmarked. This month the focus has been on off-chain code and tx builders.
Pssss! To our reviewers
According to our milestone M2, it must be demonstrated that:
- Feature aligns with Spec. Tests succeed
- At least 1 correctly succeeding test for each “tx type”
- At least 1 correctly failing test for each “tx type”
- Demonstration of complete lifecycle on a testnet (or mainnet)
Like last month, we acknowledgement that this is a proxy, albeit a poor one, for a “sensible” amount of testing. This post is to provide some commentary about how we think we’ve met these aims literally and in the spirit of.
Tx builders
Stack explorations
We made to short investigations into alternative stacks for the off-chain. One into a pallas based rust version, and another with elm-cardano.
Although pallas does have a
tx-builder it is
relatively immature. Projects built on pallas such as
zhuli have opted to roll-their-own
tx builders from the lower level libs. (You might see pallas-txbuilder
in the
Cargo.toml
, but it doesn’t seem to be in use.)
We did use pallas to create an iou signing tool. An L2 in rust can have a native iou sign/verify function. subbit-xyz-iou. Using pallas at this level requires a relatively involved understanding of Cardano. For example, that alt constructor cbor tags start at 121. This is too low level to be a comfortable place to write tx builders.
Our investigation into elm-cardano required first learning a little about elm. As the intro says: Elm is a functional language that compiles to JavaScript. It helps you make websites and web apps. In particular, there is an assumed context for executing the output of elm programs - the browser. Moreover, by design elm is highly restrictive in how to interface with the outside world.
There were three main reasons that contributed to our conclusion to not use elm-cardano this time (beyond our noob elm ability):
- Second class operation outside the primary target
- Lack of byte support
- Non extensible system of intents
We want something that a priori works in the terminal, and maybe works in a browser. This is possible, but it immediately feels like we’re trying to twist elms arm to get the functionality we want. Similarly, elm-cardano has to work quite hard to get a wasm plutus-vm to play nice with the code base.
Another perceived issue is lack of support for handling bytes, a fundamental part of tx building.
A declarative system of intents is an attractive way to organise a tx builder framework. This how plutus-apps constraints worked, although here it was for on and off chain code. The problem comes when the intents (aka constraints) available, don’t cover the required functionality. Had this been implemented as a rust style trait or haskell style class, then we could get our hands dirty and write our own. However, implemented as an ADT, there is no simple extensibility. This brings up some PTSD.
We look forward to watching elm-cardano mature, and hopefully in future can make use of it. We thank the maintainer of the project for their extensive engagement with us during our time working with it.
Stack choice
Ultimately, we are left with our default option, lucid-evolution. Better the devil you know. Thus the tx builders are written in typescript.
We use blockfrost as a provider, although this can be easily changed.
We leave particulars about code organisation, and how to setup and run the code to the documentation in the repo. Installation, setting up secrets etc is required for the following. Tests can be run without a blockfrost key.
Tests
Test results (pnpm test
from the package jobs
):
PASS src/mutual.test.ts (9.232 s)
simple
✓ job (135 ms)
✓ oneSign (302 ms)
PASS src/simple.test.ts (9.241 s)
simple
✓ simple (564 ms)
PASS src/steps.test.ts (9.707 s)
simple
✓ add (232 ms)
✓ sub (110 ms)
✓ close (113 ms)
✓ settle (89 ms)
✓ end (81 ms)
✓ expire (41 ms)
✓ addFail (152 ms)
✓ subFail (41 ms)
✓ closeFail (79 ms)
✓ settleFail (29 ms)
✓ endFail (58 ms)
✓ expireFail (62 ms)
PASS src/batch.test.ts (9.877 s)
simple
✓ job (750 ms)
Our failure cases aren’t particularly interesting, but do provide a sanity check.
Cli
The main interface is the cli tool in the jobs
package. With a little alias
alias subbit="node ./dist/src/index.js"
We can conveniently begin interacting with the chain. For ease of use, many of the available flags have defaults and so are hidden from view. For example
subbit open --subbit-id "deadbeef" --amt 10000000
Will use default values for the consumer and provider, etc.
The cli also exposes ways to show wallet info, subbit show wallets
, as well as
info on subbits. Futher details are found in the repo.
Lifecycle
We’ll walk through a full lifecycle with links to txs on the explorer (“Link”).
Upload script
subbit tx put-ref
(Link).
Open a subbit
subbit open --subbit-id deadbeef
(Link).
Add to a subbit
subbit add --subbit-id deadbeef --amt 1000000 --consumer consumer0
(Link).
We create an iou
subbit iou sign --subbit-id deadbeef --amt 1000000 --iou-key iou0
With this signature, we construct a sub.
subbit tx sub --subbit-id deadbeef --provider provider0 --amt 1000000 --sig 5344827a2834b47910d52f8d723a589bec0289707ff930d5e5b2148c232bb8134aa20d4c315478822423158d752b8283ed16d58525b9deb65827abe8b395b00e
(Link).
The consumer closes the subbit.
subbit tx close --subbit-id deadbeef --consumer consumer0
(Link).
The provider settles the subbit
subbit tx settle --subbit-id deadbeef --provider provider0 --amt 2000000 --sig 12ce2a1e14059c0c76b8ee44b449aca0fc4b4298317c56cfe605f28e82f20888f82420492ce26acf3017f4eae219098679d73653982bf796839193c835276f04
(Link).
Once settled, the consumer ends the subbit
subbit tx end --consumer consumer
(Link).
This completes the lifecycle. We now consider steps in batch.
We expose the possibility to open many subbits simultaneously. This is not very realistic, but its convenient for testing.
subbit tx open-many --many 50 --subbit-id deadbeef
(Link).
We need a lot of ious
subbit iou sign-many --many 50 --subbit-id deadbeef --amt 1000000 --iou-key iou0 > ious.json
We can then batch sub. 50 subbits is too many. We hit the tx size limit. We can however remove the final 4 entries and submit with 46 sub steps.
subbit tx batch provider0 $(cat ious.json)
(Link).
We can however close with all 50
subbit tx batch consumer0 $(cat close.json)
(Link). This tx is 13.5kB, so there is room for a few more subbits.
We can also settle all 50 simultaneously
subbit tx batch provider0 $(cat ious.json)
(Link). This tx is 12.5kB, so there is room for a more subbits.
Finally the consumer ends the settled subbits
subbit tx end --consumer consumer0
(Link).
Analysing costs
Last post we made an estimate on the number of subbits we could handle simultaneously in a single txs. Embarrassingly, we were quite a bit off. On a re-inspection of our maths, we seem to have double counted some bytes, as well as introduced extra bytes that don’t exist. We’d underestimated the datum and value, but over-estimate other quantities. We corrected our original pessimistic estimate of 22 to 37 subbits per tx. Pessimistic because we’re assuming a native asset, with long subbit-ids.
We had noted the unstaked, ada-only case should accommodate more subbits per tx. In this case, we managed 45 subbits in a tx, with 9 byte amounts, and 7 byte subbit-ids.
Recall that we have no guarantee on uniqueness of a subbit-id
. As long as a
consumer does not re-use a pair (iou-key, subbit-id)
then they are safe from
ious being reused, regardless of how long or short a subbit id is. Non
uniqueness of subbit id does make chain indexing, and tracking L1 state more
awkward but nothing unmanageable.