Skip to content

DUCAT-UNIT/cre-hmac

Repository files navigation

DUCAT Oracle (cre-hmac)

Privacy-preserving BTC/USD price-threshold commitments published to Nostr, powered by Chainlink CRE workflows running as WASM.

The oracle publishes a ladder of pre-committed liquidation thresholds every minute. Wallets read the current snapshot from the relay, derive a commit_hash, and look up the matching ladder event — the secret behind each event becomes revealable only when the threshold is breached.


TL;DR

Two workflow trees:

Dir Purpose Network
hmac/ Production workflow (single binary, single cron) mainnet/staging
hmac-dev/ Dev workflow (single binary, single cron, plus dev-only HTTP adjustment controls) dev (Mutinynet)

This README documents the dev tree. hmac/ is the simpler ancestor.


The unified cycle

CRE recently raised the per-execution HTTP-call cap from 10 to 20, which lets the full publish → ladder → promote cycle run in a single workflow execution. Previously the dev tree was split into three workflows (publisher, ladder worker, promoter) coordinated through Nostr completion markers; that split is gone.

                ┌─────────────────────────────────────────────────┐
                │              dev-unified (one cron)             │
                │             cron: 0 * * * * *                   │
                │                                                 │
                │   1. fetch BTC/USD from Chainlink Data Streams  │
                │   2. apply price adjustment (if active)         │
                │   3. publish kind-10001 (pending snapshot)      │
                │   4. publish kind-30000 ladder (7 chunks)       │
                │   5. publish kind-10000 (current snapshot)      │
                └──────────────────────┬──────────────────────────┘
                                       │
                                       ▼
                ┌─────────────────────────────────────────────────┐
                │              Nostr relay (strfry)               │
                └──────────────────────┬──────────────────────────┘
                                       │
                                       ▼
                ┌─────────────────────────────────────────────────┐
                │   wallets read kind-10000 + kind-30000 lookups  │
                └─────────────────────────────────────────────────┘

Because steps 4 and 5 happen in the same execution, step 5 runs only if step 4 fully succeeded — no completion-marker scheme is needed. A failure anywhere returns an error and the next cron tick re-runs the whole cycle. NIP-33 replacement makes everything idempotent.


HTTP-call accounting

CRE caps each execution at 20 outbound HTTP calls. The unified happy path uses 11:

Step Calls Notes
1. Chainlink price fetch 1 up to unifiedPriceMaxAttempts retries
2. adjustment-control read 1 best-effort, ignored on error
3. kind-10001 publish 1 up to unifiedSnapshotPubMaxAttempts retries
4. ladder chunks 7 866 events / 130 per chunk
5. kind-10000 publish 1 up to unifiedCurrentPubMaxAttempts retries
Total happy path 11 leaves 9 calls of retry headroom

The ladder loop budgets per chunk so every chunk gets at least one attempt even if early chunks burn retries.


Cycle timing

Workflow Cron Fires at What it does
dev-unified 0 * * * * * every minute @ :00 publishes everything for the cycle

CRE workflows take ~10s of warm-up before user code runs. End-to-end a wallet sees a fresh kind-10000 within ~30s of the top of the minute (vs ~45s under the old 3-workflow split).


Nostr event kinds

Kind Replaceable? Read by Written by Purpose
30000 yes (parameterized by #d) wallets, oracle-portal unified cycle One event per commit_hash — the threshold commitment + revealable secret
10001 yes (NIP-33) (none in steady state) unified cycle Intermediate snapshot published before the ladder. Kept as an artifact for debugging cycles that failed before step 5.
10000 yes (NIP-33) wallets, oracle-portal unified cycle The snapshot wallets must always read

A kind-10000 always has its matching kind-30000 ladder on the relay, because the unified cycle only publishes kind-10000 after every chunk of the ladder succeeded.


The commit_hash flow (wallet-side)

wallet                                              oracle
  │                                                    │
  │   read kind-10000 (current snapshot)               │
  │ ────────────────────────────────────────────────►  │
  │                                                    │
  │   {base_price, base_stamp, network, pubkey}        │
  │ ◄────────────────────────────────────────────────  │
  │                                                    │
  │   compute target rate                              │
  │   thold_price = ⌈base_price × 1.35 / rate⌉         │
  │   (rate rounded to 2 decimals)                     │
  │                                                    │
  │   commit_hash = BIP-340 tagged-hash                │
  │     "ducat/price_contract_commit"                  │
  │     of (pubkey ‖ network ‖ base_price              │
  │         ‖ base_stamp ‖ thold_price)                │
  │                                                    │
  │   read kind-30000 #d=<commit_hash>                 │
  │ ────────────────────────────────────────────────►  │
  │                                                    │
  │   {threshold event with revealable secret}         │
  │ ◄────────────────────────────────────────────────  │

Spec (matches core-ts/src/lib/price/util.ts):

const TAG     = 'ducat/price_contract_commit'
const tagHash = sha256(utf8(TAG))
const preimage = concat(
  hexToBytes(oraclePubkey),     // 32 bytes
  utf8(chainNetwork),           // variable
  uint32BE(basePrice),          // 4 bytes
  uint32BE(baseStamp),          // 4 bytes
  uint32BE(tholdPrice),         // 4 bytes
)
const commit_hash = sha256(concat(tagHash, tagHash, preimage)).hex

Both wallet and oracle compute this identically. Mismatch = wallet sees no matching event and is stuck.


Reliability features

In-process atomic promotion

kind-10000 is published in the same execution as the ladder. If any ladder chunk fails, kind-10000 is not published — wallets keep reading the previous cycle's kind-10000, and the next cron tick re-runs everything.

Float-precision normalization

Each rate is normalized to 2 decimals (math.Round(rate * 100) / 100) before computing thold_price and commit_hash. Otherwise float accumulation drifts (e.g. 3.95 → 3.949999999999959), and the wallet's clean 3.95 lookup misses.

NIP-33 replacement

Everything published by this workflow is replaceable. A failed mid-cycle publish is automatically corrected by the next successful cycle — no manual recovery needed.


Repo layout

cre-hmac/
├── README.md                  this file
├── crypto/                    pure-Go crypto lib (HMAC-SHA256, Hash160,
│                              BIP-340 tagged hashes). Testable, non-WASM.
├── datastream/                Chainlink Data Streams report decode +
│                              DON-signature verification (pure Go, testable)
├── shared/                    types and validation shared across workflows
├── wasmtest/                  handler tests with a mock CRE runtime
├── integration/               integration tests against a live relay
├── cre-tester/                diagnostic workflow: fetch a real report and log
│                              the decoded price + recovered DON signer addresses
├── hmac/                      legacy production workflow (being deprecated)
│   ├── handlers.go            createQuote / evaluateQuotes / generateQuotes
│   ├── config.json            workflow config
│   ├── workflow.yaml          cre-cli deploy descriptor
│   └── ...
├── hmac-dev/                  dev workflow (unified) — the successor
│   ├── handlers.go            HTTP trigger handlers + runUnifiedCycle
│   ├── relay.go               strfry-http client (/api/quotes, /api/query)
│   ├── crypto.go              wasm-side wrappers around crypto/
│   ├── price.go               Chainlink Data Streams adapter
│   ├── types.go               kinds, DTOs
│   ├── config.*.json          one config per environment
│   └── workflow.yaml          deploy descriptor
├── secrets.yaml
├── project.yaml
└── go.mod / go.sum

Quick start

# build the WASM binary
cd hmac-dev && GOOS=wasip1 GOARCH=wasm go build -o main.wasm

# run unit tests against the mock runtime
cd ../wasmtest && go test ./...

# integration tests against a real relay
cd ../integration && go test ./...

Deploy

From the repo root:

cre workflow deploy ./hmac-dev -T production-testnet --yes

cre workflow deploy upserts by name — same name = update, new name = create. Workflow IDs are content-addressed (hash of binary + config), so a config-only change still produces a new workflow ID.


Deployments

⚠️ The currently deployed workflows are NOT running this version yet. The security fixes in this revision (Data Streams DON-signature verification, adjustment-control auth, staleness/bounds) are merged in source but have not been deployed to any environment. Re-deploy each target (cre workflow deploy ./hmac-dev -T <target> --yes) and populate the workflow-ID table below before relying on this version. Until then, the live oracle runs the previous build.

Every environment is a target in project.yaml (RPCs, DON family, owner) and a matching block in each tree's workflow.yaml (workflow name, config). The two workflow trees:

  • hmac/ — the original single-cron workflow (config.json). Being deprecated in favour of hmac-dev/ once dev is considered ready.
  • hmac-dev/ — the unified workflow (publish → ladder → promote in one cron), with dev-only HTTP price-adjustment controls. One config per environment.

This is a testnet system. All environments — including the ones named production-testnet, testnet4, and alpha-mainnet — source price from the Chainlink testnet Data Engine (api.testnet-dataengine.chain.link, feed 0x00039289…9722). The -mainnet names are deployment targets, not mainnet price feeds.

Environment matrix (hmac-dev/, the unified workflow)

Target Workflow name Config Relay Rate range / step Cron
local-simulation dev-unified config.dev-unified.json dev-relay 1.35–10.00 / 0.01 0 * * * * *
staging dev-unified config.dev-unified.json dev-relay 1.35–10.00 / 0.01 0 * * * * *
production-testnet dev-unified config.dev-unified.json dev-relay 1.35–10.00 / 0.01 0 * * * * *
testnet4 testnet4-unified config.testnet4-unified.json testnet4-relay 1.35–10.00 / 0.01 0 * * * * *
alpha-mainnet alpha-mainnet config.alpha-mainnet-unified.json relay.alpha 1.35–10.00 / 0.01 0 * * * * *

The legacy hmac/ tree deploys to local-simulation / staging / production-testnet only, as workflow name hmac with config.json (network Mutinynet, relay relay.staging, rate 1.35–5.00, cron 0 */2 * * * *).

Deployed workflow IDs

Workflow IDs are content-addressed hashes of (binary + config), assigned on the on-chain Workflow Registry at deploy time — they are not stored in this repo and change whenever the binary or config changes. Record the current IDs here after each deploy (from the cre workflow deploy output / the Workflow Registry):

Target Workflow name Workflow ID
staging dev-unified <fill in>
production-testnet dev-unified <fill in>
testnet4 testnet4-unified <fill in>
alpha-mainnet alpha-mainnet <fill in>

Deploy a specific environment with:

cre workflow deploy ./hmac-dev -T <target> --yes   # e.g. -T testnet4

Chainlink Data Streams report verification (H1)

The price path verifies the DON signatures on each Data Streams report (datastream/), gated per environment by config:

  • report_signers — the feed's authorized DON signer addresses
  • report_signer_threshold — the f+1 quorum
  • require_report_verification — when true, an unverified report is rejected (fail closed); when false, the price is still decoded at the correct offset but a loud warning is logged each fetch.

The signer set is per feed configDigest and rotates on-chain, so it is not in the report payload. To (re-)derive it for a feed — e.g. when cutting over to a mainnet feed — read the Verifier's ConfigSet event:

VerifierProxy.getVerifier(configDigest) -> Verifier
# then the Verifier's ConfigSet(bytes32 indexed configDigest, address[] signers, uint8 f)

cre-tester/ is a diagnostic workflow that fetches one real report and logs the decoded price plus the recovered signer addresses, runnable via cre workflow simulate cre-tester -T local-simulation — use it to discover/verify the signer set for a feed before enabling require_report_verification.


CRE constraints

Limit Value Where it bites
HTTP calls / execution 20 unified cycle uses 11 happy-path, 9 retry slots remain
HTTP body 200 KB batch publishes are gzipped; full ladder still needs 7 chunks
Workflow name [a-zA-Z0-9_-]{1,64} use _ instead of .
WASM stdlib wasip1 only no real network, no goroutines, no syscalls
Cron syntax 6 fields (sec min h dom mon dow) 0 * * * * * works

Operating the system

Where to look

Question Place to look
Did a workflow execution succeed? Chainlink CRE dashboard → trigger logs
What's on the relay right now? https://dev-relay.ducatprotocol.com/api/query
Is wallet drift OK? https://oracle-portal.ducatprotocol.com/ (30m / 6h / 24h toggle)
Did anything actually liquidate? https://validator.staging.ducatprotocol.com/liq/api/liquidated

Common symptom → cause

  • Drift spike to ~60s → one minute's cycle failed before step 5. Check the trigger log for the failed minute.
  • Wallet says "commit_hash not found" → wallet computing rate with >2 decimals, or wallet read kind-10000 just as it was being replaced. Retry next cycle.

Adding/changing the rate range

If you change rate_min, rate_max, step_size, or liquidation_thold:

  1. Update config.dev-unified.json.
  2. Verify the ladder still fits in 7 chunks (ceil((rate_max - rate_min) / step_size / 130) ≤ 7) so it fits in the 20-call HTTP budget.
  3. Deploy.

Cryptography primitives

  • HMAC-SHA256 — deterministic secret derivation from (client_secret, domain)
  • Hash160RIPEMD160(SHA256(secret)), Bitcoin-compatible
  • BIP-340 Schnorr — Nostr event signatures and tagged-hash commitments
  • DON consensus — Byzantine-fault-tolerant aggregation across Chainlink Data Streams

All in pure Go in crypto/ so they're testable outside WASM.


License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages