Skip to content

HTLC lifecycle (create / redeem / refund) signing from a third-party dApp — current API gap #579

Description

@Andjroo111

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:

  1. 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'.
  2. 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:

  1. 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.
  2. 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?
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions