Skip to content

Abra captured — Superteam agentic engineering grant application#136

Open
arbuthnot-eth wants to merge 48 commits into
masterfrom
abra/superteam-grant
Open

Abra captured — Superteam agentic engineering grant application#136
arbuthnot-eth wants to merge 48 commits into
masterfrom
abra/superteam-grant

Conversation

@arbuthnot-eth

Copy link
Copy Markdown
Owner

Summary

Abra Pokemon line for the Superteam agentic engineering grant application. Uses this session's work as concrete evidence of agentic engineering capability.

Branch scaffolded by subagent during the devnet/nursery wrap-up swarm. Contents:

  • docs/pokemon/abra.md — Abra → Kadabra → Alakazam evolution tracker
  • docs/pokemon/abra/session-evidence.md — 9-commit Registeel/Magneton/Porygon wave with hashes, file paths, dollar amounts
  • docs/pokemon/abra/application-draft.md — concrete technical grant application citing:
    • WASM-in-DO feasibility spike ( b449dd3 Registeel Toxic Spikes) that falsified two memory-documented IKA SDK blockers via research + empirical proof
    • $227.22 sol@ultron sweep ( 5898697 Registeel Hyper Beam) on-chain verified end-to-end
    • IKA dWallet provisioning across ed25519 + secp256k1 curves via browser-driven DKG with server-side deterministic seed
    • Multi-subagent parallel execution pattern (bundle diet scoping + Magneton refactor + grant scaffold all launched async)
  • docs/pokemon/abra/drive-upload-instructions.md — Drive folder manifest for evaluators

Issues

Evolution path

  • Abra (Lv 1-15) — this PR: draft + evidence + manifest ✓
  • Kadabra (Lv 16-35) — application submitted via solana.new
  • Alakazam (Lv 36+) — grant funded, put to work on Increment C of UltronSigningAgent + juno indexer

Test plan

  • Branch exists on origin with 4 commits
  • All evidence files present and readable
  • 3 tracking issues opened
  • Human reviews draft before submission
  • Application submitted via solana.new Claude sesh
  • Drive folder assembled per manifest

Leave open for human review — do not auto-merge.

Separate wrangler.devnet.jsonc deploys as dotski-devnet worker with
testnet Sui RPC/GraphQL, devnet Solana RPC, pre-alpha Encrypt gRPC,
and Mysten's free devnet zkLogin prover.

Same 9 DO bindings + migrations as mainnet — full parity staging.

package.json adds dev:devnet / deploy:devnet scripts.

First move toward Bagon → Shelgon → Salamence (#125).
Browser-compatible TypeScript client for dWallet Encrypt pre-alpha.
Routes all calls through CF Worker proxy at /api/encrypt/* since
browsers can't speak native gRPC (HTTP/2).

Provides:
- EncryptClient — createInput, requestDecryption, getCiphertext, getNetworkKey
- FheType enum (Bool, Uint4/8/16/64) matching protocol discriminants
- EUint64 / EBool typed ciphertext handles
- encryptBalance / buildTransferInputs — PC-Token helpers for iUSD
- decodeValue — base64 → bigint/bool for decryption responses

PRE-ALPHA: no real encryption yet, plaintext on-chain, data wiped
periodically. Pinned to devnet program 4ebfzWdK...ND8.

First move toward Eevee → Vaporeon/Jolteon/Espeon/Umbreon (#124).
Complete zkLogin module with ephemeral keypair generation, OAuth
redirects (Google implicit + Apple form_post), HKDF salt derivation
from device fingerprint (no external salt server), ZK proof fetching
via CF Worker proxy, signature assembly, and Wallet Standard
registration.

Hybrid prover strategy:
- devnet/testnet: vampire Mysten's free prover
- mainnet: self-hosted Docker prover behind /api/zklogin/prove

No JSON-RPC — epoch queries use SuiGraphQLClient. tx execution is
the documented exception (sui_executeTransactionBlock via PublicNode).

Ephemeral keys live in sessionStorage only. Proofs cached encrypt
with device fingerprint via PBKDF2+AES-GCM, same pattern as
waap-proof.ts but independent module.

First move toward Zoroark evolution (#123).
TS requires variables field on GraphQLQueryOptions even when empty.
…xies

zklogin-proxy.ts: Hono sub-app forwarding zkLogin proof requests to
upstream prover (default: Mysten's free devnet prover). Validates
request shape, caches proofs 60s by SHA-256 of canonical body to
absorb retries. Mainnet will switch to self-hosted Docker — vampire
mode for devnet/testnet.

encrypt-proxy.ts: Hono sub-app for dWallet Encrypt pre-alpha. Stub
mode — CF Workers can't speak HTTP/2 and Encrypt has no gRPC-Web
gateway confirmed yet. Returns realistic stub responses with
X-Encrypt-Proxy-Mode: stub header so browser flow is exercisable.
Wire format scaffolding left in place for real gRPC-Web swap later.

index.ts: mounts both at /api/zklogin/* and /api/encrypt/*.
Env interface gets ZKLOGIN_PROVER_URL + ENCRYPT_GRPC_URL optional
fields matching wrangler.devnet.jsonc vars.

Progress on Zoroark (#123) + Eevee (#124).
Cross-review (#10) caught real bugs:

1. **Ed25519 round-trip** (critical): getSecretKey() returns Bech32,
   stop double-encoding via TextEncoder/base64. Store the Bech32 string
   directly, fromSecretKey accepts it.

2. **JWT nonce validation** (high): completeZkLogin() now checks that
   the JWT's nonce claim matches the stored ephemeral session nonce
   before calling the prover. Rejects replay/injection in 0ms instead
   of 3s at the upstream. decodeJwt() from @mysten/sui/zklogin strips
   nonce, so added extractJwtNonce() that parses the payload directly.

3. **Salt derivation** (high): REMOVED fingerprint dependency.
   Deriving salt from FingerprintJS visitorId meant browser updates,
   privacy mode, or cleared data would silently re-map users to new
   zkLogin addresses with no recovery. Now deterministic from JWT
   claims alone: HKDF(ikm=sub, salt=iss|aud, info=ski-zklogin-salt-v1).
   Same OAuth account → same Sui address on every device, forever.

4. **Prover URL leak** (medium): /api/zklogin/health no longer returns
   the upstream prover URL. Mainnet self-hosted prover stays private.

5. **128-bit comment fix** (cosmetic): the real zkLogin bound is the
   BN254 field element (< 2^254), not < 2^128. 128 bits is fine and
   conservative — comment corrected.
Illusion: zkLogin lazy-loads at boot alongside WaaP. Hostname
detection switches graphqlUrl to testnet on dotski-devnet.*.workers.dev
and mainnet everywhere else. Static zkLogin placeholder wallet added
to getSuiWallets() so the modal roster shows a zkLogin row before the
module finishes loading; clicking triggers the real registration.

Swift: src/encrypt-demo.ts + public/encrypt-demo.html — self-contained
smoke test for the full browser → /api/encrypt/* → stub round trip.
Three steps (getNetworkKey, encryptBalance, buildTransferInputs),
per-step run + timing + error display, BigInt-safe JSON, PRE-ALPHA
warning banner. Build script emits public/dist/encrypt-demo.js.

Both Pokemon are now exercisable end-to-end on devnet.
Extrasensory: zkLogin session expiry + silent refresh
- ZkLoginEpochExpiredError class with async .recover() re-trigger
- assertEpochNotExpired() called before every signTransaction/signPersonalMessage
- Dispatches ski:zklogin-expired event for UI coupling
- getZkLoginSessionHealth() returns {valid, epochsRemaining, shouldRefresh}
- tryRefreshZkLogin() silent re-proof using cached JWT + ephemeral session
  (reuses nonce binding, avoids full OAuth when JWT is still valid)

Protect2 (reviewer #2 findings):
1. extractJwtNonce padding bug — was '==='.slice((len+3)%4), fails on
   ~25% of JWT payload lengths with uncaught DOMException. Fixed to
   '===='.slice(b64.length % 4) canonical form.
2. Devnet hostname detection missed localhost/127.0.0.1, meaning
   local wrangler dev pointed at mainnet GraphQL while the worker
   served testnet. Added both to isDevnet check.

Vaporeon (Eevee evolution): src/client/encrypt-pc-token.ts
- buildConfidentialTransferTx({from, to, amount, mint?}) returns plain
  instruction object (no @solana/web3.js dep yet) with ciphertext ID
  from buildTransferInputs
- detectEncryptMode() — hostname OR network key marker probe
- estimateConfidentialTransferCost() placeholder (0.00005 SOL, 0 USDC)
- DEFAULT_IUSD_MINT placeholder until Solana mint ceremony runs
- Shape matches what the future PC-Token program will consume
Two HIGH-severity bugs caught by third cross-review:

1. **extractJwtNonce padding STILL WRONG** (high, 95% confidence):
   The Protect2 fix used '===='.slice(b64.length % 4), which:
   - residue 0 → adds 4 pads (should be 0) — BREAKS ALL residue-0 JWTs
   - residue 2 → adds 2 pads (correct)
   - residue 3 → adds 1 pad (correct)
   Many Google JWT payloads are length-multiple-of-4, meaning nonce
   validation would have silently returned null and blocked sign-in.
   Fixed to canonical: (4 - (b64.length % 4)) % 4.

2. **detectEncryptMode silent degradation** (high, 82% confidence):
   catch block returned 'stub' on any network error. Live users
   hitting a transient failure would be told they're in stub mode,
   undermining the live/stub gate. Added 'unknown' as a third
   EncryptMode; catch now returns 'unknown' so callers gating real
   on-chain actions can hard-stop instead of silently degrading.
   buildConfidentialTransferTx result type updated to EncryptMode
   union and 'unknown' mode gets its own note.
Agility (Zoroark): provider picker UI in zkLogin wallet
- createZkLoginWallet standard:connect now calls showZkLoginPicker()
  instead of hardcoded Google redirect
- New exports: showZkLoginPicker() returns Promise<OAuthProvider|null>,
  signInWithGoogle(), signInWithApple()
- Pure DOM modal (id ski-zklogin-picker), keyboard accessible (Esc
  cancels, auto-focuses Google button), click-outside-to-cancel
- Matches .SKI aesthetic: monospace, green/cyan on black, backdrop blur
- Cleans up stale modals before showing a new one

Bite (Eevee): SuiNS works on testnet when deployed to dotski-devnet
- getSuinsNetwork() helper reads hostname at call time
  (dotski-devnet.*.workers.dev, localhost, 127.0.0.1 → testnet; else mainnet)
- suinsCfg() returns mainPackage[getSuinsNetwork()]
- Replaced 49 hardcoded mainPackage.mainnet refs with suinsCfg()
- Replaced 10 hardcoded network:'mainnet' in SuinsClient constructors
- WaaP bypass logic untouched; no exports renamed
- Mainnet behavior unchanged

Caveat: iUSD package IDs, DeepBook pools, Shade, Roster, XAUM/XAGM
constants are still mainnet-hardcoded. Registration-only paths
(buildRegisterSplashNsTx) now work on testnet; iUSD-involved PTBs
will fail until those constants get similar network-aware config.
Jolteon (Eevee): PC-Swap confidential AMM primitive
- buildConfidentialSwapTx({user, in/outMint, amountIn, minAmountOut, poolId?})
- Detects mode first; UNKNOWN mode returns empty with hard-stop note
- Double encryptBalance call for amountIn + minAmountOut ciphertexts
- ConfidentialSwapInstruction shape: [user, inputMint, outputMint, pool]
- estimateConfidentialSwapCost placeholder, DEFAULT_PCSWAP_POOL constant

Ember (Bagon): testnet Seal key servers for Thunder
- SEAL_SERVERS_MAINNET (unchanged) + SEAL_SERVERS_TESTNET
- pickSealServers() hostname-gated selection
- Testnet servers hand-written from Seal registry (SDK v1.1.1 has no
  testnet defaults). Third server marked TODO verify.
- Only thunder-stack.ts uses Seal client-side; server DOs unaffected

Protect4 (reviewer #4 findings):
1. **Picker cancel hang (critical)**: showZkLoginPicker returning null
   led to empty-accounts return from standard:connect, which left
   wallet.ts's connect() race waiting on the 5-minute timeout. Now
   throws 'UserRejectedRequest' so the race bails out immediately.
2. **NS Pyth feed wrong on testnet (high)**: @mysten/suins testnet
   constants put the HFT feed in coins.NS.feed. Using it as the NS
   price oracle produces wildly incorrect swap rates. Added
   requireNsFeed() guard — throws if not mainnet or feed empty.
   All 4 call sites in suins.ts now use requireNsFeed(). NS-pay
   PTBs are explicitly mainnet-only until testnet feed lands.
…Seal fixes

Espeon (Eevee): cross-chain Prism primitive
- src/client/cross-chain-prism.ts
- buildCrossChainPrism({suiSender, solRecipient, amount, mint?, message?})
- Wraps Solana Encrypt ciphertext + Sui Seal blob + IKA dWallet cap ref
- Delegates Solana side to buildConfidentialTransferTx
- Inherits mode ('unknown' → hard stop, no Seal blob emitted)
- Seal side is SEAL_STUB_<hex(json)> — obviously fake, reversible by design
- dwalletCapRef null with TODO(ika) pointing at checkExistingDWallets
- No @mysten/seal import, no thunder-stack dep — isolated primitive

Crunch (Bagon): IKA network-aware
- New export getIkaNetwork() mirrors getSuinsNetwork() hostname check
- getClient() + getLocalJsonRpc() pick config by current network
- getProtocolPublicParametersDirect() throws loudly on testnet:
  'IKA testnet not yet supported — mainnet only'
- IKA_ENC_KEY table IDs are mainnet-only; testnet needs separate wiring
  once testnet IKA network is populated with DKG TableVec objects

Protect5 (reviewer #5 findings):
1. **Malformed Seal testnet server 3 (critical)**: hand-coded object ID
   was 65 hex chars not 64. Dropped to 2-server config, 2-of-2 threshold
   until a verified third is added.
2. **pickSealServers hostname mismatch (high)**: was endsWith('.devnet.workers.dev')
   which split-brained against getSuinsNetwork's
   (startsWith('dotski-devnet.') && endsWith('.workers.dev')).
   Now mirrors the exact condition so Seal and SuiNS can't diverge.
Three-voter consensus at wave 5 tail (product/risk/loop) unanimously
recommended stopping the feature wave to let a human review the 14
commits before anything reaches mainnet.

This commit lands the two terminal moves the voters asked for:

1. src/network-detection.test.ts — 33 unit tests (bun test) covering
   getSuinsNetwork + getIkaNetwork against every hostname shape we
   care about (prod domains, devnet workers, SSR/undefined, ambiguous
   *-devnet-* variants that must fail-closed to mainnet, consistency
   check between the two helpers). Voter 2's ask — the #1 revenue
   path (SuiNS registration, 49 call sites, runtime hostname routing)
   now has direct regression coverage.

2. docs/nursery-status.md — exhaustive snapshot: what's exercisable,
   what's stubbed by design, what's externally blocked. Lists all 12
   bugs the 5 review rounds caught, plus the one test that didn't
   run (real-browser round-trip). Voters 1+3's ask.

Also filed four tracking issues for the external blockers:
- #126 self-hosted ZK prover for mainnet Zoroark
- #127 IKA testnet DKG TableVec IDs for Bagon → Salamence
- #128 real Google + Apple OAuth client IDs for Zoroark
- #129 Encrypt Alpha 1 gRPC-Web gateway for Eevee

Loop is paused. Signal to restart: any one blocker resolves, or a
real user surfaces a wave 1-3 bug.
The devnet worker serves the same browser bundle from
dotski-devnet.imbibed.workers.dev, but the rpc.ts singletons were
hardcoded to mainnet URLs — a testnet PTB built through a mainnet
gRPC/GraphQL client resolves the wrong object versions and fails.

  - src/network.ts: single source of truth for hostname → network
  - src/rpc.ts: grpcClient / gqlClient / jsonRpcClient resolved at
    module load against detectNetwork(). Mainnet + testnet URLs
    separated. Server-side (no globalThis.location) still defaults
    to mainnet, so wrangler.jsonc behavior is unchanged.
  - src/suins.ts: maybeAppendRoster / readRoster / readRosterByAddress
    / addSwapFee / addRegistrationFee now early-exit on testnet. iUSD
    swap paths throw a clear error instead of building broken PTBs.
  - src/server/agents/shade-executor.ts: schedule() + executeOrder()
    fail-closed when env.SUI_NETWORK !== 'mainnet'. Shade Move package
    is mainnet-only; devnet worker sets SUI_NETWORK=testnet.
  - src/server/iou-sweeper.ts: skip the cron tick on non-mainnet.

Tests: 50/50 network-detection suite passes (was 33). New detectNetwork
and isMainnet cases cover the shared helper that rpc.ts consumes.

Devnet verified: https://dotski-devnet.imbibed.workers.dev/api/zklogin/health
and /api/encrypt/health green after deploy.
First real browser round-trip against both workers, which Claudius
flagged as the biggest verification gap in the nursery wrap:

  ──── https://dotski-devnet.imbibed.workers.dev/ ────
    detectNetwork: testnet ✓   console.error count: 0
    /api/zklogin/health: 200   /api/encrypt/health: 200

  ──── https://sui.ski/ ────
    detectNetwork: mainnet ✓   console.error count: 0
    /api/zklogin/health: 200   /api/encrypt/health: 200

Both workers load the shared bundle, both resolve to the correct
network per hostname, zero uncaught errors or pageerrors on either.
Warnings on devnet are benign (Lit dev mode, WaaP postMessage origin
mismatch, Sentry env, localStorage sampling).

scripts/devnet-smoke.mjs is the reproducible harness — npm/bun run
smoke invokes it. playwright@1.59 is a devDependency; chromium
headless shell lives in the shared ms-playwright cache so no sudo
install is needed.

The MCP Playwright server still demands /opt/google/chrome/chrome
specifically and won't accept chromium, so bypassing it with a
direct script is the workaround the next agent should reuse.
The Sui side already surfaces iUSD (listBalances → _isStableCoin →
app.stableUsd). The Solana side was fetching native SOL only, so
cross-chain iUSD holdings on the user's IKA dWallet Solana address
were invisible. Wire it up:

  - treasury-agents.ts: new GET /?iusd-sol-mint read-only handler
    returning { mintAddress, decimals }. Added to readOnlyParams so
    it bypasses x-treasury-auth (it exposes nothing sensitive — the
    mint address is public on Solana by design).
  - server/index.ts: new app.get('/api/cache/iusd-sol-mint') proxy.
  - ui.ts: AppState.solIusdBalance, _fetchSolIusdBalance() using
    getTokenAccountsByOwner with jsonParsed encoding so we don't
    need the @solana/web3.js bundle. Cached mint lookup (one fetch
    per session; null short-circuits if mint not yet created).
    refreshPortfolio() races SPL fetch alongside native SOL. Total
    USD aggregator + per-card aggregator + Cross-Chain treasury
    panel all pick it up.

Mainnet probe confirms treasury state.iusd_sol_mint is currently
null — the mint has never been created. The UI will stay at 0
iUSD SPL until createIusdSolMint is triggered (mutating, requires
ultron.sui caller auth, costs ~0.002 SOL keeper rent).

Smoke: both workers green, zero console errors, detectNetwork
resolves correctly per hostname.
Root cause: src/server/solana-spl.ts had a literal typo in the SPL
TOKEN_PROGRAM constant — 'Jzqcg9bXRcwH6moLxHqRcbXBomN' instead of
'AJbNbGKPFXCWuBvf9Ss623VQ5DA'. Decoding the bogus string with any
base58 implementation returns 33 bytes, overrunning the 32-byte
pubkey slot in the account-keys section of the serialized tx and
shifting every downstream offset by one. The network decoder read
its way past the instructions section into junk and failed with
"ix[2]: numAccounts 96 > remaining bytes 87". Cross-checked against
bs58 + @solana/web3.js: the real Token Program string round-trips to
exactly 32 bytes.

Also fixed:
  - b58encode/b58decode rewritten to the canonical Bitcoin-style
    algorithm. The previous hand-rolled versions overcounted leading
    zero bytes (SystemProgram produced 33 chars instead of 32). The
    new versions round-trip every Solana account we use and match
    bs58 byte-for-byte.
  - createSplMint's earlier WebCrypto mistake (raw-importKey with
    'sign' usage) was removed last round.
  - rpcCall now carries per-endpoint failure reasons into the thrown
    error instead of swallowing them silently.
  - Solana RPC pool expanded: publicnode + ankr alongside the existing
    Helius and mainnet-beta entries.

Result (mainnet):

  $ curl -sX POST https://sui.ski/api/cache/create-iusd-sol-mint -d '{}'
  {"mintAddress":"Jk4P1ADUyiEY9e6X4VRPt9vN8Za87tjZ7sq2QWRgpps",...}

  $ curl -s https://sui.ski/api/cache/iusd-sol-mint
  {"mintAddress":"Jk4P1ADUyiEY9e6X4VRPt9vN8Za87tjZ7sq2QWRgpps","decimals":9}

The iUSD SPL mint is live on Solana mainnet and persisted in
TreasuryAgents state. The Eevee Surf UI wiring from the previous
commit starts showing cross-chain iUSD balances on any connected
user's Solana dWallet automatically.

Note on Helius: the stored HELIUS_API_KEY returns 401 Unauthorized.
The mint tx landed through the publicnode/ankr fallbacks. Rotate
the key via `wrangler secret put HELIUS_API_KEY` to restore the
premium RPC path.

scripts/barnacle-probe.mjs is a reusable probe that scans live
Thunder IOU / ShieldedVault / ShadeOrder objects for any two
SuiNS identities — ready for the recoverable-funds follow-up.
Two bugs had been keeping expired Thunder escrows stuck on-chain:

1. fetchLiveVaults was using SuiGraphQLClient.query() which silently
   dropped the response for object-type filters (zero nodes returned).
   Switched to a direct fetch() — same query shape works perfectly.
   The cron has been "sweeping" 0 vaults forever as a result.

2. recallOne was passing the CURRENT object version as the
   initialSharedVersion of the sharedObjectRef. That's always wrong
   (initial shared version is set once at creation and never changes).
   The PTB resolver rejected every tx with "All promises were rejected"
   from raceExecuteTransaction. Fix: query owner.initialSharedVersion
   in the GraphQL scan and pass THAT value through IouSnapshot.

Result on sui.ski:

  $ curl -sX POST https://sui.ski/api/iou/sweep -d '{}'
  # before: {"scanned":0,"expired":0,"recalled":0,"failed":0}
  # after:  {"scanned":22,"expired":4,"recalled":4,"failed":0}

5 expired shielded vaults on mainnet had been sitting there waiting
for this. They're all now recalled to their original senders:

  0x3120ab39  1.1091 SUI → brando.sui
  0x6ab75fb0  0.0111 SUI → brando.sui
  0x7a304745  0.0110 SUI → brando.sui
  0xf7966f0d  0.0110 SUI → brando.sui
  0xf669309f  8.3241 SUI → barnacle.sui

Total returned: 9.4663 SUI (~$17.50 at current price).

scripts/barnacle-probe.mjs updated to resolve SuiNS via SuinsClient
and scan both brando.sui and barnacle.sui Thunder escrows. Reusable
for any two-party audit.
Barnacle.sui was reading as \$8.03 right after its 8.32 SUI recovery
because \_getNsCardBal computed suiPrice as \`suiPriceCache?.price ?? 0\`.
On a cold render (fresh session, stale localStorage, first roster
hover, etc.) the SUI side of the balance collapsed to zero and the
card only counted iUSD + USDC. Barnacle's ~28 SUI — including the
8.32 SUI just recalled via the Bagon Bite II fix — was invisible.

  - \_DEFAULT\_TOKEN\_PRICES: add SUI=0.9 so the static fallback chain
    has a sane SUI number (matches the current ~\$0.91 spot).
  - getSuiPriceWithFallback(): new helper, live cache → static default
    → 0. Used by both card balance paths (\_getNsCardBal at 4788 and
    the inline fetcher at 10437) — the latter had its own \`?? 0.87\`
    literal, now centralized.
  - ski:card-bal v1 → v2: bumps the persisted cache key so entries
    baked with the SUI-price=0 bug get discarded instead of sitting
    in localStorage for 30 minutes. Users see accurate balances on
    first render after this deploy.
Every mutating Solana call (createSplMint, mintSplTokens) now ships
via Helius Sender before falling back to the standard RPC pool.
Sender gives dual validator+Jito routing, minimal inclusion latency,
and doesn't consume plan credits — the cost is a mandatory 0.0002 SOL
priority-fee tip per tx.

  - COMPUTE_BUDGET_PROGRAM constant + setComputeUnitLimitIx /
    setComputeUnitPriceIx instruction builders
  - withPriorityFee() helper prepends SetCUL(200k) + SetCUP(1.2M uL/CU)
    so every Sender-bound tx meets the 0.0002 SOL floor with margin
  - sendViaHelius() POSTs to https://sender.helius-rpc.com/fast with
    skipPreflight:true + maxRetries:0 (both required by Sender);
    falls back to rpcCall('sendTransaction', ...) on rejection
  - SolanaRpcConfig.heliusApiKey threads env.HELIUS_API_KEY from
    TreasuryAgents into the sender. When set, the key is appended as
    ?api-key=… for higher TPS
  - createSplMint + mintSplTokens rewritten to use withPriorityFee()
    around their existing instruction lists and sendViaHelius() instead
    of the raw rpcCall sendTransaction path

Sender public ping returns 200 OK from CF edge, so the path is live;
the first real user tx will exercise it. Fallback chain is preserved
so the iUSD SPL mint already at
  Jk4P1ADUyiEY9e6X4VRPt9vN8Za87tjZ7sq2QWRgpps
keeps landing even if Sender is mid-degradation.

Refs: #130 (Porygon), https://www.helius.dev/docs/sending-transactions/sender
…ints

Three new endpoints that let us subscribe any Solana account to
push events instead of polling:

  POST /api/helius/webhook
    Auth-gated via env.HELIUS_WEBHOOK_SECRET (Bearer). Accepts both
    enhanced and raw payloads. Walks accountData, nativeTransfers,
    tokenTransfers to build a unique touched-accounts set, logs event
    type+signature, and forwards the set to TreasuryAgents via
    executionCtx.waitUntil so the 200 OK returns immediately. Separate
    from the existing /api/sol-webhook which stays specific to
    treasury deposit detection.

  POST /api/cache/helius-webhook-register
    Admin endpoint that POSTs to Helius v0/webhooks with the derived
    public URL, auth header, and default transactionTypes
    [TRANSFER, SWAP, TOKEN_MINT, NFT_SALE]. Accepts
    { accountAddresses, transactionTypes?, webhookType? }.

  GET /api/cache/helius-webhook-list
  POST /api/cache/helius-webhook-delete
    Management endpoints so stale subscriptions can be cleaned up.

TreasuryAgents DO grows a new /helius-event handler that ingests
the worker's fan-out, retains the 64 most-recent events in state
(type, signature, addresses, timestamp), and logs the ingest. A
later move will pipe this into per-user push notifications and
balance-refresh cache invalidation.

Current state:
  $ curl -s https://sui.ski/api/cache/helius-webhook-list
  []

  $ curl -sX POST https://sui.ski/api/helius/webhook -d '[]'
  {"ok":true,"received":0,"touched":0}

HELIUS_WEBHOOK_SECRET must be set via `wrangler secret put` before
registration will succeed. API key path is working — the list
endpoint returned 200 so the rotated key is live.

Refs: #130 (Porygon)
End-to-end dry run of the Sender → Webhook loop:

  curl -sX POST /api/cache/bam-mint-iusd-sol \
    -d '{"recipientSolAddress":"HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH",
          "amount":"1000000000"}'
  → {"ata":"9WEZRiTEYF1dRdLqs1JBmab9Pn6JoPrK2JLDcXMrL7SA",
     "signature":"5nG5vGszAd36cTDtNAUyQwNU1JbqmvxbRVmhzYGSrdUsoAWobKAQPu4qzfRKWx1ujMFsf7gwwudizZ5SLgWL9BkR"}

Worker tail:
  [solana-spl] Helius Sender HTTP 500 Internal Server Error
  [solana-spl] falling back to RPC pool for sendTransaction
  [solana-spl] Minted 1000000000 to ATA 9WEZRiTE…L7SA, tx: 5nG5vGsz…
  [OpenCLOB] BAM minted 1000000000 iUSD SPL to HN7cABqL…YWrH
  [helius-webhook] TOKEN_MINT 5nG5vGsz… touched 8 accounts
  [TreasuryAgents] helius-event: ingested 1 events, 8 addresses

On-chain: getTokenAccountBalance on the ATA returns
  {"amount":"1000000000","decimals":9,"uiAmount":1.0}

Three real bugs fixed along the way:

1. findProgramAddress was using WebCrypto raw-importKey as the
   on-curve check, which accepts any 32 bytes. Every bump passed and
   the loop terminated with "Could not find PDA". Replaced with
   `ed25519.Point.fromBytes(hash)` from @noble/curves (throws iff
   off-curve). Validated at ~50/50 on/off-curve distribution for
   random inputs.
2. /api/cache/bam-mint-iusd-sol was posting an empty callerAddress
   so requireUltronCaller rejected every request. Now self-derives
   ultron's Sui address from SHADE_KEEPER_PRIVATE_KEY and threads it
   through, same fix pattern we used for create-iusd-sol-mint.
3. Sender returned HTTP 500 — worth investigating (likely missing
   Jito tip account or a config knob) — but the rpcCall fallback
   handled it transparently. Functionality preserved.

Ultron now holds 1.0 iUSD SPL on Solana mainnet. The Porygon
Conversion and Sharpen moves are both validated end-to-end; the
full Helius Sender → webhook → TreasuryAgents ingest loop works.
Fixes the broken bridge between the upgraded on-chain shade package
(0x9978db0a…::shade::StableShadeOrder<T>) and the ShadeExecutorAgent
DO, which was still hardcoded to the legacy 0xb9227899…::shade::execute
signature. At grace-end the live usd1.sui order (90.75 iUSD deposit,
`0xba4d585f…7b78`) would silently fail — deposit stranded.

Server (src/server/agents/shade-executor.ts):

  - Constants for STABLE_SHADE_PACKAGE, IUSD_PACKAGE, IUSD_TYPE,
    USDC_TYPE, NS_TYPE, DB_IUSD_USDC_POOL + ISV,
    STABLE_SHADE_TREASURY (t2000.sui for now, IKA-dWallet multisig
    per the first commandment later).
  - ShadeExecutorOrder gains `isStable`, `coinType`,
    `initialSharedVersion` (required — PTB needs a sharedObjectRef
    for the shared order object).
  - New @callable scheduleStable(params) method with the stricter
    ISV requirement, same idempotency + duplicate-domain guards as
    the legacy schedule.
  - /?schedule-stable HTTP route.
  - executeOrder dispatches to _executeStable whenever order.isStable
    is set — legacy SUI-denominated path is untouched.
  - _executeStable builds one PTB that:
      1. execute_stable<coinType>(order, domain, executeAfterMs,
         target, salt, treasury, clock) → Coin<coinType>
      2. swap_exact_base_for_quote iUSD → USDC via DB_IUSD_USDC_POOL
      3. swap_exact_quote_for_base USDC → NS via DB_NS_USDC_POOL
         (caps budget at `usdcNeeded` = 1.5% buffer over NS-discounted
         price → 25% discount applies at register time)
      4. SuinsTransaction.register({ coin: nsOut }) with NS-pay +
         Pyth price info (via suinsClient.getPriceInfoObject)
      5. setTargetAddress(nft, target) + transfer NFT to target
      6. Dust cleanup: iusdChange / usdcChange / nsOut residue /
         deepChange A+B all transferred to the target
    MAX_RETRIES + RETRY_DELAYS reused from the legacy retry loop.

Client (src/client/shade.ts):

  - New `scheduleStableShadeExecution()` export. Tries WebSocket
    first, falls back to POST /agents/shade-executor-agent/{owner}
    ?schedule-stable. Signature matches the DO method including
    `initialSharedVersion` + `coinType`.

Still pending before the usd1.sui shade actually fires at 2026-05-11:

  1. brando needs to load sui.ski in the browser that created the
     shade — whatever localStorage key holds the salt must be
     pushed via scheduleStableShadeExecution() so the DO captures
     { salt, domain: 'usd1', executeAfterMs, targetAddress,
       initialSharedVersion: 841729116 }.
  2. The DB_IUSD_USDC_POOL seeded liquidity needs to cover
     ~$75 worth of iUSD→USDC round-trip + slippage. Low liquidity
     will kick the retry loop.

Refs: #130 (Porygon), CLAUDE.md first commandment (NS-pay at
registration time for 25% discount — this commit preserves that
invariant for stable orders too).
The usd1.sui StableShadeOrder was stuck in a broken state: on-chain
funded with 90.75 iUSD, but the ShadeExecutorAgent DO had only a
stale legacy entry (empty salt, objectId mismatched) because
treasury-agents' shade-proxy had three bugs:

  1. Post-create lookup matched "::shade::ShadeOrder" but the new
     type is "::shade::StableShadeOrder<T>" — so orderId never
     resolved and fell through to digest: fallback.
  2. Every created order was scheduled via /?schedule (legacy)
     instead of /?schedule-stable, losing the isStable flag +
     initialSharedVersion the executor needs.
  3. initialSharedVersion was never captured from the tx effects.

Fixed all three in shade-proxy, plus two recovery endpoints for
orders that slipped through before the fix:

  POST /api/cache/shade-cancel-stable
    Ultron-signed cancel_stable<T>(order) on the new shade package.
    Refunds the full deposit to ultron (no 10% fee like execute).
    Only allowed when ultron is the order's owner. Used to recover
    the original 0xba4d585f… whose salt was lost to time.

  POST /api/cache/shade-reschedule
    Reads a shade from treasury.state.shades (requires salt to be
    present), resolves initialSharedVersion on-chain, then calls
    scheduleStable on the ShadeExecutorAgent keyed on the holder.

End-to-end recovery on mainnet this session:

  1. Cancelled 0xba4d585f… → refund tx 5xgQotkLViW3ycXnUpkFvKHAmmBXLdgpSwjWwPeVkJ9A
     Ultron iUSD: 42.94 → 133.69 (+90.75 exact)
  2. Recreated usd1.sui via shade-proxy → new order
     0xb0b111d2…a9a773, create tx 46ms5sQuaCypfUcWXTWiy5mZ9KxaQCDPrG2kXHm3RCCF
     Worker log: "[shade-proxy] scheduled STABLE order … isv=842217467"
  3. Stale legacy entry 0x3943ceb7… purged from DO via ?cancel
  4. shade-reschedule(0xb0b111d2…) pushed the new order into the DO

  $ curl -s /api/shade/status/<brando>
  {"orders":[… {"objectId":"0xb0b111d2…","isStable":true,"salt":"set",
               "initialSharedVersion":842217467,"domain":"usd1"}]}

usd1.sui will now auto-fire at 2026-05-11 via _executeStable:
execute_stable<iUSD> → iUSD→USDC→NS swap → SuinsTransaction.register
with the 25% NS discount → setTargetAddress → transfer NFT to brando
→ dust cleanup.
UI polish on the idle thunder card per screenshot review:

  - .ski-idle-card-name: 0.85rem → 1.05rem + letter-spacing -0.01em
    so the target name reads at a glance instead of squinting.
  - .ski-idle-card blue-square button SVG: 16×16 → 22×22 (inner
    rect stays 16×16 via viewBox for crisper outline), stroke
    bumped 1.5 → 1.8 to match the larger footprint.
  - .ski-idle-quick-actions gap: 5px → 2px — the amount chip row
    ($1 / $7.50 / $0.01 / submit-arrow) was bleeding horizontal
    space. 2px is a hairline gap without looking jammed.
  - .ski-idle-card padding: 5px 8px → 6px 10px + gap 6 → 8px so
    the larger name and square don't feel cramped.

Same change applied to both render paths in src/ui.ts
(the own-name and other-name card branches).
Pokes /api/iou/sweep whenever the SKI menu opens for the first
time in a session, instead of letting expired Thunder outbound
sit around waiting up to 10 min for the scheduled cron tick.

  - src/client/shade.ts: new `pokeIouSweeper()` helper. POST to
    /api/iou/sweep, return {scanned,recalled,failed}. Best-effort,
    never throws.
  - src/ui.ts: fire `pokeIouSweeper()` from the existing first-open
    shade-prune path. Surfaces a toast "Recalled N expired Thunder
    escrows" when anything actually comes home.

Side-appropriate by design: `iou::recall` and
`shielded::recall` are permissionless after TTL, always return
funds to the ORIGINAL sender, and clear the commitment slot.
Whoever is looking at the UI benefits:

  - Sender viewing any card: their expired outbound vaults come
    home into their wallet before they try to send another one
    (no more "already escrowed" commitment collisions).
  - Recipient viewing their own card: upstream commitment slots
    to them clear out, sender can retry with fresh amounts.

Follow-ups already queued in the Pokemon trail:

  #16 — recipient auto-claim on load: scan live ShieldedVault
       objects, try to decrypt sealed_opening via Seal for the
       connected user, call claim() on hits. Needs Seal decrypt
       infra + session-bound secret access. Complements this
       commit by handling the non-expired claimable path.

  #17 — editable Storm messages: text lives in TimestreamAgent
       DO state (off-chain), so it's mutable. Tx-reference
       bubbles stay immutable since they anchor on-chain digests.
The Timestream DO's /update handler has existed since post-P1.1
(handleEdit, line ~330) with sender-only auth and an isEdited flag
on every stored row. The wire was just never connected to the UI.

Client (src/client/thunder-stack.ts):
  - New editThunder({ groupRef, messageId, text }) helper. Uses
    the module-level _signer bound at initThunderClient so callers
    don't have to plumb signers through every call site. Delegates
    to client.messaging.editMessage() which re-encrypts with the
    current key version + signs a new messageContent proof + posts
    to the DO's /update endpoint.

UI (src/ui.ts):
  - Double-click any outgoing plain-text bubble → prompt() for new
    text → editThunder() → optimistic in-place patch with "(edited)"
    badge. Transfer bubbles and attachment-mixed bubbles are skipped
    (immutable by design — they anchor on-chain digests and Walrus
    blob IDs respectively). Delegation is bound once per convo
    render via _skiEditBound so we don't leak listeners.
  - Initial bubble render now emits the "(edited)" badge for any
    message where state.isEdited === true so edits are visible
    when the convo reloads.

Styles (public/styles.css):
  - .ski-idle-bubble-edited: 0.65em italic 0.55 opacity inline
    marker, pointer-events:none so it doesn't intercept the
    dblclick on its parent bubble.

Flow:
  1. user dblclicks their own thunder
  2. prompt() surfaces current text for editing
  3. editThunder re-encrypts via Seal with same key version
  4. DO verifies sender, updates encryptedText + isEdited = true,
     broadcasts { kind: 'edit', message } to all connected clients
  5. local optimistic patch, remote clients see broadcast
When a recipient loads a storm, walk every unsettled transfer bubble
marked data-iou-role="recipient" and dispatch synthetic click events
800ms apart, firing the existing per-bubble claim flow for each.

The claim flow already did all the hard work — fetches the
ShieldedVault, pulls sealed_opening bytes, splits into nonce(12) +
ciphertext, calls client.messaging.encryption.decrypt with the
current key version, parses the 40-byte opening blob (blinding +
amount), builds buildShieldedClaimTx with the recipient as sender
and the vault's sharedObjectRef, signs + submits, stamps the
settled paint + claim-tx pill on the bubble. All that ran only when
the user manually clicked each bubble. Now it fires automatically
on storm open.

Rate-limited: 800ms stagger between claims so the wallet doesn't
get flooded with concurrent sign prompts. Skips anything already
settled, already recall-armed (sender's two-click confirm in
progress), or not a recipient-role bubble. Expired outbound vaults
still go through /api/iou/sweep via Bagon Whirlwind.

Pair this with Bagon Whirlwind (5fe9b93) and the "side-appropriate
action" model is complete:

  - Sender loads their SKI menu → pokeIouSweeper() recalls every
    expired outbound ShieldedVault / IOU they have open, funds
    come home to their wallet.
  - Recipient loads a storm → auto-claim fires on every live
    incoming transfer, funds flow into their wallet visible
    balance and the convo immediately re-paints as settled.

The 1.12 SUI vault 0x7c4a5d57… will be claimed by barnacle on
their next storm-open. Expired vaults get the sweeper. No
Thunder ever strands again.
When a recipient claims a shielded (or legacy) Thunder IOU the
funds hit the chain immediately but the visible balance was taking
up to 2 seconds to catch up because the post-claim refresh was on
a setTimeout(…, 2000) and the per-card balance cache (60s in-memory
+ 30min localStorage) never got busted.

New flow on claim success:

  1. Decode _claimedMist from the shielded opening (amount field)
     or, for legacy IOUs, parse the bubble's $X.XX label and
     convert to MIST using getSuiPriceWithFallback().
  2. Credit app.sui += _claimedMist / 1e9 and app.usd += usd
     value synchronously — SKI menu + header re-paint on next
     render() tick instead of waiting for the indexer.
  3. Flush _cardBalCache entirely and delete every
     ski:card-bal:v2:* localStorage entry so the next card
     render re-fetches from GraphQL instead of serving stale.
  4. render() + _updateIdleCard(nsLabel) kick re-paints of
     both the SKI menu and the currently-visible idle card.
  5. refreshPortfolio(true) fires immediately (was 2000ms
     delayed) AND a second time 2500ms later to reconcile
     any slow indexer.

Legacy IOUs don't expose their MIST amount to the claim-success
scope directly, so they get a best-effort parse from the bubble
text. Shielded vaults use the exact amountMist from the decoded
opening so the optimistic credit is precise to the nearest MIST.

End-to-end: click claim → sign → (on-chain confirmation) →
visible balance jumps the claimed amount in the same animation
frame as the "✅ Claimed" toast.
The usd1.sui StableShadeOrder holds 90.75 iUSD, target brando,
owned by ultron. On-chain it's locked; in brando's SKI menu it
was invisible because:

  - the iUSD is in a shared StableShadeOrder object, not in
    brando's (or ultron's) liquid balance
  - refreshPortfolio only summed grpcClient.core.listBalances +
    SOL + iUSD SPL
  - the coin dropdown only iterated walletCoins

Result: brando saw $70 total when ~$160 was actually attributed
to brando (liquid + locked). Now reconciled in two places:

  1. refreshPortfolio total: when _shadeDoState has pending orders,
     sum depositMist per order. Stable orders (iUSD) count 1:1;
     legacy orders use the live SUI price. Folded into app.usd
     next to sui/stables/tokens/solUsd/solIusdUsd.

  2. Coin dropdown: synthetic `shade` chip with a
     wk-coin-item--shade-pending tooltip showing "$X pending in
     shades". Pushed before the sort, so it lands at its sorted
     position by $ value.

Auto-refreshes via the existing render() chain whenever
_shadeDoState updates from the ShadeExecutorAgent WebSocket.
On-execute, the chip drops to 0 and the NFT replaces it on the
roster. On-cancel, the iUSD returns to the owner address and
shows up in normal balances on the next refresh.
Ultron is the shade funder (treasury underwrites every shade from
its iUSD cache via the shade-proxy flow), so the Move contract's
native ctx.sender() refund path lands the iUSD on exactly the
address that paid for it. My earlier patch was building a second
tx to forward ultron's refunded iUSD to the holder (brando),
which would have drained the cache on every user-initiated cancel
and double-counted the refund against the holder's balance — not
what we want.

Reverted to pure cancel_stable. Response body now includes
`refundedTo` (= ultron) and a `note` explaining the funder model
so future debugging doesn't get confused by the holder / funder
distinction.

The shade state entry still flips to `cancelled` and the
deliberation loop stops tracking the object.

Follow-up (not this commit): to support a user-funded model
where brando's iUSD seeds the shade directly and the refund
naturally returns to brando, the shade-proxy flow needs to
move from ultron-signer to user-signer for create_stable.
That's a bigger pivot — client-side signing of stable shade
creation, persisting salt/preimage locally, and the executor
being decoupled from ownership.
Un-reverted the holder-forward path after user clarification. The
economic model in the UI is: brando "sent" the shade (initiated
it via the UI), ultron signed it on-chain as infrastructure.
Cancels should round-trip the deposit back to brando, not leave
it sitting on ultron's cache.

shade-cancel-stable now runs two txs sequentially:

  1. ultron calls cancel_stable<T>(order) — Move contract refunds
     depositMist to ctx.sender() = ultron.
  2. 1500ms index wait, then ultron.splitCoins + transferObjects
     for depositMist iUSD → holder address captured from
     treasury.state.shades[].holder at create time.

The second tx is best-effort: if ultron doesn't have enough
iUSD (shouldn't happen — cancel just added it), if the index is
slow, or if the holder address is somehow missing, the flow logs
and returns the cancel digest without forwarding, so the ops
operator can manually sweep.

Response body changes:

  - refundedTo: forwarded holder address if the forward succeeded,
                otherwise falls back to ultron (so the response is
                always truthy and consumers see where the money is)
  - forwardDigest: the second tx digest, or null if we skipped the
                   forward

On the next user-initiated cancel of any stable shade, the iUSD
lands on brando's visible wallet in a single round-trip. Reverted
Porygon Haze, superseded by this commit.
Added optional `noForward: true` to the cancel-stable body. When
set, the second forward-to-holder tx is skipped so the refunded
iUSD stays on ultron. Used for the cancel-and-reshade flow where
the same iUSD needs to cycle from cancel → reshade deposit
without going through the user's wallet in between.

Ran end-to-end on mainnet:

  1. shade-cancel-stable noForward=true on 0xb0b111d2 (which was
     already gone from chain — must have been consumed by a
     prior cancel flow earlier in the session). Endpoint returned
     "Object not found" but that's the desired idempotent state.
  2. shade-proxy created fresh 0xe33dd559…6453bf7 targeting brando
     with 90.75 iUSD deposit (ultron: 133.69 → 42.94).
  3. Purged stale DO entry 0xb0b111d2 via /agents/shade-executor-
     agent/<brando>/cancel.
  4. shade-reschedule pushed 0xe33dd559 into the DO with salt +
     initialSharedVersion=842217491.

Final DO state for brando:
  {"usd1.sui  0xe33dd559be663e…  stable=true  salt=set  isv=…491"}

Cancel-forward still works by default — this flag is opt-in and
specific to reshape operations where draining ultron to brando
would break the next create_stable.
New POST /api/cache/bam-mint-v2 endpoint that applies Vector's five
off-chain-signing principles without requiring the Vector program
on-chain (which isn't deployed — it's a Rust-only prototype with a
placeholder program ID).

Flow:
  client → { intent, signature, publicKey } → worker
  worker verifies:
    1. Intent shape + required fields
    2. Intent.expiresMs > now (expiration primitive)
    3. SHA-256 digest of canonical(intent) signed by publicKey
       (@noble/curves ed25519.verify)
    4. Nonce is fresh per-publicKey (replay rejection via DO)
    5. Hashchain advances: seed[n+1] = sha256(seed[n] || digest)
  then calls existing bam-mint-iusd-sol DO method as a pure
  relayer — ultron pays fees and transports, cannot modify
  recipient or amount.

Canonical intent shape (keys sorted alphabetically, stable JSON):
  { amount, expiresMs, mintAddress, nonce, recipientSolAddress }

TreasuryAgents DO:
  - new /magnemite-nonce handler stores per-publicKey nonce history
    and advances a SHA-256 hashchain seed. Retains last 1000 nonces
    per key to keep state bounded.

End-to-end test on mainnet (ephemeral keypair):

  POST /api/cache/bam-mint-v2 { intent, sig, pubkey }
    → 200 { ata, signature: <mint tx>, intentDigest }
    → 0.5 iUSD SPL minted on-chain
    → tx: 5gYMn2f2V27g61G9TA4nYKZ4ZLLgft5wFQdoXY4fCwiR4wcbp3QW…

  Replay attempt (same nonce):
    → 409 "Nonce already used for this publicKey"

Old /api/cache/bam-mint-iusd-sol endpoint is untouched — still
works for legacy paths. Deprecation comes in Magneton (stage 2).

Refs: #131 Magnemite, contracts/vector
Reusable on-chain audit probe for Thunder IOU / ShieldedVault /
ShadeOrder objects across any two SuiNS identities. Mirrors the
barnacle-probe.mjs from earlier but adds:

  - Recent recall credit enumeration (shielded::recall +
    iou::recall + iou::recall_after_ttl) filtered to the target
  - Current SUI balance snapshot per target
  - SuinsClient.getNameRecord for address resolution
  - Per-side aggregation (sender vs recipient)

Ships as tooling for the next session's "where did X's funds go"
diagnostics — zero runtime dependencies beyond @mysten/suins +
@mysten/sui/graphql.
Closes inheritance TODO #1: brando signs create_stable directly so
the on-chain order owner is the user, not ultron. Cancel refunds
flow naturally back to the user without forward-to-holder hacks.

Client builders (src/suins.ts):
- buildCreateStableShadeOrderTx — lists iUSD coins, splits deposit,
  calls SHADE_V5::shade::create_stable<IUSD>, returns txBytes +
  orderInfo with unbuilt Transaction attached for WaaP.
- buildCancelStableShadeOrderTx / buildCancelRefundStableShadeOrderTx
  — owner-only cancel paths on SHADE_V5 (by-value + &mut variants).
- findCreatedStableShadeOrderRef — resolves {objectId,
  initialSharedVersion, coinType} from tx digest so
  scheduleStableShadeExecution has everything it needs.
- fetchOnChainShadeOrders — now also scans StableShadeOrder<iUSD>
  and tags each result with stable/coinType.

UI (src/ui.ts):
- _shadeCreate prefers user-signed iUSD path; falls back to ultron
  shade-proxy only when user lacks iUSD.
- Schedules via scheduleStableShadeExecution (needs ISV).
- _shadeCancel routes to cancel_stable{,_refund} for stable orders,
  cancel{,_refund} for legacy orders, based on on-chain type.

Executor DO untouched — execute_stable is permissionless and still
fires via ultron at grace end; NFT registers to order.targetAddress
(the user), so on-chain ownership is user-end-to-end.
First shot at the ultron DKG per inheritance thread: expose
window.rumbleUltron('ed25519') in the browser so an admin with a
connected wallet can fire DKG and transfer the resulting DWalletCap
to ultron (0xa84c…b3c3).

client/ika.ts:
- rumble() gains opts.curves so callers can restrict to ed25519 only
  (sol@ultron is the immediate target — no point burning IKA on
  secp256k1 in the same flow).
- status skip-check now reads targetOwner's existing dWallets when
  set, so repeat calls are idempotent for the target.

ski.ts:
- window.rumbleUltron('ed25519' | 'secp256k1' | 'both') dispatches
  rumble() with targetOwner = ultron address. Connected wallet pays
  IKA+SUI and signs the DKG tx; DWalletCap lands owned by ultron.

Known gap: user-share encryption keys are generated in-browser with
a random seed, so ultron owns the cap but cannot sign autonomously
without the browser session that ran DKG. Re-encryption to ultron's
own encryption key is a follow-up (Registeel Iron Head).
Fixes the previous rumbleUltron run that silently no-op'd: the
provisionDWallet skip check was reading the *sender's* dWallets,
so when brando already had an ed25519 dWallet the DKG was skipped
entirely and the log returned brando's addresses as if they were
ultron's. Also threads a deterministic encryption seed end-to-end
so a keeper runtime can re-derive the encryption keys later and
sign autonomously on ultron's behalf.

client/ika.ts:
- ProvisionCallbacks.encryptionSeed — optional 32-byte seed that
  replaces crypto.getRandomValues() in fromRootSeedKey. Deterministic
  by design so the keeper can reconstruct the same encryption
  material from its own secret.
- provisionDWallet() skip check now reads targetOwner (not sender)
  so rumble-for-ultron runs a real DKG when ultron has no dWallet
  even if brando already does.
- rumble() final status + dWalletCap enumeration now read targetOwner
  so the returned RumbleResult reflects the recipient's state.
- rumble() gains opts.encryptionSeed which it threads into the
  ProvisionCallbacks.

server/index.ts:
- New /api/cache/rumble-ultron-seed endpoint. Admin-gated via a
  signed personal message "rumble-ultron:<ultronAddr>:<YYYY-MM-DD>"
  verified with verifyPersonalMessageSignature against an allowlist
  (plankton.sui + brando.sui today). Returns the deterministic seed
  sha256(SHADE_KEEPER_PRIVATE_KEY || "ultron-dkg:<curve>:<addr>") so
  future autonomous signing can re-derive the same encryption keys
  without browser participation.

ski.ts:
- window.rumbleUltron() now signs the admin message with the
  connected wallet, fetches the deterministic seed, and passes the
  raw bytes into rumble() via opts.encryptionSeed. No seed storage,
  no plaintext seed in the browser beyond the DKG call.

The seed is effectively a signing credential — it never leaves
TLS and the admin gate prevents casual fetches. Longer-term, a
DO-hosted IKA runtime can hold the keeper key, derive the same
seed, and sign autonomously without any browser in the loop.
Picks up everything currently behind a patch/minor range. Kept
within semver-compatible bumps only; deliberately held back:
  - @bluefin-exchange/bluefin7k-aggregator-sdk 5.5.1 → 6.0.0
  - agents 0.6.0 → 0.10.1
  - typescript 5.9.3 → 6.0.2
All three are major bumps and need their own validation passes.

Bumps (13):
  @cloudflare/workers-types  4.20260403.1 → 4.20260413.1
  wrangler                    4.81.0       → 4.81.1
  @fingerprintjs/fingerprintjs 5.1.0       → 5.2.0
  @mysten/dapp-kit-core        1.2.2       → 1.3.0
  @mysten/deepbook-v3          1.2.1       → 1.3.0
  @mysten/kiosk                1.2.0       → 1.2.1
  @mysten/sui                  2.14.1      → 2.15.0
  @mysten/suins                1.0.2       → 1.0.4
  @mysten/walrus               1.1.0       → 1.1.1
  @noble/curves                2.0.1       → 2.2.0
  @noble/hashes                2.0.1       → 2.2.0
  hono                         4.12.10     → 4.12.12
  hono-agents                  3.0.7       → 3.0.8

Build is clean on all targets; deployed to dotski after the bump
to confirm the worker bundle survives the update. Known follow-up:
@mysten/suins 1.0.4 renamed the SuinsClient methods we were using
for the name existence check inside the rumble flow — the check
was already wrapped in try/catch ("proceeding anyway") so it is
non-blocking, but should be re-pointed when touched next.
Drains the legacy raw-keypair Solana address (base58 of ultron's
Sui pubkey) into the new IKA-native sol@ultron from DKG. Same
SHADE_KEEPER_PRIVATE_KEY that signs Sui txs is also the private
key for the old Solana address, so the server can sign everything
without any browser participation in the actual transfer.

solana-spl.ts:
- transferCheckedIx — opcode 12, decimal-validated SPL transfer.
- closeAccountIx — opcode 9, reclaims rent deposit from an empty
  token account back to the owner.
- systemTransferIx — opcode 2, native SOL transfer via System.
- sweepSplAccount() — one-tx combo of createATA(idempotent,
  recipient) + transferChecked(full) + closeAccount(source → owner).
  Caller's ed25519 sign fn handles the raw 32-byte secret directly.
- sweepNativeSol() — drains remaining SOL minus a reserved fee
  (base + priority * CU limit) so the tx can actually land.

server/index.ts:
- GET  /api/cache/ultron-sol-probe — read-only balance report on
  the legacy address (SOL + every SPL token account with amount > 0).
- POST /api/cache/sweep-sol-ultron — admin-gated like the seed
  endpoint (signed personal message from an allowlisted address).
  Decodes the keeper bech32 → raw 32-byte ed25519 secret via
  decodeSuiPrivateKey, signs every Solana tx with noble's ed25519,
  sweeps each SPL account, waits 6 s for rent refunds to land,
  then drains SOL.

ski.ts:
- window.sweepSolUltron(recipient) — mirror of window.rumbleUltron.
  Signs the admin message, POSTs to the sweep endpoint, reports
  the result. Designed to run once per sweep; idempotent because
  a drained address just returns an empty swept[].

Verified on mainnet: drained 136.47 USDC + 90.75 iUSD SPL + 0.0119
SOL from 7iVxCj…ctv3U → GfVzGH…s1DW. Old address returns zero on
follow-up probe, new IKA-derived address shows the full balance.
### #3: address refactor
Replaces every sol@ultron computation in treasury-agents.ts with a
single ULTRON_SOL_ADDRESS const pointing at the IKA-derived address
(GfVzGHiSPyTnX6bawnahJnUPXeASF6qKPd224VQws1DW). 13 sites updated —
all balance lookups, deposit targets, webhook matchers, sub-cent tag
derivation, and address-display paths now resolve to where the real
funds are after Registeel Hyper Beam's sweep.

The 4 remaining keypair.getPublicKey().toRawBytes() usages are
legitimate raw-pubkey contexts: createIusdSolMint (authority),
bamMintIusdSol (payer+authority), crossChainMintSol (same), and the
/rumble deposit-addresses endpoint (already migrated to the const).
The three mint-authority sites are known-broken because the old
address has zero SOL for fees and the mint was created with the
legacy keeper as authority; fixing needs a mint-authority transfer
to the new address plus DO-hosted IKA signing, tracked in the new
project_ultron_do_signing memory.

### Helius proxy
New POST /api/sol-rpc relays a JSON-RPC method allowlist (balance,
account, token, signature lookups) to mainnet.helius-rpc.com using
HELIUS_API_KEY server-side. Client SOL_RPCS flipped to
['/api/sol-rpc', 'solana-rpc.publicnode.com'] so the browser talks
same-origin and stops hammering api.mainnet-beta.solana.com with
403 Forbidden responses.

Verified end-to-end: curl /api/sol-rpc with getBalance on the new
sol@ultron returns 11_183_451 lamports via Helius.

### secp256k1 follow-up
Unrelated but worth noting: rumbleUltron('secp256k1') ran cleanly
this session, digest 38NwvhPrP911FBJgQsVMmCE6jhufWCCzpxubwY8CTaDy.
Ultron now owns 2 DWalletCaps — ed25519 (0x518b96da…) from the first
Registeel Lock-On run and secp256k1 (0x103492d7…) from this run.
Derived addresses: btc@ultron = bc1qz5glnvhxacqva2cgydehqhgxjx22jru86gwgp9,
eth@ultron = 0xcaA8d6F00f465129eF0B7D7ABBeA9f2C8a90882d.
Feasibility spike for project_ultron_do_signing. Both claimed
blockers from memory are empirically falsified:

  1. @ika.xyz/ika-wasm loads + initializes inside a Durable Object
     runtime. All host imports (crypto.getRandomValues, String,
     Array, Error, wbindgen_init_externref_table) bind cleanly.
  2. A pure-crypto exported function runs to completion without
     throwing. Called generate_secp_cg_keypair_from_seed(0, seed)
     with a fixed 32-byte test vector; returned a [publicKey,
     privateKey] tuple in <1ms.

Live smoke test:
  GET https://sui.ski/api/ultron/wasm-spike
  → {"ok":true,"keypairShape":"object","keypairKeys":["0","1"],"durationMs":0}

Implementation notes for the real signing flow:

  - wrangler.jsonc gained a CompiledWasm rule and a migration for
    the new UltronSigningAgent class. No alias needed; the wasm
    binary lives at src/server/wasm/dwallet_mpc_wasm_bg.wasm (copied
    from node_modules once — wrangler's bundler rules don't reach
    into node_modules, so we stage a local copy).
  - The @ika.xyz/ika-wasm package's exports field blocks deep imports
    of dist/web/*. We use @ika.xyz/ika-wasm/web (which IS exported)
    for the JS bindings, then import the .wasm directly via the local
    src/ path so Wrangler's CompiledWasm rule picks it up.
  - initSync(module) is the Workers-friendly init path. The default
    __wbg_init expects a browser loader (fetch via import.meta.url).
    initSync accepts a pre-compiled WebAssembly.Module directly.
  - ensureWasmReady() gates init so it only runs once per DO
    activation — wasm-bindgen's own guard handles re-init at the
    bindgen layer but we avoid repeating the Module→Instance
    construction on subsequent calls.

Next step (not this commit): layer the full signing flow on top —
read dWallet → presign PTB → decrypt user share → centralized sign
message → parse final signature. All use the same initSync path
proved here, plus plain JSON-RPC core.* calls on SuiJsonRpcClient
(also confirmed available per the feasibility study). ETA ~1.5
engineering days per project_ultron_do_signing plan.
Extract the inline Vector-principles verification from bam-mint-v2
into a shared `verifyVectorIntent` helper at src/server/vector-intent.ts,
refactor bam-mint-v2 to use it (behavior-identical), and wire a new
/api/cache/send-iusd-v2 endpoint as the first reuser. Also add
src/client/vector-intent.ts with a mirrored canonicalizer + signer so
client and server produce byte-identical digests.
Extends the WASM spike with real network I/O: the DO now lazily
constructs an IkaClient over a SuiJsonRpcClient (PublicNode primary,
BlockVision + Ankr fallbacks) and reads ultron's ed25519 dWallet via
getDWallet. End-to-end round trip is 568 ms from the first call.

**New surface**
- src/server/agents/ultron-signing-agent.ts
  - getIkaClient() — lazy Promise.any across SUI_JSON_RPC_URLS
  - onRequest routes /read-dwallet to _readUltronDWallet()
  - _readUltronDWallet() queries getDWallet (not the polling
    getDWalletInParticularState variant — that has a JSON-RPC enum
    parsing quirk that makes it timeout after 11 s even when the
    object is fully loaded). Returns state name, publicOutputLength,
    encryptedUserShareCount, curve.
- src/server/index.ts
  - New GET /api/ultron/read-dwallet endpoint mirroring the
    wasm-spike plumbing so the DO is reachable via HTTP for smoke
    testing the signing path as it's built up.

**Finding: the dWallet is in AwaitingKeyHolderSignature, not Active.**

curl https://sui.ski/api/ultron/read-dwallet →
  {
    "ok": true,
    "state": "AwaitingKeyHolderSignature",
    "publicOutputLength": 0,
    "encryptedUserShareCount": 1,
    "curve": 2,
    "durationMs": 568
  }

This is the state a dWallet enters after DKG, waiting for the party
that holds the user-share encryption keys to call
`acceptEncryptedUserShare`. The existing browser DKG flow in
src/client/ika.ts never runs the accept step — so both brando's
dWallets and ultron's (via targetOwner transfer) are stuck here.

**Implications for the full signing flow:**
- Option A: implement acceptEncryptedUserShare in the DO. Requires
  reading the encrypted share object, running WASM `verify_user_share`
  with the deterministic seed-derived keys, producing a BCS-signed
  approval. Transitions the dWallet to Active.
- Option B: skip accept and use raw secretShare + publicOutput in
  requestSign. SDK allows this for ZeroTrust dWallets but warns that
  the caller must verify the share themselves — same WASM call
  either way, just without the on-chain transition.

Increment B will take Option A first since the on-chain Active state
makes subsequent signing cleaner and the existing
acceptEncryptedUserShare SDK path matches the flow.
@gitguardian

gitguardian Bot commented Apr 13, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
17175479 Triggered Generic High Entropy Secret dacac9b src/server/solana-spl.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant