Conversation
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.
…action standard 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.
Member
Author
|
Already merged into master; opened to keep the change set on record. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_TRANSACTIONcould only ever carry onetransaction, 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 API —
client/PublicRequestTypes.ts,client/HubApi.tsSignTransactionRequestbecomes a union of four shapes, with the existing inline single-transactionrequest kept unchanged as
SignTransactionRequestSinglefor backward compatibility:standard— atransactionsarray ofTransactionInfoentries or serializedUint8Arraysswitch-validator— exactly two: set-active-stake, update-stakerunstaking— exactly three: set-active-stake, retire-stake, remove-stakeTransactionInfoentry type mirrors the Keyguard's, minus the sender: the request-levelsenderis 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 toSignedTransaction | SignedTransaction[]— an array exactly whenmore than one transaction was signed.
switch-validatordeliberately has novalidatorAddress: the target is derived from the signedupdate-staker's
newDelegation, so what is displayed is what gets signed.SignStakingRequestgains thevalidator address/image and amount fields the wallet needs to render both sides of a switch.
Request parsing — new
src/lib/SignTransactionRequestParsing.ts(703 lines)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.
sender/recipient, network id, staking data and label checks.
RpcApi, router, views) so it can be imported standalone byunit tests — new
tests/unit/SignTransactionRequestParsing.spec.ts, 65 tests over 1020 lines.rawSignTransactionRequest()is the inverse mapping for history state, preserving layout and validatormetadata across a redirect.
Ledger —
src/views/SignTransactionLedger.vue(largely rewritten),src/components/LedgerUi.vueinteractions.
instructions, pending steps after it — collapsing to a compact
n/totalcounter above 8 steps.signalling transactions no longer displayed, and a fix for the network client being unavailable on
Checkout reload.
Elsewhere —
SignTransaction.vue/SignStaking.vue/SignTransactionSuccess.vuefollow the uniontype;
RequestParserandRequestTypescarry thelayoutthrough;Cashlinknow usescashlink.feeforfunding 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
master, after this branch.TransactionInfo.validityStartHeightstays required until the Hub has its own network connection.