Skip to content

Multi-transaction signing for unstaking and validator switches - #588

Closed
mraveux wants to merge 28 commits into
matheo/unstaking-watchtower-basefrom
matheo/unstaking-watchtower
Closed

mraveux wants to merge 28 commits into
matheo/unstaking-watchtower-basefrom
matheo/unstaking-watchtower

Conversation

@mraveux

@mraveux mraveux commented Sep 9, 2026

Copy link
Copy Markdown
Member

Why

Unstaking and switching validators need several transactions signed in one user interaction, because
the later ones can only be broadcast after the first has settled on chain — they are then handed to the
watchtower, which sends them when the deactivation matures. SIGN_TRANSACTION could only ever carry one
transaction, so the Hub had no way to express the request or to drive a Ledger through several consecutive
signatures.

This is the Hub half. The Keyguard half is T404-16; the wallet half is its own branch.

What

Client APIclient/PublicRequestTypes.ts, client/HubApi.ts

  • SignTransactionRequest becomes a union of four shapes, with the existing inline single-transaction
    request kept unchanged as SignTransactionRequestSingle for backward compatibility:
    • standard — a transactions array of TransactionInfo entries or serialized Uint8Arrays
    • switch-validator — exactly two: set-active-stake, update-staker
    • unstaking — exactly three: set-active-stake, retire-stake, remove-stake
  • New TransactionInfo entry type mirrors the Keyguard's, minus the sender: the request-level sender
    is inherited by every entry
    . As in the Keyguard, entries carry no labels; labels stay request-level and
    only apply when there is a single transaction.
  • signTransaction() now resolves to SignedTransaction | SignedTransaction[] — an array exactly when
    more than one transaction was signed.
  • switch-validator deliberately has no validatorAddress: the target is derived from the signed
    update-staker's newDelegation, so what is displayed is what gets signed. SignStakingRequest gains the
    validator address/image and amount fields the wallet needs to render both sides of a switch.

Request parsing — new src/lib/SignTransactionRequestParsing.ts (703 lines)

  • A close port of the Keyguard's request parsing, kept diffable against it check for check, with every
    deviation marked in a comment. This matters because for Ledger accounts there is no Keyguard, so
    this parsing plus the Ledger's own display is the only validation the request gets.
  • Layout constraints are enforced at parse time (transaction kind and order per layout), along with
    sender/recipient, network id, staking data and label checks.
  • Deliberately free of app-graph imports (RpcApi, router, views) so it can be imported standalone by
    unit tests — new tests/unit/SignTransactionRequestParsing.spec.ts, 65 tests over 1020 lines.
  • rawSignTransactionRequest() is the inverse mapping for history state, preserving layout and validator
    metadata across a redirect.

Ledgersrc/views/SignTransactionLedger.vue (largely rewritten), src/components/LedgerUi.vue

  • Arbitrary multi-transaction standard requests, plus the two staking layouts, signed as consecutive Ledger
    interactions.
  • A multi-step signing indicator: a checkmark per completed step, the current step number and its
    instructions, pending steps after it — collapsing to a compact n/total counter above 8 steps.
  • Both validators rendered for a switch, the validator shown on the staking-contract side, zero amounts of
    signalling transactions no longer displayed, and a fix for the network client being unavailable on
    Checkout reload.

ElsewhereSignTransaction.vue / SignStaking.vue / SignTransactionSuccess.vue follow the union
type; RequestParser and RequestTypes carry the layout through; Cashlink now uses cashlink.fee for
funding as well; multi-transaction signing demos added under demos/.

How it was verified

65 new unit tests on the request parsing. QA then ran the ten-flow test plan on T404-15 for both Keyguard
and Ledger (regular send, validator switch, unstaking, instant payout, start staking, add stake, cashlink
creation, swap refund, standard multi-tx signing, checkout):
https://app.qase.io/public/report/379bd6f17c667627377c8950bcb27604cd5e0505

Follow-ups

  • Released as HubApi v1.15.0 on master, after this branch.
  • TransactionInfo.validityStartHeight stays required until the Hub has its own network connection.

Mathéo and others added 28 commits January 18, 2026 17:45
Extend SignTransactionRequest to accept either a single transaction
(backward compatible) or an array of transactions. The return type
of signTransaction now includes SignedTransaction[] for multi-tx flows.
Parse and render multi-transaction signing requests, supporting both
structured TransactionData arrays and raw serialized Uint8Array[]
(used by the wallet's unstaking watchtower flow). Staking transactions
are detected and given special UI treatment with validator info.
Add explicit type casts in CashlinkManage, RefundSwap, and
RefundSwapLedger to handle the now-union SignTransactionRequest type.
Add demo buttons for testing multi-transaction and staking
multi-transaction signing flows.
Widens SignTransactionRequestMulti.layout to 'switch-validator' |
'unstaking', validates the 3-tx shape (set-active-stake → retire-stake →
remove-stake) in the request parser, and forwards the keyguard request
with the new layout from SignTransaction.vue.
The internal type only listed 'switch-validator', but the parser writes
'switch-validator' | 'unstaking' and downstream consumers branch on both.
The cast at the parser site silently masked the mismatch.
Both validatorAddress and fromValidatorAddress drive the keyguard's
from→to card layout and are forwarded with non-null assertions in
SignTransaction.vue. Reject early at parse time so a missing field
surfaces as a clear hub error instead of crashing further down.
raw() round-trips the parsed request into history state for reload. The
multi-tx branches dropped layout, validatorAddress, validatorImageUrl,
fromValidatorAddress, fromValidatorImageUrl, and amount, so a reload mid
sign-flow re-entered the parser without a layout and rendered the standard
single-tx UI for what is actually a switch-validator or unstaking request.
The keyguard's switch-validator layout no longer accepts an amount
field. Stop carrying it through the public type, the parsed type,
the parser propagation, and the keyguard request construction.
Mirrors the keyguard's tightening in commit a0a8025d. The previous check
for the third tx only required senderType=staking + recipientType=basic,
which matched both remove-stake and delete-validator — letting a caller
whose keyPath signs a validator key route a delete-validator through the
unstaking flow. The recipient was also unbound, allowing the unbonded
NIM to be redirected while UI labels stayed benign.

Inspect the parsed sender data of the third tx to require remove-stake,
and bind tx0.sender == tx1.sender == tx2.recipient. The keyguard remains
the security boundary; this just yields a clearer hub-side error.
The Keyguard now reads the validator being switched to from the signed
update-staker transaction rather than from the request, so that what it
displays is what gets signed, and its request type no longer carries
the field. Drop it from the switch-validator request, and drop the cast
on the unstaking request, which the client types now cover.
The Keyguard labels the staking address from this field, since
senderLabel and recipientLabel name the two validators. Carried through
parsing and both raw() round-trips so it survives redirect mode.
Instead of showing the staking contract address and identicon, show the
validator address and validator image, if provided. This matches how
the Wallet and Keyguard represent the account for the staking contract.
Note that this is just decoration and this can not / is not meaningfully
verified by the Hub. However, the user will see all information that is
actually relevant on the Ledger.
Instead of rendering the user address and details as sender, render the old validator,
if provided, as sender for update-staker.
… code improvements and cleanup

The whole foundation of the multi-transaction implementation was not very nice.
The request parsing had many of the issues that were flagged for the Keyguard
in nimiq/keyguard#551, and the internal ParsedSignTransactionRequest type had
grown additional optional properties which then had to be checked explicitly
everywhere. By type, a parsed multi-transaction request was not
distinguishable from a traditional one, only by checking those new optional
properties, which is why the existing Ledger code did not produce a type
error even though multi-transaction signing is not yet implemented there. Some
of the optional properties and flags were additionally unnecessary or could be
solved more nicely. The SIGN_TRANSACTION parsing is therefore adapted from the
Keyguard, the parsing result is shaped such that single and multi-transaction
requests map onto it unconditionally, and the transaction signing code is
cleaned up accordingly. Finally, the (still unpublished) public request types
are tidied up and further small issues fixed.

Here is a complete list of changes:

Public request types (unpublished multi-transaction API):

- Rename TransactionData to TransactionInfo and its extraData to
  recipientData, mirroring the Keyguard's TransactionInfo, and add
  senderData.
- Drop the per-entry senderLabel and recipientLabel, which the Keyguard does
  not support for array entries either.
- Split SignTransactionRequestMulti into per-layout request types for the
  standard, switch-validator and unstaking layouts, such that layout
  specific fields can be required instead of all being optional.
- Type the transactions array as Array<TransactionInfo | Uint8Array>, as
  mixed entries are supported.
- Remove validatorAddress from the switch-validator request; the validator
  being switched to is derived from the signed update-staker newDelegation,
  such that what is displayed is what gets signed.
- Remove senderLabel from the standard layout; the sender is the user's own
  account, which is labeled from the user's account data and must not be
  relabeled by a caller.
- Document that SIGN_TRANSACTION resolves with a single SignedTransaction if
  and only if a single transaction was signed.

Parsing, moved into the new SignTransactionRequestParsing.ts:

- Adopt the Keyguard's thoroughly reviewed parsing as a close, check for
  check diffable port, in a module free of app graph imports such that it
  can be unit tested standalone. RequestParser now only delegates to it.
- Requests with TransactionInfo entries were previously not validated at all
  and not gated by layout, such that an unstaking layout with object entries
  bypassed every check. Both entry formats now run the same checks.
- Detect the entry format per entry, instead of inferring it for all entries
  from the first one.
- Deserialize serialized entries via Transaction.deserialize instead of the
  lenient fromAny, and report deserialization errors as request errors.
- Check the network id of serialized transactions against the configured
  network.
- Reject aggregated values or fees exceeding Number.MAX_SAFE_INTEGER, as
  totals are converted to Number for display.
- Require transactions to be in validity start height order.
- Reject staking transactions that carry a caller provided staker or
  validator signature proof, which transaction.sign() would silently
  overwrite.
- Bind all transactions to the request level sender: transactions with a
  basic or contract sender must be sent from it, and outgoing staking
  transactions must pay out to it. This is the parse time analogue of the
  Keyguard's signer check after unlocking the key, and is what makes
  RpcApi's wallet check meaningful for the funds that are actually spent.
- Fully verify how the switch-validator transactions relate to each other:
  exactly two transactions, same fee paying sender, no contract senders,
  set-active-stake followed by update-staker, newActiveBalance of 0,
  newDelegation set, reactivateAllStake set, and update-staker starting one
  to two epochs after set-active-stake.
- Fully verify how the unstaking transactions relate to each other: exactly
  three transactions, same fee paying sender and staker, no contract
  senders, set-active-stake, retire-stake and remove-stake in order, not
  retiring more than is being paid out, payout to the fee payer and staker,
  no contract payout recipient, no recipient data on remove-stake,
  retire-stake starting one to two epochs after set-active-stake and
  remove-stake one block after retire-stake.
- Validate staking data regardless of the layout, and reject invalid staking
  data instead of ignoring it.
- Validate labels for length and control characters, and only accept them
  for standard layout requests with a single transaction.
- Parse validator image urls as urls with a protocol allowlist.
- Support contract creations via the CONTRACT_CREATION pseudo recipient,
  including the Keyguard's data length checks, and reject combined
  transaction flags.
- Enforce the 64 byte recipient data limit, except for staking recipients.
- Parse addresses via Address.fromAny instead of the Address constructor,
  which converts array likes such as { length: 20 } into an arbitrary
  address instead of rejecting them.
- Remove the ad hoc value and validityStartHeight truthiness checks, which
  rejected legitimate zero values.
- Always export the request in the serialized transactions format in raw(),
  and only values that survive both persistence channels. The
  switch-validator validatorAddress is not exported, as it is re-derived on
  re-parse.

ParsedSignTransactionRequest:

- Replace the parallel representations, i.e. the single transaction
  properties, the ParsedTransactionData[] | PlainTransaction[] union and
  serializedTransactions, with a single unconditional
  transactions: Nimiq.Transaction[]. Single and multi-transaction requests
  are now represented identically.
- Make layout a required enum which drives the layout specific validation,
  and remove the isStakingRequest flag. Parsed multi-transaction requests
  are now distinguishable by type, which is what hid the missing Ledger
  support before.
- Delete ParsedTransactionData, and type the validator addresses and image
  urls as Nimiq.Address and URL instead of strings.

SignTransaction:

- Resolve the signer once from the request level sender, via a wallet lookup
  including contracts as RpcApi does it, instead of per branch and from
  different addresses.
- Build the Keyguard request from a common object and always pass serialized
  transactions, also for single transactions. This removes the conversion of
  parsed transactions back into transaction objects, the duck typing of the
  entry format, the string to account type mapping and the re-serialization
  of plain transactions, and fixes senderData not being forwarded.
- Do not pass labels for standard layout requests with multiple
  transactions, same as in the Keyguard, which displays the transactions'
  own sender and recipient addresses.

SignTransactionLedger:

- Reject multi-transaction and staking layout SIGN_TRANSACTION requests
  instead of rendering a facsimile of the first transaction and dropping all
  but the first result. Ledger support for the layouts follows separately.
- Build the transaction infos for SIGN_TRANSACTION and SIGN_STAKING in a
  single block, including the sender type patch and senderData.
- Resolve with a single result if and only if a single transaction was
  signed; SIGN_STAKING keeps resolving with an array.

Additionally, RefundSwapLedger and the multi-transaction demo are updated for
the new parsed and public request shapes.
…r's own address from the request

A calling app must not be able to relabel the user's own account on the
confirmation screens. This was enforced for the standard layout's sender, but
not for the other places the user's own address is shown.

- Remove recipientLabel from the unstaking request and stakerLabel from the
  switch-validator request; label both from the user's account data instead.
- Drop a recipientLabel for a standard-layout transaction paying out to the
  request sender, i.e. remove-stake and delete-validator, and label that side,
  the recipient, from the account data too.
- SignTransactionLedger: render the transaction's own sender, the staking
  contract for those, so the user's details land on the side the user is on,
  and let the account label overwrite a requested one.
SignTransactionRequestParsing.ts is a close port of the Keyguard's reviewed
SIGN_TRANSACTION parsing, plus a number of Hub specific additions. As the
Keyguard has no tests for its own parsing, these are the only tests covering
these checks anywhere, which is why they pin the rejections and not only the
happy paths.

65 tests across the module's three exports:

- parseSignTransactionRequest, request structure: the legacy single-transaction
  format mapping onto a one-element transactions array, including its extraData,
  the layout validation, and the transactions array itself.
- Serialized and TransactionInfo entries, also mixed within one array:
  deserialization failures, network id, account types, utf8 encoded
  recipientData, the 64 byte data limit and its staking exemption, contract
  creations via the CONTRACT_CREATION pseudo recipient and their data lengths,
  transaction flags, and address parsing.
- The aggregate checks: totals exceeding Number.MAX_SAFE_INTEGER, and the
  validity start height order.
- The binding of all transactions to the request level sender, for basic,
  contract and outgoing staking senders, and the rejection of caller provided
  staker and validator signature proofs.
- Labels: length and control characters, only for standard layout requests with
  a single transaction, and never for the user's own address in any layout.
- The switch-validator and unstaking layouts: how their transactions must relate
  to each other, the rejection of contract senders, the epoch delays, and the
  validator addresses.
- rawSignTransactionRequest: round-trips through the serialized byte format, for
  the legacy format and for all layouts.
- patchLegacyRequestSenderType: applying the WalletStore sender type in place,
  its restriction to the legacy single-transaction format, and the re-derivation
  of a created contract's address.

The layouts' checks that their transactions share the same fee-paying sender and
staker, and that unstaking pays out to that address, are not reachable from the
tests: the request level sender binding, which the Keyguard has no counterpart
for, rejects such requests earlier. They are kept in the parsing as defense in
depth and for diffability with the Keyguard, and are marked as such where the
tests come closest to them. The layouts' contract sender checks, in contrast,
are reachable, as the binding compares the sender address and not its type.

The jsdom test environment does not provide TextEncoder and TextDecoder, which
@nimiq/core's nodejs build requires as globals. The new tests/setup-globals.js,
wired up in jest.config.js, provides them from node's util module. Node's
TextEncoder creates Uint8Arrays in node's realm, which are not instanceof the
test context's Uint8Array, so they are re-created in the test context's realm to
keep instanceof checks in the tested code behaving as they do in a browser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…x requests

Ledger accounts previously rejected all multi-transaction SIGN_TRANSACTION
requests. The switch-validator and unstaking layouts are now handled by
sharing the SIGN_STAKING code path. Standard layout requests with multiple
transactions remain rejected.
…request types

While SIGN_TRANSACTION and SIGN_STAKING operated on parsed Nimiq.Transactions,
CHECKOUT and CREATE_CASHLINK collected individual payment parameters in
mounted() and built their transaction info by hand, which resulted in completely
different collection of the payment info. Variables unused for the requests
operating on Nimiq.Transaction were forced placeholder values, and on the other
hand, the parsed transactions only existed for those requests, with non-null
assertions. The transactions getter now builds Nimiq.Transactions for all request
types, moving the transaction info collection there. Thereby mounted() got much
slimmer and now focused on data to display and side effects.

Resulting or related improvements:

- Sign and send in a streamlined loop, and render the sender, recipient, amount,
  fee and data from the transactions for all request types.
- Give Hub created transactions a placeholder validity start height, and set the
  final height at signing.
- Patch the legacy sender type already in created(), as the getter is a computed
  which the initial render already evaluates, and re-evaluate it via requestRevision
  after the checkout payment options are updated in place, which now als refreshes
  the display.
- Render nothing and reject cleanly when a request cannot be handled, instead
  of dereferencing an undefined $refs entry.
- Apply a requested recipientType and flags to the checkout transaction, which
  the Ledger displays, instead of silently signing a plain transfer.
- Fund Ledger Cashlinks with getFundingDetails' fee, which the transaction was
  always signed with, instead of displaying the claiming fee next to it
- Only render a requested validator image on the card that also renders the
  matching validator address.
- Only accept the internal sender info object on sign-transaction requests.
Support showing a step indicator and instructions for multi-step signing requests in
a similar visual style as we already had for SetupSwapLedger.
…ions

Show the LedgerUi step indicator and instructions for multi-transaction requests.
…kout reload

On reload of a multi-transaction checkout, the transaction recipient might have to be re-fetched
from the checkout callback. Until that is done, the transaction can not be built and the UI stays
empty via the v-if="transactions.length" check. As a result of this, also the network client
component in the UI was unavailable and accessing it threw in mounted(). To circumvent that, the
network is now instantiated separately instead of as part of the template.
@mraveux

mraveux commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Already merged into master; opened to keep the change set on record.

@mraveux mraveux closed this Sep 9, 2026
@mraveux
mraveux deleted the matheo/unstaking-watchtower branch September 9, 2026 13:59
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.

2 participants