Hi team — following up on nimiq/wallet#293 (HTLC SDK methods for Nimiq Pay mini-apps), filing this in the Hub repo because the gap is Hub-specific and current. I'm shipping a non-Oasis HTLC dApp on Nimiq mainnet (hash.market — an HTLC-backed peer-to-peer prediction market) and ran into a hard upstream block on three connected lifecycle operations. Posting concrete repro + code references so it's actionable rather than a vague "Hub doesn't support X."
Context. Phase 1 of the project drove the full HTLC lifecycle (create → redeem → refund) on mainnet from a CLI built on @nimiq/core v2.5.0 — hand-built recipient_data, RegularTransfer, and TimeoutResolve proofs (ref: primitives/transaction/src/account/htlc_contract.rs). Wire format byte-verified via HashedTimeLockedContract.dataToPlain() / proofToPlain(). Four mainnet txs included on first attempt. Wire format works.
The block hits when porting to a browser dApp where keys live in Hub.
Block 1 — HTLC creation via SIGN_TRANSACTION is rejected by both layers.
The natural shape is:
hub.signTransaction({
appName, sender, recipient: <computed contract address>,
recipientType: AccountType.HTLC, // 2
flags: TransactionFlag.ContractCreation, // 1
extraData: <82 bytes of creation_data>,
value, fee, validityStartHeight,
});
What actually happens:
- Hub
RequestParser.ts parses recipient via Nimiq.Address.fromString(...) (src/lib/RequestParser.ts, inside the RequestType.SIGN_TRANSACTION branch). No special-case for the magic string 'CONTRACT_CREATION'.
- Keyguard
RequestParser.js then validates: when flags === ContractCreation, recipient MUST equal the literal string 'CONTRACT_CREATION', otherwise it throws (src/lib/RequestParser.js, parseTransaction()):
if (flags === Nimiq.TransactionFlag.ContractCreation && recipient !== 'CONTRACT_CREATION') {
throw new Errors.InvalidRequestError(
'Transaction recipient must be "CONTRACT_CREATION" when creating contracts',
);
}
This is a catch-22. Pass the computed contract address → Keyguard rejects with the error above. Pass the literal string 'CONTRACT_CREATION' → Hub's parser fails on Nimiq.Address.fromString('CONTRACT_CREATION') before reaching Keyguard. Hub silently closes / redirects back without UI.
Internal Hub code paths do construct recipient: 'CONTRACT_CREATION' for Keyguard — SETUP_SWAP's NIM funding leg is the proof that the underlying mechanism works. It's the public SIGN_TRANSACTION entry point that doesn't surface it.
Block 2 — RegularTransfer redemption.
Sender = HTLC contract, recipient = basic account, proof = [discriminant, hash_depth, hash_root, preimage, signature_proof]. The signature covers tx.serializeContent() only — the proof bytes themselves aren't signed. So the primitive needed is essentially "sign tx-content bytes with the recipient's keypair, then stitch the proof together."
SETUP_SWAP does this internally for its redeem leg, but the public SIGN_TRANSACTION request doesn't accept the HTLC sender shape (nor would it know how to build the proof). SIGN_MESSAGE prepends "Nimiq Signed Message:\n", so it can't substitute.
Block 3 — TimeoutResolve refund.
Same primitive as redemption, simpler proof ([discriminant, signature_proof]). Same gap.
Three possible paths I see:
-
(A) Same-asset SETUP_SWAP. Pass fund = NimiqHtlcCreationInstructions, redeem = NimiqHtlcSettlementInstructions with both legs type: 'NIM', even though there's no real swap. Hub already signs all three lifecycle txs upfront in this flow and returns redeem + refund as pre-signed templates. Loose semantic fit but might "just work"?
-
(B) Wait for nimiq/wallet#293. When HTLC SDK methods land in @nimiq/mini-app-sdk, Pay mini-apps get this directly. If @nimiq/core v2.6+ exposes them too, full in-browser signing without Hub becomes possible. Long-term answer might be "not via Hub at all."
-
(C) New Hub request type(s). A SIGN_HTLC_CREATE / SIGN_HTLC_REDEEM / SIGN_HTLC_REFUND trio (or one SIGN_HTLC_LIFECYCLE with a discriminant). Wraps the existing internal primitives. Additive — wouldn't break anything. Would also unblock escrow, freelance-milestone, ticket-resale, and domain-transfer dApps that hit the same wall.
Workaround we'll ship in the meantime.
Until one of (A)/(B)/(C) lands, we plan to derive an ephemeral signing key per market+side from a SIGN_MESSAGE signature on the user's main address (deterministic + recoverable + no custody). The user signs once via Hub, we derive the ephemeral key, the user funds the ephemeral address with a normal SIGN_TRANSACTION, then we sign HTLC creation / redemption / refund locally using the ephemeral key. Real on-chain HTLCs, no custody, but two Hub popups + one extra tx + ~30s confirmation wait per bet. We'll publish this pattern when we ship — the next non-Oasis HTLC dApp on Nimiq is going to hit this same wall.
A few questions for the team:
- Is (A) the intended-but-undocumented pattern today? Does Hub accept
fund and redeem of the same type: 'NIM' against the same hash root? If yes, we'll integrate and document.
- Is (B) the right mental model long-term? That this surface eventually moves out of Hub into
@nimiq/core direct usage once SDK methods land?
- Would you take a community PR for (C)? Internal primitive already exists; it's a public API surface question. Happy to draft against the architecture you'd want.
For context on the use case: each YES/NO position in hash.market locks in its own HTLC, multisig oracle reveals the preimage at resolution. Phase 1 mainnet validation and Phase 2 architecture are public.
Thanks for Hub — it's load-bearing for getting more HTLC-native dApps shipping on Nimiq.
—
Hi team — following up on
nimiq/wallet#293(HTLC SDK methods for Nimiq Pay mini-apps), filing this in the Hub repo because the gap is Hub-specific and current. I'm shipping a non-Oasis HTLC dApp on Nimiq mainnet (hash.market— an HTLC-backed peer-to-peer prediction market) and ran into a hard upstream block on three connected lifecycle operations. Posting concrete repro + code references so it's actionable rather than a vague "Hub doesn't support X."Context. Phase 1 of the project drove the full HTLC lifecycle (create → redeem → refund) on mainnet from a CLI built on
@nimiq/corev2.5.0 — hand-builtrecipient_data,RegularTransfer, andTimeoutResolveproofs (ref:primitives/transaction/src/account/htlc_contract.rs). Wire format byte-verified viaHashedTimeLockedContract.dataToPlain()/proofToPlain(). Four mainnet txs included on first attempt. Wire format works.The block hits when porting to a browser dApp where keys live in Hub.
Block 1 — HTLC creation via
SIGN_TRANSACTIONis rejected by both layers.The natural shape is:
What actually happens:
RequestParser.tsparsesrecipientviaNimiq.Address.fromString(...)(src/lib/RequestParser.ts, inside theRequestType.SIGN_TRANSACTIONbranch). No special-case for the magic string'CONTRACT_CREATION'.RequestParser.jsthen validates: whenflags === ContractCreation,recipientMUST equal the literal string'CONTRACT_CREATION', otherwise it throws (src/lib/RequestParser.js,parseTransaction()):This is a catch-22. Pass the computed contract address → Keyguard rejects with the error above. Pass the literal string
'CONTRACT_CREATION'→ Hub's parser fails onNimiq.Address.fromString('CONTRACT_CREATION')before reaching Keyguard. Hub silently closes / redirects back without UI.Internal Hub code paths do construct
recipient: 'CONTRACT_CREATION'for Keyguard —SETUP_SWAP's NIM funding leg is the proof that the underlying mechanism works. It's the publicSIGN_TRANSACTIONentry point that doesn't surface it.Block 2 —
RegularTransferredemption.Sender = HTLC contract, recipient = basic account, proof =
[discriminant, hash_depth, hash_root, preimage, signature_proof]. The signature coverstx.serializeContent()only — the proof bytes themselves aren't signed. So the primitive needed is essentially "sign tx-content bytes with the recipient's keypair, then stitch the proof together."SETUP_SWAPdoes this internally for its redeem leg, but the publicSIGN_TRANSACTIONrequest doesn't accept the HTLC sender shape (nor would it know how to build the proof).SIGN_MESSAGEprepends"Nimiq Signed Message:\n", so it can't substitute.Block 3 —
TimeoutResolverefund.Same primitive as redemption, simpler proof (
[discriminant, signature_proof]). Same gap.Three possible paths I see:
(A) Same-asset
SETUP_SWAP. Passfund = NimiqHtlcCreationInstructions, redeem = NimiqHtlcSettlementInstructionswith both legstype: 'NIM', even though there's no real swap. Hub already signs all three lifecycle txs upfront in this flow and returns redeem + refund as pre-signed templates. Loose semantic fit but might "just work"?(B) Wait for
nimiq/wallet#293. When HTLC SDK methods land in@nimiq/mini-app-sdk, Pay mini-apps get this directly. If@nimiq/corev2.6+ exposes them too, full in-browser signing without Hub becomes possible. Long-term answer might be "not via Hub at all."(C) New Hub request type(s). A
SIGN_HTLC_CREATE/SIGN_HTLC_REDEEM/SIGN_HTLC_REFUNDtrio (or oneSIGN_HTLC_LIFECYCLEwith a discriminant). Wraps the existing internal primitives. Additive — wouldn't break anything. Would also unblock escrow, freelance-milestone, ticket-resale, and domain-transfer dApps that hit the same wall.Workaround we'll ship in the meantime.
Until one of (A)/(B)/(C) lands, we plan to derive an ephemeral signing key per market+side from a
SIGN_MESSAGEsignature on the user's main address (deterministic + recoverable + no custody). The user signs once via Hub, we derive the ephemeral key, the user funds the ephemeral address with a normalSIGN_TRANSACTION, then we sign HTLC creation / redemption / refund locally using the ephemeral key. Real on-chain HTLCs, no custody, but two Hub popups + one extra tx + ~30s confirmation wait per bet. We'll publish this pattern when we ship — the next non-Oasis HTLC dApp on Nimiq is going to hit this same wall.A few questions for the team:
fundandredeemof the sametype: 'NIM'against the same hash root? If yes, we'll integrate and document.@nimiq/coredirect usage once SDK methods land?For context on the use case: each YES/NO position in
hash.marketlocks in its own HTLC, multisig oracle reveals the preimage at resolution. Phase 1 mainnet validation and Phase 2 architecture are public.Thanks for Hub — it's load-bearing for getting more HTLC-native dApps shipping on Nimiq.
—