Skip to content

feat: wallet-side capabilities, byte-exactness, and ten packages - #619

Open
jorbuedo wants to merge 1 commit into
IntersectMBO:mainfrom
jorbuedo:upstream-pr
Open

jorbuedo wants to merge 1 commit into
IntersectMBO:mainfrom
jorbuedo:upstream-pr

Conversation

@jorbuedo

@jorbuedo jorbuedo commented Oct 11, 2026 •

Copy link
Copy Markdown

This is the wallet-side work we have been running on top of evolution for several months, rebased onto main as of 4e748e6a and offered in one piece. It is large, so the sections below are ordered by how much they are likely to interest you, and every claim in it is something the suite checks.

Happy to split any section into its own PR, re-shape an API, or drop a part outright — this is the whole of what we have, not a take-it-or-leave-it.

Defects in current main, each with a test

what where why it matters
type-check checks an empty program packages/evolution/package.json tsc --noEmit resolves tsconfig.json, which is "include": [] plus a project reference. --noEmit does not build references, so the script passes on any tree. It reported clean with fourteen real errors staged. Now -p tsconfig.src.json
HeaderMap compares labels by object identity src/cose/Header.ts every labelFromInt(n) builds a fresh Label, so getHeader never found a label that was present and setHeader appended — producing a COSE header map with a duplicate key, both values encoded
NativeScripts.toJSON truncates a uint64 slot src/NativeScripts.ts Number(bigint) on slot: InvalidBefore(2^53) and InvalidBefore(2^53 + 1) both produced { type: "after", slot: 9007199254740992 }, and [Hash.symbol] routed through it, so the two hashed equal. Reachable on read — a script decoded from the chain may carry any uint64
a Koios credential goes on the wire as an object src/sdk/provider/internal/KoiosQuery.ts the body was built from Credential.hash directly, so JSON.stringify wrote {"0":170,"1":170,…} where Koios expects hex. The query could not have worked for any real credential; the tests that covered it put a string in the fixture
writeConstr refuses every constructor tag when minimal encoding is off src/Data.ts on the grounds that the encoder cannot write a tag of 24 or more. It can, and the tree encoder does — so custom, non-minimal threw on every constructor index of 7 and up. Caught by your own Data.encoder.test.ts once the tree encoder could write wide tags
a transfer leaves a programmable input's inline datum behind packages/cip113/src/Cip113.ts not a defect in this repo — a hazard the reference SDK measured and we therefore refuse. Asked to move a CIP-68 (100) reference token, it built the transaction, the ledger accepted it, and the datum came out d87980 where the metadata had been. Ours cannot overwrite a datum, but it could move such an input and emit no datum, which loses the metadata just as permanently. Now refused, naming the input
getByUnit answers 0n for an asset fromUnit just built src/Assets.ts fromUnit accepts policyId.assetName; getByUnit sliced the dotted text at 56. Zero reads as "not here", so the disagreement reached a balance silently
a body key the schema does not model is dropped src/TransactionBody.ts measured: a body with one unmodelled key went in at 1213 bytes and came out at 1209 — a different transaction id, with nothing raised. Now refused at decode, naming the key

Byte-exactness, which is the reason for most of the rest

A wallet signs bytes and a user approves a screen; if those are not the same bytes, the signature covers something else. Two arrival details cannot be recovered from a decoded value: whether an input set came #6.258-tagged or bare (both valid, both accepted), and the width of every integer header.

So a decoded body, witness set, datum and redeemer now replay the bytes they arrived as, at every node — a list assembled from decoded children writes each child as it arrived while the list itself takes the current layout. mode: "canonical" still outranks the record, because canonicalising is a request for a new representation. A value built in-process has no record and is encoded by your layout exactly as before, which Data.encoder.test.ts and InlineDatum.TxCodecOptions.test.ts both pin.

This sits on top of #616/#617/#618 rather than beside them: the Plutus-data writer is yours, and the arrival replay is a check inside it.

fromEncoded on Transaction, TransactionBody, TransactionWitnessSet and AuxiliaryData assembles from parts already encoded, which is what a builder accumulating fields as bytes needs.

The hex boundary, branded the way you brand the rest

BytesFromHex is Schema.Uint8ArrayFromHex plus a length filter, so TransactionHash.toHex() returned a bare string — and from there a transaction hash, a datum hash and a key were interchangeable. Sixteen modules now export a Hex schema built the way PaymentAddress, RewardAddress and DnsName already are (Schema.String + pattern + Schema.brand), and toHex returns it.

The extended key pair is why this is worth more than tidiness: a 64-byte extended public key and a 96-byte extended private key are both "hex", and confusing them is unrecoverable in one direction.

The provider port, and a history tier

The port stopped at the ten reads a transaction builder makes. A wallet cannot work without more, so nine members join it — plus getTip, getTxConfirmations and splitAddressesForTxsAt.

Every addition is optional, including getTip, because this interface is implemented outside this repository and a required member retires those implementations at a minor version. A caller that needs the tip checks for it and reports the absence.

getTxConfirmations counts the containing block as the first confirmation, so a freshly included transaction answers 1 and an unseen one null rather than 0. splitAddressesForTxsAt publishes the batching a bounded backend already applies: getTxsAt refuses an offset across a split body, which leaves a paging caller needing the budget — and a budget restated beside a caller drifts. Absent means unbounded.

The Koios tier was driven against preprod rather than a stubbed fetch, which is what found the credential-hash defect above, the 5120-byte body limit a wallet's signable set passes at ~90 credentials, and chain parameters that were read from the wrong endpoint. A request carrying a credential is refused on a plain http endpoint, and a refusal report carries the body, because 429 means two different things and only the body says which.

Smaller additions in evolution

  • Fees in exact rational arithmetic: one ceiling over total execution units (as txscriptfee computes), a single floor of the tiered reference-script total (as tierRefScriptFee does), bounded by the real 204,800-byte cap.
  • Coin selection: randomImprove is a faithful port of CSL's cip2_random_improve_by, asset-aware, and it throws rather than falling back to a predictable PRNG when WebCrypto is absent.
  • CDDL conformance: pool_owners as the #6.258 set the chain writes, the UnitInterval and Voter codec pairs their siblings had, CostModel reachable from the entry point, MinAda.forOutput, NonnegativeInterval.fromDecimal.
  • CIP-21 canonical order for multiasset and mint, applied by the encoder that owns each map and not as a global option — Plutus map keys keep the order they were given. Narrower than fix: encode mint, multiasset and withdrawal keys in CIP-21 canonical order #584, which was closed.
  • CBORBuilder, an append-as-you-go serializer that encodes through CBOR's own encoder rather than being a second one, plus wide tag support and the splicing helpers.

Ten packages

package what it is
review span-preserving review before signing: the exact bytes of each part, plus unknownKeys and inputsSetTagged — the two facts a Schema.Struct cannot carry
plan wallet-side planning: converges fee, change and collateral; ExUnits as a swappable service (caller-supplied, presets, provider-evaluated)
cips thirteen CIP companions (4, 13, 14, 16, 27, 67, 68, 88, 100/108/119/136, 102, 105, 129, 1855), each driven by an oracle outside this library
cip30 the CIP-30 read surface, byte-exact, deliberately without signTx; getAccountPublicKey is consent-gated
cip113 programmable tokens: the consumer transfer path, registry codecs, deployment records, issuer derivation
cip139 the Universal Query Layer, method names verbatim from cip-0144.md
canonical RFC 8785 JCS and the CIP-116 shape — both exist because something digests their output
dijkstra typed reads of the pre-fork (pv12) body fields, clearly labelled as draft-grounded
hardware-cardano Ledger and Trezor sign requests from a reviewed body; anything unrepresentable is refused by name
backup-keys a password-encrypted envelope for moving a wallet secret (Argon2id + XChaCha20-Poly1305); not a ratified CIP and says so

Each has a README, @since/@category on its public symbols, and a changeset: changeset status
bumps eleven packages minor (the ten above plus evolution), with aiken-uplc, devnet and
scalus-uplc as dependency patches.

One breaking change

TransactionOutput carried a second, parallel output model — a ShelleyTransactionOutput / BabbageTransactionOutput union with address: AddressEras, amount: Value — while TxOut carried the one TransactionBody, the builders and the sdk use. Both were public under names that gave no hint which was which, and they rejected each other: an output built with the first could not be placed in a body, and the error read Expected TransactionOutput, actual [toString threw] about a value that was a TransactionOutput, the other one.

TransactionOutput now re-exports TxOut. One case in InlineDatum.TxCodecOptions.test.ts covered the removed model specifically; with one model it is the TxOut case written twice, so it is dropped. If you would rather keep both models, say so and we will restore the module and keep our side on TxOut.

TxOut also gains an encodingFormat field: Conway accepts both the Shelley array and the Babbage map, they are different bytes, and a builder that cannot say which it means inherits whichever the legacy rule picks — which changes with whether an inline datum happens to be present.

Validation

  • Every gate ci.yml runs, on this branch: turbo lint 14/14, turbo type-check 15/15,
    turbo check:dts 16/16, turbo build 15/15 (packages), turbo docgen 1/1, changeset status
    clean. ⚠️ docs:build (the Next.js site) fails here — identically on unmodified main, so it is
    this machine's node_modules, not the change.
  • packages/evolution/docs is regenerated (191 modules updated, 6 added, 1 removed), since this
    changes the public surface and that output is committed in your tree.
  • 2,731 tests passed, 84 skipped across 11 packages (evolution alone: 2,131 passed / 75 skipped). The skips are cip113 harnesses that need a local blueprint clone and evolution-devnet, which needs Docker.
  • Hardware pass on a real Ledger and Trezor; three preprod sends verified on chain.
  • Driven in production in a mobile wallet, which is where most of the defects above were found.

⚠️ One thing to know about the test gate: packages/evolution's tests are not type-checked. type-check names only tsconfig.src.json, and tsc -b tsconfig.test.json aborts on TS5101 (deprecated baseUrl) before reporting. Silencing that shows 147 diagnostics across 24 files on current main — none of them ours, we fixed the seven that were. Worth its own PR, and we are happy to send it.

Two things we had to decide, and would rather you decided

TSchema.ts is excluded from docgen.json. docgen documents a name and cannot follow
export { ArrayCombinator as Array }, so it reports Missing Array documentation. The alias exists
because a module-scope binding called Array captures the one Babel's rest-parameter prologue
emits
— for (var n = arguments.length, t = new Array(n), …) — and in Union(...members) that
built a schema object instead of an array: members.map is not a function under Metro in Node,
TypeError: Function is not a constructor on Hermes, and on React Native it took the whole app
down. It is invisible to node, bun and vitest, which run the native prologue. The module still
has rest-parameter functions, so restoring the const restores the crash. Excluding it costs one
module's generated page; if you would rather have the page, the fix belongs in docgen's alias
handling.

Two @example blocks needed imports. docgen type-checks examples standalone, and
NonnegativeInterval.fromDecimal and RequestBudget.configure named their module without
importing it. Fixed in place — worth knowing that an example without an import fails the docgen
gate rather than being skipped.

Suggested reading order

  1. packages/evolution/package.json — the one-line type-check fix.
  2. src/cose/Header.ts, src/NativeScripts.ts, src/Assets.ts — the small defects.
  3. src/TransactionBody.ts, src/Transaction.ts, src/Data.ts — byte-exactness.
  4. src/sdk/provider/Provider.ts — the port, and whether the optional-member convention is what you want.
  5. packages/review, then packages/plan — the two packages everything else rests on.

@jorbuedo jorbuedo changed the title feat: wallet-side capabilities, byte-exactness, and twelve packages feat: wallet-side capabilities, byte-exactness, and ten packages Oct 11, 2026
@jorbuedo
jorbuedo force-pushed the upstream-pr branch 2 times, most recently from e545d3d to c780f1b Compare October 11, 2026 15:23
A wallet needs three things this SDK did not have: the bytes a transaction
arrived as, so a signature covers what was displayed; reads beyond the ten a
transaction builder makes; and the standards layers around a transaction that
every wallet otherwise reimplements.

In `packages/evolution`:

- **Byte-exactness.** A decoded body, witness set, datum and redeemer replay the
  bytes they arrived as, scoped to what the schema cannot carry: the `IntersectMBO#6.258`
  input-set tag and integer header widths. `mode: "canonical"` still outranks the
  record. A body key the schema does not model is refused at decode rather than
  dropped, which changed the transaction id silently.
- **The hex boundary is branded and validated.** Sixteen modules export a `Hex`
  schema built the way `PaymentAddress` already is, so a transaction hash and a
  datum hash stop being interchangeable once they leave the typed domain.
- **The provider port grows a history tier**, every member optional: what has
  happened at these addresses, which addresses a stake account has used, the
  bytes of a transaction, how deep it is, and the batches `getTxsAt` can serve.
- **Fees, coin selection, CDDL conformance, COSE, native scripts**: exact Conway
  fee arithmetic, random-improve as CSL computes it, pool owners as a tagged set,
  a structurally-keyed COSE header map, and `NativeScripts.fromJSON`.
- **Defects fixed**: `type-check` checked an empty program; `getHeader` never
  found a label that was present; `toJSON` truncated a uint64 slot; a Koios
  credential went on the wire as `{"0":170,…}`; `writeConstr` refused every
  constructor tag under a non-minimal option set; `getByUnit` answered `0n` for
  an asset `fromUnit` had just built.

Ten packages: `review` (span-preserving review before signing), `plan`
(wallet-side planning), `cips`, `cip30`, `cip113`, `cip139`, `canonical`,
`dijkstra`, `hardware-cardano`, `backup-keys`.

⚠️ One breaking change: `TransactionOutput` was a second, parallel output model
that the builders and `TransactionBody` rejected. It now re-exports `TxOut`,
which is the model they use.

Evidence: 2,731 tests passed / 84 skipped across 11 packages; `turbo lint` 14/14;
          `turbo type-check` 15/15; `changeset status` clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant