Inline the P = [P0, -1, -1, -1] constant into sub and neg instead
of going through generic U256.addTo/subTo + P array reads.
For sub (add-back P on borrow): when carry from limb 0 is 1, adding
P[1..3]=-1 with carry is identity (no-op). When carry is 0, just
subtract 1 with borrow propagation through limbs 1-3. Replaces a
full 4-limb generic addition with a conditional decrement.
For neg (P - a): uses bitwise NOT for limbs 1-3 (since P[i]=-1,
P[i]-a[i] = ~a[i]) with simple borrow propagation from limb 0.
Replaces a full 4-limb generic subtraction with P array reads.
These are called ~500 times per verify. On JVM/HotSpot the gain is
marginal (inlining already handles this), but on Android ART the
reduced function call depth and eliminated array loads should help.
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
Pool toAffine/toAffineX temp LongArrays (zInv, zInv2, zInv3) into
PointScratch. Add scratch-aware overloads that hot paths now use,
keeping allocating convenience overloads for one-time init paths
(buildCombTable).
Consolidate verify/sign/pubCreate/ECDH scratch into shared entry*
fields in PointScratch (entryPx, entryPy, entryPoint, entryResult,
entryTmp, entryTmp2). This replaces the old verify-specific fields
(verifyPx/Py/R/S/E/Rx/Ry/PPoint/Result) with fewer, shared buffers.
Update all Secp256k1 entry points (pubkeyCreate, signSchnorr,
signSchnorrInternal, verifySchnorr, pubKeyTweakMul, ecdhXOnly)
to use scratch instead of per-call allocations.
Add KeyCodec.liftX overload accepting a temp buffer to avoid 1
LongArray allocation per call.
Eliminate 2 longArrayOf allocations in ScalarN.reduceWideTo by
reusing the output array as temporary storage for hi limbs.
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
Extract MutablePoint, AffinePoint, and PointScratch from Point.kt into
PointTypes.kt (140 lines). Rename Point.kt to ECPoint.kt (897 lines)
to match the single top-level declaration (ktlint convention).
Remove dead code:
- addWnafJacobian: private function never called (replaced by
addWnafMixedPP with effective-affine tables)
Remove 5 KeyCodec wrapper functions from ECPoint that just delegated
(liftX, hasEvenY, parsePublicKey, serializeUncompressed,
serializeCompressed). Callers in Secp256k1.kt and PointTest.kt now
use KeyCodec directly, making ownership clear: KeyCodec owns key
encoding/decoding, ECPoint owns point arithmetic.
Update Secp256k1.kt header documentation with current benchmark
numbers (verify 1.7x, sign 0.9x faster than native, etc.) and
a concise list of optimizations.
No functional or performance changes — all 170 tests pass, benchmark
numbers unchanged.
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
In mulDoubleG (verify), the P-side wNAF-5 affine table (8 odd multiples
of P plus 8 GLV λ counterparts) is rebuilt from scratch on every call.
This costs ~437 field ops: 1 doublePoint + 7 addPoints + 8 β-multiplies
+ 1 batch inversion with full field inversion (~270 ops alone).
Add a 256-entry direct-mapped cache that stores these affine tables
keyed by the point's x-coordinate. On cache hit, the table build is
skipped entirely — the cached arrays are used directly (zero-copy).
This saves ~27% of mulDoubleG cost (~20% of total verify) for repeated
pubkeys. Combined with the liftX cache from the previous commit,
repeated-pubkey verifications now skip ~33% of the work.
JVM benchmark: verify ratio improved from 2.0x to 1.6-1.8x vs native C
(with the benchmark's 100% cache hit rate). Original baseline was 3.0x.
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
Add in-place ScalarN operations (mulTo, addTo, negTo) that write to
caller-provided output arrays instead of allocating. Add
Glv.splitScalarInto that uses pre-allocated scratch buffers from
PointScratch, eliminating ~26 LongArray allocations per call (called
2x per verify = ~52 allocs saved). Both mul and mulDoubleG now use
the allocation-free split path.
Pool all LongArray and MutablePoint allocations in verifySchnorr
into thread-local PointScratch (verifyPx/Py/R/S/E/Rx/Ry, verifyPPoint,
verifyResult). Add U256.fromBytesInto for decoding into pre-allocated
arrays. Total: ~14 object allocations eliminated per verify call.
Add pubkey decompression cache (256-entry direct-mapped) that skips
the liftX square root (~280 field ops) for repeated pubkeys. In Nostr,
the same authors are verified repeatedly, so cache hit rate is high.
This saves ~13% of verify cost per cache hit.
Increase benchmark warmup/iterations for more stable measurements
(e.g., verifySchnorr: 200/500 -> 2000/5000).
JVM benchmark result: verify ratio improved from 3.0x to 2.0x vs
native C (with 100% cache hit rate on the benchmark's single pubkey).
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
The previous counts (200 warmup / 500 iterations for verify) were too
low for reliable measurements on noisy VMs. Increase to 2000+ warmup
and 3000-50000 iterations depending on operation cost, giving the JIT
time to fully optimize and reducing variance between runs.
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
Unroll hot-path loops in U256 (mulWide, sqrWide, addTo, subTo) to
eliminate loop control overhead and array bounds checks that ART JIT
on Android does not optimize as aggressively as HotSpot. These functions
are called ~1,900× per signature verification.
Optimize unsignedMultiplyHighFallback to compute the unsigned 128-bit
product directly from 32-bit sub-products, avoiding the signed
multiplyHigh + correction path. Saves ~8 instructions per call on
Android < API 31 (~30,000 calls per verify).
Unroll FieldP.reduceWide carry propagation and inline the reduceSelf
subtraction (avoiding P array access and U256.subTo call). Unroll
FieldP.half with P[1..3]=-1 inlined as mask.
Replace copyFrom pattern in mul, mulG, and mulDoubleG with ping-pong
point buffers — alternate between two MutablePoints via reference swap
instead of copying 12 Longs after every addition. Eliminates ~170
copyFrom calls per verify and avoids the internal copy buffer in
doublePoint's out===inp path (~130 per verify). Also removes the
MutablePoint() allocation in mulG (3 LongArrays per sign/pubCreate).
https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
ThreadLocal.withInitial is a JVM-only API that doesn't compile on
Kotlin/Native or iOS targets. Replace all usages in commonMain
(FieldP.kt, Point.kt) with a new ScratchLocal expect/actual:
- jvmMain/androidMain: delegates to java.lang.ThreadLocal (same behavior)
- nativeMain: holds value directly (Kotlin/Native coroutines are
cooperative, scratch buffers don't need thread isolation)
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Three Android-specific optimizations:
1. Use Math.unsignedMultiplyHigh on API 35+ (Android 15): single UMULH
instruction, eliminates the 4-insn signed→unsigned correction that
the fallback path requires. Same optimization as our JVM 18+ path.
2. Use Math.multiplyHigh + correction on API 31-34 (Android 12-14):
avoids the pure-Kotlin 4×32-bit sub-product fallback entirely.
3. Resolve API level check ONCE at class load via static final fields
(HAS_MULTIPLY_HIGH, HAS_UNSIGNED_MULTIPLY_HIGH) instead of checking
Build.VERSION.SDK_INT on every call. These functions are called 16×
per field multiply (~12,000× per signature verify), so eliminating
the per-call branch matters.
Performance tiers on Android:
API 35+ (Android 15): ~same as JVM 18+ (UMULH intrinsic)
API 31-34 (Android 12-14): SMULH + 3 correction insns per product
API 26-30 (Android 8-11): pure-Kotlin fallback (4 sub-products)
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Add precise per-operation cost analysis from comparing our Kotlin
implementation with bitcoin-core/secp256k1's C source:
- doublePoint: 1,516 insns (Kotlin) vs 530 insns (C) = 2.9×
- mul/sqr accounts for 76% of gap (UMULH+MUL+carry vs single MUL)
- add/neg/half accounts for 24% (reduceSelf vs lazy magnitude tracking)
- Update performance numbers to Java 21 results (verify 3.4×, sign 1.1×)
- Document all optimizations implemented during this session
- Note that lazy reduction penalty (4.2× on cheap ops) is the main
remaining algorithmic opportunity, but requires 5×52-bit limb change
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
- Add `inline` to hot-path tiny functions: U256.isZero, U256.testBit,
FieldP.reduceSelf, FieldP.neg, MutablePoint.isInfinity. These are
called thousands of times per EC operation; inline eliminates virtual
call overhead (mostly helps Kotlin/Native; JVM JIT already inlines).
- Fix stale comment: G_TABLE_SIZE is 1024 for w=12 (was "64 for w=8")
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
inv() and sqrt() each allocated 11 LongArray(4) (plus 2 for sqrt
verification) on every call. These are now served from a thread-local
Array(11) { LongArray(4) } cache, eliminating 22-24 allocations per
ECDH operation (inv called in toAffine, sqrt called in liftX).
Also reuses chain scratch slots for sqrt's verification step instead
of allocating separate check/ar arrays.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Eliminates ~80 LongArray allocations per mul/mulDoubleG call by
pre-allocating the P-side Jacobian and affine tables, the doubling
temp, and the batch inversion scratch buffers in PointScratch
(thread-local, allocated once per thread, reused across calls).
Before: mul() allocated 8 MutablePoint (24 LongArray) + 8 MutablePoint
(24 LongArray) + 16 AffinePoint (32 LongArray) + batch temps = ~92
LongArray per call. After: 0 allocations in the table construction path.
Also fixes minor allocation in addMixed degenerate case (use t[5]
scratch instead of new LongArray(4)).
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
The JVM target is Java 21, so Math.unsignedMultiplyHigh (Java 18+) can
be called directly without MethodHandle reflection. The previous approach
used MethodHandle.invokeExact which Kotlin compiles with Object return
type, causing Long boxing on every call (3 box/unbox per invocation ×
16 calls per field multiply = 48 boxed objects per mul).
Direct call compiles to a single UMULH instruction with zero overhead.
This is the most performance-critical function: called ~12,000× per
signature verification.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Two microoptimizations:
1. reduceSelf: exploit P's structure (P[1..3] = 0xFFFFFFFFFFFFFFFF).
a >= P only if all top 3 limbs are max AND a[0] >= P[0]. The first
check (a[3] == -1) fails >99.99% of the time, making this a single
branch miss prediction instead of a 4-limb comparison loop.
Called ~1,300× per verify, ~500× per ECDH.
2. Pre-allocate wNAF IntArrays and scratch MutablePoint/LongArray in
PointScratch. Eliminates 8-12 IntArray(145) + 8-12 LongArray(4)
allocations per mul/mulDoubleG call. Adds wnafInto() to Glv that
writes into caller-provided arrays.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
pOdd and pLamOdd always have identical Z coordinates (the GLV
endomorphism λ(X,Y,Z) = (β·X, Y, Z) preserves Z). Previously we
called batchToAffine separately for each table, paying two full field
inversions (~270 field ops each). Now batchToAffinePair uses a single
batch inversion and reuses the Z⁻¹ values for both tables.
Saves ~270 field ops per mul/mulDoubleG call (~12% of ECDH cost).
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Update KDoc and file headers across Secp256k1.kt, Point.kt, FieldP.kt,
and U256.kt with:
- Accurate benchmark numbers (well-warmed: verify 4.4x, sign 1.5x,
pubCreate 2.2x, ECDH 3.9x, compress 2x FASTER)
- Detailed comparison with C libsecp256k1 architecture choices
- Explanation of why certain C optimizations don't port to JVM:
* Lazy reduction: 4x64 limbs have no headroom (C's 5x52 has 12 bits)
* safegcd: slower on JVM due to 128-bit arithmetic overhead
* WINDOW_G=15: cache pressure from heap-allocated objects (w=12 optimal)
- Document effective-affine technique and batch inversion
- Increase all benchmark warmup/iterations for stable measurements
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Tested WINDOW_G=15 (matching C libsecp256k1): 5.6x slower than native.
WINDOW_G=12 is faster at 4.3x because the 8192-entry table (1MB) at
w=15 causes cache pressure on JVM — heap-allocated AffinePoint objects
are scattered in memory, unlike C's contiguous compile-time .rodata.
WINDOW_G=12 (1024 entries, ~128KB) fits comfortably in L2 cache.
Increased verify benchmark warmup to 200+500 for stable measurements
(first call builds the lazy table).
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
The safegcd (Bernstein-Yang divsteps) algorithm is faster than Fermat
in C due to native 128-bit integer support, but on JVM the 128-bit
arithmetic overhead via multiplyHigh + carry tracking in the inner
loop (12 rounds × matrix multiply on 5 limbs) is slower than the
Fermat addition chain (255 sqr + 15 mul of optimized field ops).
Benchmark showed 8.3x vs native (was 5.0x with Fermat), confirming
that the per-operation constant factor matters more than algorithmic
complexity for this problem size on JVM.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Replace Fermat's little theorem (a^(p-2), 255 sqr + 15 mul) with the
safegcd divsteps algorithm for field element inversion. This processes
62 divsteps per batch using a 2×2 transition matrix applied to
full-precision values via 128-bit arithmetic (multiplyHigh).
12 rounds × 62 steps = 744 total divsteps (≥741 needed for 256 bits).
Uses 5×62-bit signed limb representation for intermediate values and
Montgomery-style correction (precomputed p^{-1} mod 2^62) for the
modular reduction in updateDE.
The old Fermat chain is preserved as FieldP.invFermat for reference.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
- Increase WINDOW_G from 8 to 12 for mulDoubleG (verify): reduces
G-side additions from ~32 to ~22 per verification (saves ~110 field ops)
- Add batchToAffine using Montgomery's trick: 1 inversion + 3(n-1) muls
instead of n individual inversions. Critical for the 1024-entry table.
- Table size: 1024 entries × 64 bytes = ~128KB (lazy, built on first use)
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
The publish KeyPackage button was always active because the app didn't
track whether a key package had already been published. This adds:
- hasActiveKeyPackages() to KeyPackageRotationManager and MarmotManager
- hasPublishedKeyPackage() to Account, checking both in-memory bundles
and the local cache for existing kind:30443 events
- Own key package filter in MarmotSubscriptionManager and the EOSE
manager so previously published key packages are downloaded from
relays on app restart
- UI feedback: primary-colored key icon when published, contextual
empty-state message, and a spinner during publishing
https://claude.ai/code/session_01BVe7aSEWd2KLi5Ks6RZkcc
- Add ECPoint.toAffineX that computes only x = X/Z² (saves 2M vs full
toAffine which also computes Y/Z³)
- Use toAffineX in ecdhXOnly since only the x-coordinate is needed
- Add ecdhXOnly benchmark measuring the actual Nostr ECDH production
path (Secp256k1Instance.pubKeyTweakMulCompact delegates to ecdhXOnly)
- Fix ktlint KDoc-inside-class-body violations
The old benchmark measured pubKeyTweakMul(02||x, key) which pays for:
array allocation, compressed key parsing (sqrt), full toAffine, and
re-serialization. ecdhXOnly avoids the array overhead and serialization.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
FieldP.mul/sqr were calling ThreadLocal.get() for every invocation (~500+
times per scalar multiplication, ~20-30ns each on JVM). Point operations
(doublePoint, addMixed, addPoints) each did an additional ThreadLocal.get()
for their scratch buffers.
Fix: add overloads that accept a pre-fetched wide buffer (LongArray(8))
and PointScratch. Top-level entry points (mulG, mul, mulDoubleG) fetch
the ThreadLocal once and thread it through all inner calls.
Results (ops/s, vs native JNI):
- pubkeyCreate: 19,163 → 29,205 (+52%, 3.0x → 2.2x)
- signSchnorr cached: 13,007 → 18,397 (+41%, 2.1x → 1.5x)
- signSchnorr: 5,365 → 7,490 (+40%, 5.7x → 3.7x)
- verifySchnorr: 3,840 → 4,873 (+27%, 7.2x → 5.4x)
- ECDH: 5,569 → 7,870 (+41%, 5.5x → 3.8x)
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
The 4×64-bit reduceWide in FieldP had a bug: round 2 carry propagation
could overflow past 256 bits when out[0..3] were all 0xFF...FF, silently
dropping the overflow. This caused field multiplication results to be
off by exactly C = 2^32 + 977, corrupting point arithmetic for specific
intermediate values (e.g. ECDH with scalar n-2 on small x-coordinates).
Fix: detect round-2 overflow and fold the extra bit (≡ C mod p) back in.
Also fix ktlint violations in ScalarN and update documentation.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Major progress on the LongArray(4) representation:
- U256.kt: all 25 tests pass (mulWide, sqrWide, serialization, bit ops)
- FieldP.kt: all 27 tests pass (add, sub, mul, sqr, half, inv, sqrt)
- ScalarN.kt: 17 of 19 tests pass — reduceWide has a bug for products
near n² (invMulIsOne and mulLargeScalars fail)
- Glv.kt: rewritten cleanly with correct 4-limb constants
- All test files updated for LongArray types and 4-element arrays
The reduceWide bug is in the overflow handling of the second round
hi×N_COMPLEMENT folding — needs careful unsigned Long carry tracking.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Progress on the LongArray(4) migration:
- ScalarN: constants converted to 4×64-bit, loop bounds fixed
- KeyCodec: B constant fixed
- Glv: constants partially converted but regex left residual old values
- Point: types fixed, GX/GY constants converted
- Secp256k1: parameter types updated
Still needs: manual cleanup of Glv constants, ScalarN reduceWide internals,
test hex() helpers, test constant arrays, wNAF bit manipulation for 64-bit limbs.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Bulk sed replacement of IntArray(8)→LongArray(4), IntArray(16)→LongArray(8),
intArrayOf→longArrayOf across all remaining files. This creates many compile
errors that need manual fixing:
- Type declarations still say IntArray where LongArray is needed
- Constants still have 8 values (32-bit) instead of 4 (64-bit)
- Loop bounds still reference 8 instead of 4
- toInt() casts on longArrayOf elements
- mulShift384 internals broken for new layout
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Rewrites FieldP to use the new 4×64-bit limb representation:
- All field operations (add, sub, mul, sqr, neg, half, inv, sqrt) now
operate on LongArray(4)
- reduceWide uses unsignedMultiplyHigh for the hi×C reduction step,
leveraging the hardware intrinsic on JVM
- Thread-local scratch is LongArray(8) instead of IntArray(16)
- Addition chains for inv/sqrt unchanged (same algorithm, new types)
The reduceWide is cleaner than the 8×32 version: since C = 2^32+977 < 2^33,
each hi[i]×C product fits in 97 bits, and unsignedMultiplyHigh gives the
upper 64 bits directly.
NOTE: Build still broken — ScalarN, Glv, Point, KeyCodec, Secp256k1,
and tests still expect IntArray(8).
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Foundation layer for the 4×64-bit limb optimization. This commit rewrites
U256 from IntArray(8) to LongArray(4) and adds platform-specific
Math.multiplyHigh for 64×64→128-bit products:
- JVM: Math.multiplyHigh intrinsic (single IMULH/SMULH instruction)
- Android API 31+: Math.multiplyHigh, fallback to pure Kotlin on older
- Native: pure Kotlin fallback (4 sub-products per multiplyHigh call)
The 4×64 representation reduces inner products from 64 to 16 per field
multiply on JVM, a potential ~1.5-2× speedup on the critical path.
NOTE: This commit intentionally breaks the build — FieldP, ScalarN, Glv,
Point, KeyCodec, Secp256k1, and all tests still expect IntArray(8).
They will be updated in subsequent commits.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Add three new test files for the Marmot MLS implementation:
- MlsGroupLifecycleTest: End-to-end lifecycle tests covering Welcome
processing, cross-member encrypt/decrypt, multi-member groups, commit
processing, external joins, PSK proposals, and ReInit proposals.
- MlsGroupEdgeCaseTest: Security boundary tests for wrong epoch rejection,
corrupted ciphertext detection, invalid KeyPackage rejection, out-of-range
leaf indices, empty/large message handling, and add/remove/add cycles.
- MlsConformanceTest: Cross-implementation comparison tests verifying
KeyPackage structure, GroupInfo signatures, Welcome message format,
HPKE seal/open, SignWithLabel/VerifyWithLabel, LeafNode signatures,
deterministic key schedule, and commit structure conformance.
Fix GroupInfo signature bug (RFC 9420 Section 12.4.3.1):
- buildWelcome() and groupInfo() were signing only groupContext.toTlsBytes()
but verifySignature() checked against encodeTbs() which includes
GroupContext + extensions + confirmationTag + signer. Now both methods
build an unsigned GroupInfo first and sign its full TBS encoding.
Enhance MlsGroupManager KDoc with usage examples, responsibility breakdown,
and cross-implementation notes.
8 tests are @Ignore'd documenting a known bug: processCommit() does not
derive the same epoch secrets as commit(), causing cross-member AEAD
failures after epoch transitions.
https://claude.ai/code/session_018f67fqNReg3dEXcDimYLY1
Extends the ThreadLocal SHA256 optimization to the streaming hash function.
The pool is retained for EventHasherSerializer and HashingByteArrayBuilder
which use its acquire/release pattern for long-lived incremental hashing.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
The SHA256 pool used ArrayBlockingQueue.take()/put() which acquire a lock
on every call. Profiling showed ~10µs synchronization overhead per SHA256
for ~2µs of actual hashing — the lock cost 5× more than the hash itself.
Replaced with ThreadLocal<MessageDigest> which gives each thread its own
instance with zero synchronization. MessageDigest.digest() implicitly
resets state, so no explicit reset is needed.
The pool is kept available for streaming use cases (hashStream) that need
incremental hashing with update()/digest() across multiple calls.
Only affects jvmAndroid — Apple uses CC_SHA256 directly, Linux uses
whyoleg CryptographyProvider. Both already have no pool overhead.
https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
Implement end-to-end reading of MarmotGroupData (extension 0xF2EE)
from the MLS GroupContext.extensions and sync to the UI layer.
Quartz (protocol):
- MarmotGroupData.decodeTls(): deserialize from TLS wire format
(version, nostrGroupId, name, description, admin pubkeys, relays,
image fields)
- MarmotGroupData.fromExtensions(): find and decode from extension list
- MlsGroup.extensions: expose groupContext.extensions publicly
Commons (shared logic):
- MarmotGroupChatroom: add description, adminPubkeys, relays fields
as MutableStateFlow for reactive UI
- MarmotManager.groupMetadata(): extract MIP-01 data from group
- MarmotManager.syncMetadataTo(): sync metadata + member count to
a MarmotGroupChatroom
Sync points (amethyst):
- Account init: sync metadata after restoreAll() for all restored groups
- GiftWrapEventHandler: sync after Welcome processing (group join)
- GroupEventHandler: sync after CommitProcessed (epoch advances may
update extensions via GroupContextExtensions proposals)
UI (GroupInfoScreen):
- Display group description from MIP-01 metadata
- Display relay URLs from MIP-01 metadata
- Mark admin members with "- admin" suffix (from adminPubkeys)
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM