Abra captured — Superteam agentic engineering grant application#136
Open
arbuthnot-eth wants to merge 48 commits into
Open
Abra captured — Superteam agentic engineering grant application#136arbuthnot-eth wants to merge 48 commits into
arbuthnot-eth wants to merge 48 commits into
Conversation
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 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
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- 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
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 trackerdocs/pokemon/abra/session-evidence.md— 9-commit Registeel/Magneton/Porygon wave with hashes, file paths, dollar amountsdocs/pokemon/abra/application-draft.md— concrete technical grant application citing:b449dd3Registeel Toxic Spikes) that falsified two memory-documented IKA SDK blockers via research + empirical proof5898697Registeel Hyper Beam) on-chain verified end-to-enddocs/pokemon/abra/drive-upload-instructions.md— Drive folder manifest for evaluatorsIssues
Evolution path
Test plan
Leave open for human review — do not auto-merge.