Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion hosts/imports.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"Packages/AppDependencies/Package.resolved",
"polkadot-app.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
],
"ref": "bc99bdc551fc4d9beb755e575f211f6bd4a3de63",
"ref": "958fb7eb4cbb5deb5c802f5d69622562d6983282",
"source": "https://github.com/paritytech/polkadot-ios-community.git"
}
}
2 changes: 1 addition & 1 deletion hosts/ios/.claude/docs/architecture/chain-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ For reactive subscriptions, prefer `CallbackBatchStorageSubscription.asyncStream
## SCALE Codec

- `substrate-sdk-ios` provides SCALE encoding/decoding
- Use `Data(hexString:)` to convert hex strings to Data — the `Data` wrapper is preferred over the underlying `NSData(hexString:)` from NovaCrypto
- Use `hexString.fromHex()` (SubstrateSdkExt) to convert hex strings to Data. It wraps `Data(hexString:)` and reads left to right at the call site; both are preferred over the underlying `NSData(hexString:)` from NovaCrypto
- Use `toHex()` from SubstrateSdk for Data-to-hex conversion — prefer the `Data` wrapper over the underlying `NSData.toHexString` from NovaCrypto
- Implement `ScaleEncodable`/`ScaleDecodable` at the type level for reusability
- Use `Data.randomOrError` from SubstrateSdk for random/test data generation
Expand Down
11 changes: 11 additions & 0 deletions hosts/ios/.claude/docs/architecture/chat-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ Chat is a core feature composed of multiple sub-modules under `Modules/Chat/`. I
- `callCoordinator` manages call lifecycle
- See `architecture/data-transport.md` for transport layer details

### Push notification payload

After a message is posted to the statement store, the sender also pushes it through the relay
(`POST /api/v1/notify`) so the recipient's Notification Service Extension can render it without
opening a statement-store connection. The `message` field is `hex(ChaCha20-Poly1305(SCALE(payload)))`,
so hex doubles the size and an APNs alert (4 KB total) leaves under 2 KB of plaintext.

The payload is the push-only `Chat.NotificationPayload`: `messageId ‖ timestamp ‖ version u8 = 0 ‖ kind u8 ‖ content`, where
kind `0` is Stripped and `1` is Full. Full carries `RemoteMessageContentV1` unchanged. Stripped
(`Chat.StrippedContentV1`) is used when the full content exceeds 1800 bytes.

### Media attachment thumbnails

The optional `thumbnail: Data` field in image and video metadata contains a BlurHash string encoded as UTF-8. Senders generate the hash with 4×3 components from an image no larger than 128 points on its longest side. Receivers must parse the bytes through the typed `BlurHash` boundary before rendering. Invalid UTF-8, malformed BlurHash values, and legacy binary thumbnail bytes are treated as a missing preview; the full attachment download continues normally.
Expand Down
50 changes: 50 additions & 0 deletions hosts/ios/.claude/docs/architecture/coinage.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,54 @@ Transfer plans determine how coins are spent:
- Integrates with chat for payment request/confirmation messages
- See `architecture/chat-extension.md` for chat integration

## Submission Policies (retries)

A coinage transaction proven unable to land is **built again** rather than failed. Each of the three
retriable kinds registers a `DurableSubmissionPolicy` (see architecture/durable-transactions.md) at
`CoinageService.make`, keyed by id: `coinage-split`, `coinage-unload`, `coinage-claim`.

All three are one generic — `InputGatedSubmissionPolicy` — composed with a `CoinageRebuild` that only
knows how to read one kind of transaction back from the ledger, name its inputs, watch them and build
it. Deciding *when* to wait, build, give up or retry belongs to the policy alone, so that behaviour is
written and tested once.

- **Gate**: a rebuild waits until every input it spends is present on chain. `awaitInputs` holds out
30s once *some* inputs are visible (an input still landing is the ordinary reason a look is
incomplete), gives up a call after 5 minutes of nothing visible, and wakes at the deadline rather
than waiting on the chain. The newest look wins outright even when narrower — a fork can take an
input away, and building against the widest view ever seen would spend what is no longer there.
- **Presence**: coins come from the chain (`CoinOnChainQuerying.subscribeCoinInfos`, a storage
subscription whose accumulator drops a key that goes absent). Vouchers come from **our own rows**
(`DatabaseDependencyFactoring.makeTrackedVoucherSnapshotStream()`, filtered to `recycler != nil`) —
deliberately not a chain read, because those are the same rows the rebuild builds from, so the gate
and the build can never disagree. A chain read could say "in a recycler" while the row the call is
built from still has none, and the build would then fail on every attempt until location sync caught
up. A voucher counts as present while it sits in a recycler: that is where an unload proves it.
Either way a read that fails is never emitted as a look — it must not erase what the chain last
showed; a coin subscription that drops instead ends the wait, and the executor's backoff opens a
fresh one.
- **Params** (`CoinageSubmissionParams`) are SCALE and persisted with the row, so a shape change needs
a versioned decoder. A transfer carries `buildUntil` + `retryFailures`; a claim carries `retryUntil`
and the peer's key, which only the payment message holds. The transfer window is
`CoinageConstants.claimRetryWindow` (6h) — the same one the recipient's claim gets, so neither side
gives up while the other still tries.
- **Bounding**: `retryableFailure` lets an `.expired` attempt be rebuilt however late, but a
`.dispatchFailed` or `.rejected` one only while the window is open — nothing else stops a failure
that always repeats from being rebuilt for ever.
- **Building** is shared with the first attempt through the extracted declarers
(`SplitExtrinsicBuilder`, `UnloadExtrinsicBuilder`, `ClaimExtrinsicBuilder` over
`CoinageExtrinsicParts`), which go through `DurableTxServicing.buildExtrinsics` — one build path, not
two. An unload resolves a **fresh** free token and recycler revision on every build and notes the
quota only once extrinsics actually exist; it re-reads its vouchers after the look so each carries
the recycler location it is proven in now.
- **Claims register once.** `ClaimCoinsService` stops when every coin *has* a claim
(`coins − settled.receivedPublicKeys()`), not when every claim finalized. Rebuilding a failed claim
is the policy's job, into the coin that claim recorded — a claim retried into a fresh coin would
strand a payment already registered against the first one.

Recycling, voucher loading, offramp unloads and installation registration register with no policy and
keep failing terminally.

## External Payments (offramp)

`Packages/Coinage/Sources/ExternalPayment/` moves coins to a destination account for a product
Expand Down Expand Up @@ -153,4 +201,6 @@ Transfer plans determine how coins are spent:
| Contract calls | `Packages/Revive/` (see architecture/revive.md) | Runtime API encoding, revert handling, `EvmAddress` |
| Backup recovery | `Packages/Coinage/Sources/Backup/`, `CoinageBackupSyncService` | Scan rules, progress model, restored-balance card |
| Durability (oracle, asset ledger) | `Packages/Coinage/Sources/CoinageTx/` | Coin/voucher evidence or invariants; the engine itself is `Packages/DurableTransactions` (see architecture/durable-transactions.md) |
| Retry policies | `Packages/Coinage/Sources/CoinageTx/Submission/` | When a transaction is rebuilt, what it waits for, how long |
| Extrinsic declaration | `Packages/Coinage/Sources/Transfer/Plan/Builders/` | The call and origin of a split, unload or claim — shared by the first attempt and every rebuild |
| Instance ID config | `AppConfig.Coinage.instanceId` | Remote config schema or app instance strategy changes |
71 changes: 68 additions & 3 deletions hosts/ios/.claude/docs/architecture/durable-transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ Coinage (`Packages/Coinage/Sources/CoinageTx/`) is the first domain; installatio

| Component | Role |
|-----------|------|
| `DurableTxService` / `DurableTxServicing` | Builds, registers (atomically, with the domain's hook inside the same transaction) and submits; status and group streams; `start()` / `stop()` the head-driven recovery |
| `DurableTxRepositoryProtocol` | The ledger row, domain-neutral: id, `domainId`, sequence, group, tx hash, checkpoint, mortality, status, success record. Compare-and-set is the only status writer |
| `DurableTxService` / `DurableTxServicing` | Builds, registers (atomically, with the domain's hook inside the same transaction) and submits; **schedules** rows that have no extrinsic yet; status and group streams; `start()` / `stop()` the head-driven recovery and the builder |
| `DurableSubmissionPolicy` / `DurableSubmissionPolicyRegistry` | The write-side domain seam (below): builds a transaction outside the call that registered it, and again when an attempt is proven unable to land |
| `DurableSubmissionExecutor` | Actor. Watches the ledger's `pendingSubmission` rows, buckets them by `(policyId, groupId)`, one task per bucket, with backoff and a per-row rebuild cooldown |
| `DurableSubmissionLauncher` | Takes ownership of an attempt, writes it onto the row, and hands it to the tracker — the one path both a first submission and a rebuild go through |
| `DurableVerdictWriter` | The single writer of every status write. A `failure` whose policy answers `canRetry` becomes `pendingSubmission` instead |
| `DurableTxAttempt` | One attempt: `txHash`, `checkpoint`, `mortalityBlocks`, derived from a built extrinsic's own `CheckMortality` era |
| `DurableTxRepositoryProtocol` | The ledger row, domain-neutral: id, `domainId`, sequence, group, the current attempt (tx hash, checkpoint, mortality), status, success record, submission policy. Compare-and-set is the only status writer |
| `DurableTxRegistrationScope` | Marker for the store's open write transaction. A domain store writes its rows inside a hook receiving it and must throw `foreignRegistrationScope` for a scope of another store technology |
| `DurableTxTracker` | Follows one built extrinsic from submission; proposes verdicts through the same compare-and-set; releases ownership exactly once |
| `DurableTxOwnershipSet` | Volatile: which transactions a live submission owns, so a pass skips them |
| `DurableTxOwnershipSet` | Volatile: which *attempt* of which transaction a live submission owns, so a pass skips it. One-shot per attempt, so a rebuild is owned afresh |
| `DurableRecoveryPass` | One pinned view per chain, two rounds per domain (so a domain reasoning over other transactions' statuses sees round-one writes), CAS writes |
| `CompletionLadder` | Rules 0–5 (below) |
| `PinnedChainViewProtocol` / `PinnedChainViewFactory` | The generic chain reads at two pinned heads: block hash at height, block ref by hash, dispatch outcome, body search over a window. Keyed by `chainId` |
Expand Down Expand Up @@ -55,6 +60,59 @@ Coinage (`Packages/Coinage/Sources/CoinageTx/`) is the first domain; installatio
- `CoinageTxEntry` = engine `DurableTxEntry` + `inputs` / `outputs`. `CoinageTxId`, `CoinageTxStatus`,
`CoinageTxGroupId` are typealiases of the engine's types; `Coinage` re-exports `DurableTransactions`.

## Status and Attempts

`DurableTxStatus` has five cases. The two predicates over them are deliberately different:

- **`isLive`** — `pending`, `pendingSuccess`, `pendingSubmission`. The transaction holds whatever its
domain locked for it.
- **`awaitsVerdict`** — `pending`, `pendingSuccess`. Bytes were submitted and nothing has concluded
about them, so a recovery pass may decide it.

`pendingSubmission` is the gap between them: registered, locks held, nothing on the wire. It is the
executor's, not a pass's — a row with no attempt has no bytes, no window and no inclusion for any rule
to read. `getAllEntries(domain:)` therefore excludes such rows, so neither the pass nor any oracle sees
one.

A row's `txHash` / `checkpoint` / `mortality` describe **the current attempt**, not *the* extrinsic. A
rebuild overwrites them in place and keeps the row's id, group and the domain's rows — which is what
lets a payment made out of a reorged claim still land once the claim is built again. Every status write
is therefore a compare-and-set on `(status, txHash)`: a verdict about bytes already proven unable to
land can never be written onto the rebuilt attempt that replaced them.

A scheduled row still *is* a `DurableTxEntry`, carrying placeholder attempt fields
(`DurableTxSchedule.makeEntry`), so a caller watching its operation group sees the transaction exist and
waits for it rather than reading an empty group as a finished one. Those fields are meaningless until
`withAttempt(_:)` replaces them and nothing reads them while the status is `pendingSubmission`.

## The Seam: `DurableSubmissionPolicy`

The write-side counterpart of `TxCompletionOracle`:

```swift
public protocol DurableSubmissionPolicy: Sendable {
var chainId: ChainId { get }
func canRetry(_ entry: DurableTxEntry, params: Data, failure: DurableFailureKind) async -> Bool
func prepareSubmission(_ transactions: [ScheduledDurableTx])
async throws -> [DurableTxId: SubmissionPreparation] // .ready(ExtrinsicBuiltModel) | .giveUp
}
```

1. **`canRetry` must not read the chain.** It is asked while a verdict is being written. Whether a
rebuild is still *possible* belongs to `prepareSubmission`, which may suspend for as long as it needs.
2. **A failure kind bounds the loop.** `DurableFailureKind` is `.expired`, `.dispatchFailed` or
`.rejected`. An attempt that was simply never included may be rebuilt however late; one that was
dispatched and failed, or refused outright, would most likely fail the same way — nothing else stops
a repeating failure from being rebuilt for ever.
3. **A thrown error is never a verdict.** `prepareSubmission` throwing just means the call is made again
after a backoff; only `.giveUp` fails a transaction.
4. **`params` are opaque to the engine** and stored as given, so a policy owns their encoding *and its
evolution* — a shape change needs a versioned decoder for the rows already written.

Policies are registered into `DurableTxService.policies` before anything is scheduled. A row naming an
unregistered policy is abandoned by the executor rather than left waiting for ever, and a verdict for it
is written as the failure it already was.

## The Seam: `TxCompletionOracle`

```swift
Expand Down Expand Up @@ -135,6 +193,10 @@ attempt has settled without a `finalizedSuccess`.

1. **Terminal verdicts rest on finalized facts.** Only the finalized-bounded search, Rule 3 at F, and a
pre-submission validation refusal may write `failure`; only F-level evidence writes `finalizedSuccess`.
1b. **Every status write goes through `DurableVerdictWriter`.** It is the one place that offers a
failure back to the transaction's policy before it becomes terminal. A writer that bypasses it is a
path that can forget to retry. A policy that cannot be *read* fails the write rather than the
transaction: the verdict is re-derived next pass, while a failure written now could never be taken back.
2. **Unknown is never a verdict.** A failed read leaves a transaction undecided; every predicate is
positive-form.
3. **Rows are never deleted.** Terminal rows are history a domain's provenance reads still need.
Expand All @@ -148,6 +210,9 @@ attempt has settled without a `finalizedSuccess`.
| Seam | Where | When to touch |
|------|-------|---------------|
| Engine API | `Packages/DurableTransactions/Sources/DurableTransactions/Engine/DurableTxService.swift` | New engine capability every domain needs |
| Submission seam | `.../Engine/DurableSubmissionPolicy.swift` | Changing what a domain can build or rebuild |
| Builder pacing | `.../Engine/DurableSubmissionExecutor.swift` (`Timing`) | Backoff and rebuild cooldown |
| Verdict routing | `.../Engine/DurableVerdictWriter.swift` | How a failure is offered back to a policy |
| Ladder | `.../Engine/CompletionLadder.swift` | Only for a rule true of *any* transaction |
| Domain seam | `.../Oracle/TxCompletionOracle.swift` | Changing what a domain can tell the engine |
| Chain reads | `.../Chain/PinnedChainView*.swift` | New generic chain access |
Expand Down
8 changes: 8 additions & 0 deletions hosts/ios/.claude/docs/code/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ single coalesced in-flight task use `CoalescingTask`; for one typed guarded valu
`OSAllocatedUnfairLock<State>` is enough. (Real example: `IncomingPaymentService` +
`IncomingPaymentContext` in `Packages/Coinage`.)

## Injecting Time

Take `DateProviding` from `FoundationExt` (`NowDateProvider` in production) rather than a
`@Sendable () -> Date` closure. `read()` is `async`, so a caller must already be in an async context —
which is exactly where a wall-clock read belongs. Keep a `Clock` alongside it for *pacing* (sleeps,
timeouts): a clock instant cannot be compared against a deadline persisted on an earlier launch, and a
date cannot pace a sleep.

## Key Rules

0. **Check `StructuredConcurrency` and `AsyncExtensions` before writing any custom concurrency
Expand Down
39 changes: 39 additions & 0 deletions hosts/ios/.claude/docs/code/data-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,45 @@ try save(entity)

This is documented in CLAUDE.md and enforced in reviews.

### Concurrency modes

`CoreDataService` (Operation-iOS 3.0.0) takes a `concurrencyMode`. `CoreDataConcurrencyPolicy` names both
modes — `.app` is `.concurrent(readerConcurrency: 2)` (writer + observer + short-lived readers), and
`.notificationServiceExtension` is `.serial` (one context, 2.x behaviour) — and `forCurrentTarget` picks
between them **at compile time**: the extension declares `NOTIFICATION_SERVICE_EXTENSION` in
`NotificationServiceExtension/Configs/*.xcconfig`, the app declares nothing. A new target that links these
files must declare its own mode there; nothing is inferred from the running bundle. Rollback is one line in
that file.

| Entry point | Use for | Contract |
|---|---|---|
| `performWrite` | any mutation | one transaction: the service saves when the block leaves changes and rolls back on throw. **Never call `save()` / `rollback()` inside.** |
| `performRead` | one-shot fetches | runs on a reader context that may overlap the writer; never mutate |
| `performObserve` | fetched results controllers, long-lived observers | the observer context; merges every writer save automatically, never reset |
| `perform` (StructuredConcurrency) | legacy | writer context, caller saves; no call sites should remain |

Contracts worth knowing (Operation-iOS 3.0.0):

- A `performRead` block that mutates fails with `CoreDataServiceError.readLeftChanges`; it is not silently
discarded. Return plain values only — in concurrent mode the reader context is gone when the completion runs.
- `close()` drains in-flight work; anything arriving while it drains is rejected with `closeInProgress`, and
a `drop()` during the drain throws the same. A read's completion may close the service, a read's block may not.
- The configuration takes a `logger` (both facades pass `Logger.shared`). It surfaces diagnostics that cannot
be raised as errors, such as a row the mapper could not read or a remote delete with no tombstone.
- Cross-process deletes reach `CoreDataContextObservable` only if the entity's identifier attribute is marked
**Preserve After Deletion** in the model. No entity sets it today; the extension only inserts, so nothing is
lost. Mark it in the same version bump if the extension ever starts deleting rows.

Repositories already route fetches to readers and saves to the writer; `subscribeSnapshot` attaches to the
observer. Raw-context code goes through the async `performWrite` / `performRead` bridges in
`StructuredConcurrency`. See the library README section "Core Data concurrency modes".

`subscribeSnapshot` re-maps only the rows its fetched results controller reports as inserted, updated, moved
or deleted (mapped models are cached by object ID), so mapper cost is per change, not per subscriber × rows.
A row whose *related* objects change without the row itself changing is not re-mapped; derived-state
subscribers (coin and voucher state from `CDDurableTx`) get their refresh because the durable-tx repository
touches the parent rows (`CoinageTxRowObserver`). Do the same for any new relationship-derived mapper.

### Migration

- `Common/Storage/Migration/` — migration strategies
Expand Down
Loading
Loading