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.

—

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions