From 9855ad93b1cab97f7162db0abcecacad88e2a1a2 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 15:52:39 +0800 Subject: [PATCH 01/16] docs: add test-coverage and safe-fixes design spec Captures the scope, test plan, and pinned quirks for an upcoming unit-test coverage push (covering every modeled Operation, Asset edge cases, VIZEncoder primitives) plus the narrow set of behavior-preserving source corrections. Also adjusts .gitignore so design docs under docs/superpowers/ are tracked while generated docs under /docs remain ignored. --- .gitignore | 3 +- ...-16-test-coverage-and-safe-fixes-design.md | 166 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-05-16-test-coverage-and-safe-fixes-design.md diff --git a/.gitignore b/.gitignore index 4bfde22..1a1b81d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ xcuserdata/ Package.resolved /build -/docs +/docs/* +!/docs/superpowers/ /.gh-pages .swiftpm diff --git a/docs/superpowers/specs/2026-05-16-test-coverage-and-safe-fixes-design.md b/docs/superpowers/specs/2026-05-16-test-coverage-and-safe-fixes-design.md new file mode 100644 index 0000000..da2ea90 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-test-coverage-and-safe-fixes-design.md @@ -0,0 +1,166 @@ +# Test Coverage Expansion and Safe Fixes — Design + +**Date:** 2026-05-16 +**Status:** Approved, ready for implementation plan +**Scope:** unit-test additions to `Tests/UnitTests/` plus minimal, behavior-preserving source fixes + +## Background + +`viz-swift-lib` is a low-level Swift library for the VIZ blockchain. It was recently modernized (Swift 5.5+, async/await, `actor Client`, broad `Sendable` conformance). The unit-test suite currently has 56 tests, all passing, and covers core paths (key handling, signing, base58, sha2, transaction shape, a handful of operations, basic JSON-RPC error paths). Integration tests exist but hit a live node and are out of scope here. + +A walk-through of the source revealed gaps that this design addresses: + +- Only ~4 of 50+ `Operation` types have direct encode/decode tests. +- `Asset` string parsing has limited edge-case coverage. +- Several `VIZEncoder` primitive paths (`Bool`, `Date`, `Optional`, `Data`, varint boundaries) are exercised only indirectly. +- A few latent code-quality issues — silent decode fallbacks, asymmetric encode/decode dispatch, a dead debug `print`, a stale Linux-manifest typo — are not currently caught by tests. + +## Goals + +1. Cover every modeled `Operation` with tests appropriate to its support tier. +2. Strengthen `Asset` and `VIZEncoder` primitive coverage with explicit edge cases. +3. Pin currently-quirky behavior (silent fallbacks, encode asymmetries) in tests plus `// TODO:` source comments so future maintainers see them. +4. Apply only safe, behavior-preserving source corrections this round. +5. Keep the Linux test manifest in sync. + +## Non-goals + +- Restructuring `Operation.swift` (1,294 LOC) into multiple files. +- Replacing the hand-rolled api-namespace switch in `Client.swift`. +- Adding new RPC requests or modeling new operations (committee, proposal, paid subscription, etc.). +- Public-API signature changes. +- Integration-test changes. +- CI workflow changes (noted issue: `swift test -v` in the workflow currently runs integration tests against the live node — out of scope). + +## Approach + +**Approach A — extend in place** was selected over a per-domain fixture catalog or fully data-driven JSON fixtures. The existing test helpers in `Tests/UnitTests/Common.swift` (`AssertEncodes`, `AssertDecodes`, `TestDecode`) already support binary + JSON + string round-trips well; this is a coverage push, not a test-infra rewrite. Approach A minimizes review surface and matches existing conventions. + +We will introduce one small per-operation fixture helper inside `Tests/UnitTests/Operation.swift` for readability, but not a separate fixtures file. + +## Test additions + +### `Tests/UnitTests/Operation.swift` + +A `fileprivate` fixture helper: + +```swift +fileprivate struct OperationFixture { + let value: Op + let json: String // canonical snake_case JSON (operation body only) + let binary: Data // canonical binary hex (operation body only, + // without the OperationId varint prefix) +} +``` + +Three test groups, plus an asymmetry test: + +1. **Encode-supported operations — full round-trip** (~25 tests, one per op) + For each op currently handled in `AnyOperation.encode`: + - `AssertEncodes(fixture.value, fixture.binary)` — binary + - JSON-shape check against `fixture.json` + - `AssertDecodes(json: fixture.json, fixture.value)` + - JSON round-trip through `AnyOperation` (verifies op-id wrapping) + + Encode-supported ops (in encode dispatch order): `Vote`, `Content`, `Transfer`, `TransferToVesting`, `WithdrawVesting`, `AccountCreate`, `AccountUpdate`, `WitnessUpdate`, `AccountWitnessVote`, `AccountWitnessProxy`, `Custom`, `DeleteContent`, `SetWithdrawVestingRoute`, `RequestAccountRecovery`, `RecoverAccount`, `ChangeRecoveryAccount`, `EscrowTransfer`, `EscrowDispute`, `EscrowRelease`, `EscrowApprove`, `DelegateVestingShares`, `Award`, `ReceiveAward` (virtual), `BenefactorAward` (virtual), `InviteRegistration`. + +2. **Virtual / decode-only operations — JSON decode only** (~14 tests) + `AuthorReward`, `CurationReward`, `CommentReward`, `LiquidityReward`, `Interest`, `FillConvertRequest`, `FillVestingWithdraw`, `ShutdownWitness`, `FillOrder`, `FillTransferFromSavings`, `Hardfork`, `CommentPayoutUpdate`, `ReturnVestingDelegation`, `WitnessReward`. Each: feed an `AnyOperation` JSON containing the op id and verify the decoded `operation` is the expected struct with the expected field values. + +3. **Unknown-mapped operations — pin current decode-to-`Unknown` behavior** (~27 tests) + Op ids currently mapped to `Operation.Unknown()` in `AnyOperation.init(from:)`: `account_metadata`, `proposal_create`, `proposal_update`, `proposal_delete`, `chain_properties_update`, `content_payout_update`, `content_benefactor_reward`, `committee_worker_create_request`, `committee_worker_cancel_request`, `committee_vote_request`, `committee_cancel_request`, `committee_approve_request`, `committee_payout_request`, `committee_pay_request`, `use_invite_balance`, `expire_escrow_ratification`, `set_paid_subscription`, `paid_subscribe`, `paid_subscription_action`, `cancel_paid_subscription`, `set_account_price`, `set_subaccount_price`, `buy_account`, `account_sale`, `create_invite`, `claim_invite_balance`, `versioned_chain_properties_update`. Each test: decode the op-id-only JSON and assert the resulting `operation is Operation.Unknown`. This makes the "we don't model this yet" status load-bearing and visible. When any of these is later modeled, the corresponding test fails loudly. + +4. **`testEncodeAsymmetry`** (single test, multiple cases) + Documents the current asymmetric / broken encode paths: + - `Operation.CustomJson` encode → `XCTAssertThrowsError` (decode produces `CustomJson`, encode dispatches on `Operation.Custom`). + - `Operation.Convert` encode → `XCTAssertThrowsError` (no `OperationId.convert` case). + - Other ops defined as types but not in encode dispatch (`ReportOverProduction`, `CommentOptions`, `ChallengeAuthority`, `ProveAuthority`, `TransferToSavings`, `TransferFromSavings`, `CancelTransferFromSavings`, `CustomBinary`, `DeclineVotingRights`, `ResetAccount`, `SetResetAccount`, `ClaimRewardBalance`, `AccountCreateWithDelegation`) → `XCTAssertThrowsError` for each. + + Pinning these makes future fixes (adding the missing dispatch cases / `OperationId` rows) a deliberate, test-failure-driven change. + +### `Tests/UnitTests/Asset.swift` + +Add: + +- Malformed-string init returns `nil`: `""`, `"VIZ"`, `"10.000"`, `"10.000VIZ"`, `"abc VIZ"`, `"10..0 VIZ"`. +- Custom-symbol precision inference: `Asset("10.123456 FOO")` → precision 6; `Asset("10 FOO")` → precision 0; `Asset("10. FOO")` → precision 0 (empty fraction). +- Negative and zero amounts: `Asset(-1.5, .viz)`, `Asset(0, .viz)`. +- Description-precision exactness: `Asset(1, .viz).description == "1.000 VIZ"`, `Asset(1, .vests).description == "1.000000 VESTS"`. +- Binary encoding of `.vests` and `.custom(name:precision:)` with non-trivial precision and short names (padding to 7 bytes). +- Decode failure on `"not an asset"` JSON-string → `XCTAssertThrowsError`. + +### `Tests/UnitTests/VIZEncoder.swift` + +Add: + +- `Bool`: `true` → `Data("01")`, `false` → `Data("00")`. +- `Date` (`UInt32` little-endian seconds since 1970): epoch 0; a known timestamp matching existing fixtures. +- `Optional`: `.some(0xbeef)` → `01 ef be`; `.none` → `00`. +- `Data`: raw append, no length prefix — pin the current contract. +- `appendVarint` boundary values: `0`, `127`, `128`, `16383`, `16384`, `UInt64.max`. +- `Array` of size 200 (varint length prefix is multi-byte: `c8 01`). + +### `Tests/UnitTests/XCTestManifests.swift` + +- Add new test entries for every new method above. +- Fix the existing typo on line 118 (`SeemURLTest` → `VIZURLTest`). + +## Source changes (safe corrections) + +1. **`Sources/VIZ/Client.swift:233`** — delete the commented-out debug line: + ```swift + // print(String(data:urlRequest.httpBody!, encoding: .utf8)) + ``` + +That is the only source-code line changed in this round. Every other latent issue is *pinned* rather than fixed, with a `// TODO:` source comment. + +## Pinned quirks (test + source comment, NOT fixed) + +For each item below: a test documents current behavior; a one-line `// TODO:` comment is added at the source location explaining the deferred fix. + +| Issue | Location | Comment to add | +|---|---|---| +| `API.Share.init(from:)` silently returns `0` on string-parse failure | `Sources/VIZ/API.swift:79–87` | `// TODO: should throw DecodingError on parse failure instead of returning 0` | +| `custom` op id decodes as `CustomJson` but encode switch expects `Custom` | `Sources/VIZ/Operation.swift:1126, 1214` | `// TODO: encode dispatches on Operation.Custom, decode produces Operation.CustomJson — reconcile` | +| `Operation.Convert` has no matching `OperationId` case | `Sources/VIZ/Operation.swift:133–143` | `// TODO: no matching OperationId case — encoding falls through to default` | +| `PublicKey.AddressPrefix.testNet` stringifies to `"VIZ"` like `.mainNet` | `Sources/VIZ/PublicKey.swift:108–109` | `// VIZ has no separate testnet prefix; .testNet currently stringifies to "VIZ" (intentional)` | +| `ExtendedAccount.currentEnergy` reads `Date()` implicitly (not injectable) | `Sources/VIZ/API.swift:144–153` | `// TODO: take a Date parameter for testability` | + +## Linux / CI + +- `XCTestManifests.swift` is updated in lock-step with new test methods (hand-edited; `swift test --generate-linuxmain` is deprecated in current toolchains and produces the same shape). +- The `SeemURLTest` → `VIZURLTest` typo at line 118 of the manifest is corrected — this entry is inside `#if !os(macOS)`, so the typo currently silently disables `VIZURLTest` on Linux only. Fixing it adds 2 tests' worth of Linux coverage. +- `.github/workflows/tests.yml` runs `swift test -v` (all tests including integration). No workflow changes here; integration tests against `https://node.viz.cx` may flake the badge but that's a separate decision. + +## Estimated test deltas + +| Area | New tests | +|---|---| +| Operations — encode-supported round-trip | ~25 | +| Operations — virtual decode-only | ~14 | +| Operations — Unknown-mapped pin | ~27 | +| Operations — encode asymmetry (single test, many `XCTAssertThrowsError` cases) | 1 (covers ~15 ops) | +| Asset edge cases | ~12 | +| VIZEncoder primitives | ~8 | +| **Total** | **~87** | + +Bringing the suite from 56 → ~143 unit tests. + +## Success criteria + +- `swift test --filter UnitTests` passes locally. +- The GitHub Actions workflow stays green for `UnitTests` (integration-test flakiness is a separate concern). +- Every operation defined in `Sources/VIZ/Operation.swift` has at least one test referencing it by type, so dead/unmodeled operations become visible. +- Each pinned quirk has both a test and a `// TODO:` comment, so the next maintainer can grep for `TODO:` and find every known issue from this round. +- Total runtime of the unit suite remains under 1 second. + +## Open questions / deferred decisions + +These are intentionally not addressed this round; recording them so they survive into the next session: + +- Fix vs. remove for the asymmetric `Operation.CustomJson` / `Operation.Custom` pair (pick a single canonical type). +- Adding `OperationId.convert` and dispatch case for `Operation.Convert`. +- Modeling the ~15 currently-`Unknown` operation types. +- Splitting `Operation.swift` into per-category files. +- Replacing the `Client.api` method-namespace switch with a declarative table + tests. +- CI: filter the badge workflow to `--filter UnitTests` so it stops depending on live-node reachability. From 9ab7ce4f837917ab1db34a17dffdf4ea02fc90b2 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:09:18 +0800 Subject: [PATCH 02/16] Add Vladimir Babin to API.swift authors --- Sources/VIZ/API.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VIZ/API.swift b/Sources/VIZ/API.swift index 2cc972f..38a673b 100644 --- a/Sources/VIZ/API.swift +++ b/Sources/VIZ/API.swift @@ -1,13 +1,13 @@ /// VIZ RPC requests and responses. /// - Author: Johan Nordberg /// - Author: Iain Maitland +/// - Author: Vladimir Babin import Foundation /// VIZ RPC API request- and response-types. public struct API { - // https://github.com/VIZ-Blockchain/viz-cpp-node/blob/786afa351fb24bfa7353578bdf6a88e5687ee8ca/libraries/protocol/include/graphene/protocol/config.hpp#L49 public static let CHAIN_ENERGY_REGENERATION_SECONDS: Double = 5*60*60*24 // 5 days public struct DynamicGlobalProperties: Decodable, Sendable { From 05c438bfe0cd0ffec91449ca92944e524ef06f35 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:09:18 +0800 Subject: [PATCH 03/16] Add implementation plan for test coverage expansion and safe fixes --- ...2026-05-16-test-coverage-and-safe-fixes.md | 1336 +++++++++++++++++ 1 file changed, 1336 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-test-coverage-and-safe-fixes.md diff --git a/docs/superpowers/plans/2026-05-16-test-coverage-and-safe-fixes.md b/docs/superpowers/plans/2026-05-16-test-coverage-and-safe-fixes.md new file mode 100644 index 0000000..9209dc8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-test-coverage-and-safe-fixes.md @@ -0,0 +1,1336 @@ +# Test Coverage Expansion and Safe Fixes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the `viz-swift-lib` unit-test suite from 56 → ~143 tests by covering every modeled `Operation`, hardening `Asset` and `VIZEncoder` primitive coverage, and pinning known quirks with tests + `// TODO:` comments. Apply only one safe source correction (remove a dead `print` line). + +**Architecture:** Extend existing test files in place using the established `AssertEncodes` / `AssertDecodes` helpers in `Tests/UnitTests/Common.swift`. Introduce a single `fileprivate OperationFixture` helper inside `Tests/UnitTests/Operation.swift` to keep per-op round-trips compact. No new test files, no test-infra rewrites. + +**Tech Stack:** Swift 5.5+, XCTest, Swift Package Manager (`swift test`). + +**Spec:** `docs/superpowers/specs/2026-05-16-test-coverage-and-safe-fixes-design.md` + +--- + +## File Structure + +**Modified (source):** +- `Sources/VIZ/Client.swift` — remove one dead commented line +- `Sources/VIZ/API.swift` — add 2 `// TODO:` comments +- `Sources/VIZ/Operation.swift` — add 2 `// TODO:` comments +- `Sources/VIZ/PublicKey.swift` — add 1 explanatory `//` comment + +**Modified (tests):** +- `Tests/UnitTests/Operation.swift` — add fixture helper + ~57 new tests +- `Tests/UnitTests/Asset.swift` — add ~12 new tests +- `Tests/UnitTests/VIZEncoder.swift` — add ~8 new tests +- `Tests/UnitTests/XCTestManifests.swift` — add manifest entries, fix `SeemURLTest` → `VIZURLTest` typo + +**Created:** none. + +--- + +## Conventions used in this plan + +- All `swift test` runs are against the `UnitTests` target unless noted: `swift test --filter UnitTests`. +- For new binary-encode tests, the engineer writes a fixture with `Data("")` for `.binary`, runs the test once to capture the actual hex from the XCTAssertEqual failure, pastes the captured hex back, and re-runs to confirm pass. This is the **snapshot-pin** pattern used throughout this plan. Before pasting, do a quick sanity check: the hex should contain ASCII bytes of the embedded account names (e.g. `666f6f` for "foo"), recognizable asset suffixes (e.g. `56495a` for "VIZ"), and a length-prefix varint that matches the field count. If a fixture looks wrong on sight, stop and investigate before pinning. +- Existing tests in `Operation.swift` (the old `testEncodable` / `testDecodable`) stay untouched — new per-op tests are added alongside. + +--- + +## Task 1: Source corrections and TODO comments + +**Files:** +- Modify: `Sources/VIZ/Client.swift:233` +- Modify: `Sources/VIZ/API.swift:79-87` (add comment), `Sources/VIZ/API.swift:144-153` (add comment) +- Modify: `Sources/VIZ/Operation.swift:133` (add comment on Convert), `Sources/VIZ/Operation.swift:1126` (add comment on custom decode), `Sources/VIZ/Operation.swift:1214` (add comment on Custom encode) +- Modify: `Sources/VIZ/PublicKey.swift:107-109` (add explanatory comment) + +- [ ] **Step 1: Delete the dead `print` line in `Client.swift`** + +In `Sources/VIZ/Client.swift`, locate line 233 (inside `urlRequest(for:)`): + +```swift + urlRequest.httpBody = try encoder.encode(payload) +// print(String(data:urlRequest.httpBody!, encoding: .utf8)) + return urlRequest +``` + +Remove the commented-out `print` line entirely so the two remaining lines sit flush. + +- [ ] **Step 2: Add TODO on `API.Share.init(from:)`** + +In `Sources/VIZ/API.swift`, locate the `Share` struct (around line 72). Modify its `init(from:)`: + +```swift + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intValue = try? container.decode(Int64.self) { + self.value = intValue + } else { + // TODO: should throw DecodingError on parse failure instead of returning 0 + self.value = Int64(try container.decode(String.self)) ?? 0 + } + } +``` + +- [ ] **Step 3: Add TODO on `ExtendedAccount.currentEnergy`** + +In `Sources/VIZ/API.swift`, locate `currentEnergy` (around line 144). Add a comment line directly above the property declaration: + +```swift + // TODO: take a Date parameter for testability instead of reading Date() implicitly + public var currentEnergy: Int { + let deltaTime = Date().timeIntervalSince(lastVoteTime) +``` + +- [ ] **Step 4: Add TODO on `Operation.Convert`** + +In `Sources/VIZ/Operation.swift`, locate the `Convert` struct (around line 133). Add a comment above the struct: + +```swift + // TODO: no matching OperationId case — encoding falls through to default and throws + /// Convert operation. + public struct Convert: OperationType, Equatable { +``` + +- [ ] **Step 5: Add TODO on the `custom` decode / `Custom` encode asymmetry** + +In `Sources/VIZ/Operation.swift`, locate `AnyOperation.init(from:)` and find the `case .custom:` line (around line 1126). Update: + +```swift + // TODO: encode dispatches on Operation.Custom, decode produces Operation.CustomJson — reconcile + case .custom: op = try container.decode(Operation.CustomJson.self) +``` + +And in the same file, locate the encode dispatch `case let op as Operation.Custom:` (around line 1214). Add a comment above it: + +```swift + // TODO: paired with the decode-side TODO above — pick one canonical type for the `custom` op id + case let op as Operation.Custom: + try container.encode(OperationId.custom) + try container.encode(op) +``` + +- [ ] **Step 6: Add explanatory comment on `PublicKey.AddressPrefix`** + +In `Sources/VIZ/PublicKey.swift`, locate the `description` switch (around line 107). Add a comment above the `.testNet` case: + +```swift + switch self { + case .mainNet: + return "VIZ" + // VIZ has no separate testnet prefix; .testNet currently stringifies to "VIZ" (intentional) + case .testNet: + return "VIZ" + case let .custom(prefix): + return prefix.uppercased() + } +``` + +- [ ] **Step 7: Verify everything still builds and tests pass** + +Run: `swift test --filter UnitTests` +Expected: `Executed 56 tests, with 0 failures`. + +- [ ] **Step 8: Commit** + +```bash +git add Sources/VIZ/Client.swift Sources/VIZ/API.swift Sources/VIZ/Operation.swift Sources/VIZ/PublicKey.swift +git commit -m "chore: remove dead print line and pin known quirks with TODOs + +Deletes the commented-out debug print in Client.urlRequest and adds +TODO/explanatory comments at five known-quirk locations (Share decode +fallback, currentEnergy clock injection, Operation.Convert missing +OperationId, custom/CustomJson encode-decode asymmetry, testNet prefix +collision). No behavior changes." +``` + +--- + +## Task 2: OperationFixture helper + +**Files:** +- Modify: `Tests/UnitTests/Operation.swift` (append helper at top of file, after existing imports and before the existing fileprivate `vote` constant) + +- [ ] **Step 1: Add the fixture struct and round-trip helper** + +Open `Tests/UnitTests/Operation.swift`. After the `import XCTest` line and before the existing `fileprivate let vote = ...`, insert: + +```swift +fileprivate struct OperationFixture { + let value: Op + let json: String // canonical snake_case JSON of the operation body + let binary: Data // canonical binary hex of the operation body (no OperationId prefix) + let opIdName: String // string form of the op id, e.g. "vote", used for AnyOperation wrapping +} + +fileprivate func roundTrip(_ fixture: OperationFixture, file: StaticString = #file, line: UInt = #line) { + // 1. Binary encode + AssertEncodes(fixture.value, fixture.binary, file: file, line: line) + // 2. JSON decode + AssertDecodes(json: fixture.json, fixture.value, file: file, line: line) + // 3. AnyOperation round-trip: wrap [op_id, body] JSON, decode, expect matching op + let wrappedJSON = "[\"\(fixture.opIdName)\",\(fixture.json)]" + do { + let any = try TestDecode(AnyOperation.self, json: wrappedJSON) + guard let decoded = any.operation as? Op else { + XCTFail("AnyOperation decoded to \(type(of: any.operation)), expected \(Op.self)", file: file, line: line) + return + } + XCTAssertEqual(decoded, fixture.value, file: file, line: line) + } catch { + XCTFail("AnyOperation decode failed: \(error)", file: file, line: line) + } +} +``` + +- [ ] **Step 2: Verify the suite still builds and passes** + +Run: `swift test --filter UnitTests` +Expected: `Executed 56 tests, with 0 failures` (the new helper is unused so far). + +- [ ] **Step 3: Commit** + +```bash +git add Tests/UnitTests/Operation.swift +git commit -m "test: add OperationFixture helper and roundTrip assertion for op tests" +``` + +--- + +## Task 3: Round-trip tests for encode-supported operations + +**Files:** +- Modify: `Tests/UnitTests/Operation.swift` (add 25 new test methods inside `class OperationTest`) + +Per the spec, the encode-supported set is: `Vote`, `Content`, `Transfer`, `TransferToVesting`, `WithdrawVesting`, `AccountCreate`, `AccountUpdate`, `WitnessUpdate`, `AccountWitnessVote`, `AccountWitnessProxy`, `Custom`, `DeleteContent`, `SetWithdrawVestingRoute`, `RequestAccountRecovery`, `RecoverAccount`, `ChangeRecoveryAccount`, `EscrowTransfer`, `EscrowDispute`, `EscrowRelease`, `EscrowApprove`, `DelegateVestingShares`, `Award`, `ReceiveAward`, `BenefactorAward`, `InviteRegistration`. + +Two of those (`AccountUpdate`, `Award`) need shared helper values that already exist in `Tests/UnitTests/API.swift` and `Tests/IntegrationTests/API.swift`. Reuse the public-key strings from those files in your fixtures. + +The pattern below is **identical for every op** — only the fixture data differs. Do them one at a time, capturing the binary hex via the snapshot-pin workflow described in "Conventions". + +- [ ] **Step 1: Add a `testRoundTrip_vote` method** + +Inside `class OperationTest`, append: + +```swift + func testRoundTrip_vote() { + let fx = OperationFixture( + value: Operation.Vote(voter: "foo", author: "bar", permlink: "baz", weight: 1000), + json: "{\"voter\":\"foo\",\"author\":\"bar\",\"permlink\":\"baz\",\"weight\":1000}", + binary: Data(""), + opIdName: "vote" + ) + roundTrip(fx) + } +``` + +- [ ] **Step 2: Run and capture the binary hex** + +Run: `swift test --filter UnitTests.OperationTest/testRoundTrip_vote` +Expected: FAIL with a message like `XCTAssertEqual failed: ("03666f6f036261720362617ae803") is not equal to ("")`. + +Copy the actual hex (the left-hand side of the equality), and replace `Data("")` in the fixture with `Data("03666f6f036261720362617ae803")`. + +Sanity check: `666f6f` = "foo", `626172` = "bar", `62617a` = "baz", `e803` = 1000 little-endian. Good. + +- [ ] **Step 3: Re-run to confirm pass** + +Run: `swift test --filter UnitTests.OperationTest/testRoundTrip_vote` +Expected: `Test Case '-[UnitTests.OperationTest testRoundTrip_vote]' passed`. + +- [ ] **Step 4: Add the remaining 24 round-trip tests** + +For each operation below, repeat Steps 1-3 with the appropriate fixture. All test methods go inside `class OperationTest`. Naming: `testRoundTrip_`. + +For multi-line JSON / complex authority literals, reuse `PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")!` style helpers already used in `Tests/UnitTests/API.swift` and `Tests/UnitTests/Operation.swift`. + +Each fixture entry: + +```swift + func testRoundTrip_content() { + let fx = OperationFixture( + value: Operation.Content( + title: "Hello", + body: "World", + author: "alice", + permlink: "hello-world", + parentAuthor: "", + parentPermlink: "ru--general", + jsonMetadata: "{}" + ), + json: "{\"parent_author\":\"\",\"parent_permlink\":\"ru--general\",\"author\":\"alice\",\"permlink\":\"hello-world\",\"title\":\"Hello\",\"body\":\"World\",\"json_metadata\":\"{}\"}", + binary: Data(""), + opIdName: "content" + ) + roundTrip(fx) + } + + func testRoundTrip_transfer() { + let fx = OperationFixture( + value: Operation.Transfer(from: "foo", to: "bar", amount: Asset(10, .viz), memo: "baz"), + json: "{\"from\":\"foo\",\"to\":\"bar\",\"amount\":\"10.000 VIZ\",\"memo\":\"baz\"}", + binary: Data(""), + opIdName: "transfer" + ) + roundTrip(fx) + } + + func testRoundTrip_transferToVesting() { + let fx = OperationFixture( + value: Operation.TransferToVesting(from: "alice", to: "bob", amount: Asset(5, .viz)), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"amount\":\"5.000 VIZ\"}", + binary: Data(""), + opIdName: "transfer_to_vesting" + ) + roundTrip(fx) + } + + func testRoundTrip_withdrawVesting() { + let fx = OperationFixture( + value: Operation.WithdrawVesting(account: "alice", vestingShares: Asset(100, .vests)), + json: "{\"account\":\"alice\",\"vesting_shares\":\"100.000000 VESTS\"}", + binary: Data(""), + opIdName: "withdraw_vesting" + ) + roundTrip(fx) + } + + func testRoundTrip_accountCreate() { + let masterKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let activeKey = PublicKey("VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6")! + let regularKey = PublicKey("VIZ5oPsxWgfCH2FWqcXBWeeMmZoyBY5baiuV1vQWMxVVpYxEsJ6Hx")! + let memoKey = PublicKey("VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH")! + let fx = OperationFixture( + value: Operation.AccountCreate( + fee: Asset(10, .viz), + creator: "viz", + newAccountName: "newbie", + master: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[masterKey: 1]]), + active: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[activeKey: 1]]), + regular: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[regularKey: 1]]), + memoKey: memoKey, + jsonMetadata: "" + ), + json: "{\"fee\":\"10.000 VIZ\",\"creator\":\"viz\",\"new_account_name\":\"newbie\",\"master\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"active\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6\",1]]},\"regular\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ5oPsxWgfCH2FWqcXBWeeMmZoyBY5baiuV1vQWMxVVpYxEsJ6Hx\",1]]},\"memo_key\":\"VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH\",\"json_metadata\":\"\"}", + binary: Data(""), + opIdName: "account_create" + ) + roundTrip(fx) + } + + func testRoundTrip_accountUpdate() { + let masterKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let memoKey = PublicKey("VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH")! + let fx = OperationFixture( + value: Operation.AccountUpdate( + account: "alice", + master: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[masterKey: 1]]), + active: nil, + regular: nil, + memoKey: memoKey, + jsonMetadata: "" + ), + json: "{\"account\":\"alice\",\"master_is_set\":true,\"master\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"active_is_set\":false,\"active\":null,\"regular_is_set\":false,\"regular\":null,\"memo_key\":\"VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH\",\"json_metadata\":\"\"}", + binary: Data(""), + opIdName: "account_update" + ) + roundTrip(fx) + } + + func testRoundTrip_witnessUpdate() { + let signingKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let fx = OperationFixture( + value: Operation.WitnessUpdate( + owner: "alice", + url: "https://example.com", + blockSigningKey: signingKey, + props: Operation.WitnessUpdate.Properties(), + fee: Asset(0, .viz) + ), + json: "{\"owner\":\"alice\",\"url\":\"https:\\/\\/example.com\",\"block_signing_key\":\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",\"props\":{},\"fee\":\"0.000 VIZ\"}", + binary: Data(""), + opIdName: "witness_update" + ) + roundTrip(fx) + } + + func testRoundTrip_accountWitnessVote() { + let fx = OperationFixture( + value: Operation.AccountWitnessVote(account: "alice", witness: "witness1", approve: true), + json: "{\"account\":\"alice\",\"witness\":\"witness1\",\"approve\":true}", + binary: Data(""), + opIdName: "account_witness_vote" + ) + roundTrip(fx) + } + + func testRoundTrip_accountWitnessProxy() { + let fx = OperationFixture( + value: Operation.AccountWitnessProxy(account: "alice", proxy: "proxy1"), + json: "{\"account\":\"alice\",\"proxy\":\"proxy1\"}", + binary: Data(""), + opIdName: "account_witness_proxy" + ) + roundTrip(fx) + } + + func testRoundTrip_custom() { + let fx = OperationFixture( + value: Operation.Custom( + requiredRegularAuths: ["alice"], + requiredActiveAuths: [], + id: 7, + data: Data([0x01, 0x02, 0x03]) + ), + json: "{\"required_regular_auths\":[\"alice\"],\"required_active_auths\":[],\"id\":7,\"data\":\"010203\"}", + binary: Data(""), + opIdName: "custom" + ) + roundTrip(fx) + } +``` + +Continue with the remaining 14 ops following the same pattern: + +```swift + func testRoundTrip_deleteContent() { + let fx = OperationFixture( + value: Operation.DeleteContent(author: "alice", permlink: "post"), + json: "{\"author\":\"alice\",\"permlink\":\"post\"}", + binary: Data(""), + opIdName: "delete_content" + ) + roundTrip(fx) + } + + func testRoundTrip_setWithdrawVestingRoute() { + let fx = OperationFixture( + value: Operation.SetWithdrawVestingRoute(fromAccount: "alice", toAccount: "bob", percent: 5000, autoVest: false), + json: "{\"from_account\":\"alice\",\"to_account\":\"bob\",\"percent\":5000,\"auto_vest\":false}", + binary: Data(""), + opIdName: "set_withdraw_vesting_route" + ) + roundTrip(fx) + } + + func testRoundTrip_requestAccountRecovery() { + let masterKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let fx = OperationFixture( + value: Operation.RequestAccountRecovery( + recoveryAccount: "viz", + accountToRecover: "alice", + newOwnerAuthority: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[masterKey: 1]]), + extensions: [] + ), + json: "{\"recovery_account\":\"viz\",\"account_to_recover\":\"alice\",\"new_owner_authority\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"extensions\":[]}", + binary: Data(""), + opIdName: "request_account_recovery" + ) + roundTrip(fx) + } + + func testRoundTrip_recoverAccount() { + let newKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let oldKey = PublicKey("VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6")! + let fx = OperationFixture( + value: Operation.RecoverAccount( + accountToRecover: "alice", + newOwnerAuthority: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[newKey: 1]]), + recentOwnerAuthority: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[oldKey: 1]]), + extensions: [] + ), + json: "{\"account_to_recover\":\"alice\",\"new_owner_authority\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"recent_owner_authority\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6\",1]]},\"extensions\":[]}", + binary: Data(""), + opIdName: "recover_account" + ) + roundTrip(fx) + } + + func testRoundTrip_changeRecoveryAccount() { + let fx = OperationFixture( + value: Operation.ChangeRecoveryAccount( + accountToRecover: "alice", + newRecoveryAccount: "viz", + extensions: [] + ), + json: "{\"account_to_recover\":\"alice\",\"new_recovery_account\":\"viz\",\"extensions\":[]}", + binary: Data(""), + opIdName: "change_recovery_account" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowTransfer() { + let fx = OperationFixture( + value: Operation.EscrowTransfer( + from: "alice", + to: "bob", + agent: "carol", + escrowId: 1, + sbdAmount: Asset(0, .viz), + steemAmount: Asset(5, .viz), + fee: Asset(0.1, .viz), + ratificationDeadline: Date(timeIntervalSince1970: 1_700_000_000), + escrowExpiration: Date(timeIntervalSince1970: 1_700_086_400), + jsonMeta: "" + ), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"escrow_id\":1,\"sbd_amount\":\"0.000 VIZ\",\"steem_amount\":\"5.000 VIZ\",\"fee\":\"0.100 VIZ\",\"ratification_deadline\":\"2023-11-14T22:13:20\",\"escrow_expiration\":\"2023-11-15T22:13:20\",\"json_meta\":\"\"}", + binary: Data(""), + opIdName: "escrow_transfer" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowDispute() { + let fx = OperationFixture( + value: Operation.EscrowDispute(from: "alice", to: "bob", agent: "carol", who: "alice", escrowId: 1), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"who\":\"alice\",\"escrow_id\":1}", + binary: Data(""), + opIdName: "escrow_dispute" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowRelease() { + let fx = OperationFixture( + value: Operation.EscrowRelease( + from: "alice", to: "bob", agent: "carol", who: "carol", receiver: "bob", + escrowId: 1, sbdAmount: Asset(0, .viz), steemAmount: Asset(5, .viz) + ), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"who\":\"carol\",\"receiver\":\"bob\",\"escrow_id\":1,\"sbd_amount\":\"0.000 VIZ\",\"steem_amount\":\"5.000 VIZ\"}", + binary: Data(""), + opIdName: "escrow_release" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowApprove() { + let fx = OperationFixture( + value: Operation.EscrowApprove(from: "alice", to: "bob", agent: "carol", who: "carol", escrowId: 1, approve: true), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"who\":\"carol\",\"escrow_id\":1,\"approve\":true}", + binary: Data(""), + opIdName: "escrow_approve" + ) + roundTrip(fx) + } + + func testRoundTrip_delegateVestingShares() { + let fx = OperationFixture( + value: Operation.DelegateVestingShares(delegator: "alice", delegatee: "bob", vestingShares: Asset(1000, .vests)), + json: "{\"delegator\":\"alice\",\"delegatee\":\"bob\",\"vesting_shares\":\"1000.000000 VESTS\"}", + binary: Data(""), + opIdName: "delegate_vesting_shares" + ) + roundTrip(fx) + } + + func testRoundTrip_award() { + let fx = OperationFixture( + value: Operation.Award( + initiator: "alice", + receiver: "bob", + energy: 100, + customSequence: 0, + memo: "thanks", + beneficiaries: [Operation.Beneficiary(account: "carol", weight: 1000)] + ), + json: "{\"initiator\":\"alice\",\"receiver\":\"bob\",\"energy\":100,\"custom_sequence\":0,\"memo\":\"thanks\",\"beneficiaries\":[{\"account\":\"carol\",\"weight\":1000}]}", + binary: Data(""), + opIdName: "award" + ) + roundTrip(fx) + } + + func testRoundTrip_receiveAward() { + let fx = OperationFixture( + value: Operation.ReceiveAward( + initiator: "alice", + receiver: "bob", + customSequence: 0, + memo: "thanks", + shares: Asset(0.5, .vests) + ), + json: "{\"initiator\":\"alice\",\"receiver\":\"bob\",\"custom_sequence\":0,\"memo\":\"thanks\",\"shares\":\"0.500000 VESTS\"}", + binary: Data(""), + opIdName: "receive_award" + ) + roundTrip(fx) + } + + func testRoundTrip_benefactorAward() { + let fx = OperationFixture( + value: Operation.BenefactorAward( + initiator: "alice", + benefactor: "bob", + receiver: "carol", + customSequence: 0, + memo: "thanks", + shares: Asset(0.5, .vests) + ), + json: "{\"initiator\":\"alice\",\"benefactor\":\"bob\",\"receiver\":\"carol\",\"custom_sequence\":0,\"memo\":\"thanks\",\"shares\":\"0.500000 VESTS\"}", + binary: Data(""), + opIdName: "benefactor_award" + ) + roundTrip(fx) + } + + func testRoundTrip_inviteRegistration() { + let newKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let fx = OperationFixture( + value: Operation.InviteRegistration( + initiator: "invite", + newAccountName: "alice", + inviteSecret: "5KVvGJo9HGXoYBFiLbNqckJR8YxrRKApFjmL3PYWQeUNuaRZhXe", + newAccountKey: newKey + ), + json: "{\"initiator\":\"invite\",\"new_account_name\":\"alice\",\"invite_secret\":\"5KVvGJo9HGXoYBFiLbNqckJR8YxrRKApFjmL3PYWQeUNuaRZhXe\",\"new_account_key\":\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\"}", + binary: Data(""), + opIdName: "invite_registration" + ) + roundTrip(fx) + } +``` + +Apply the snapshot-pin workflow for each (run, copy hex, paste, re-run). + +**Special handling:** +- For `testRoundTrip_witnessUpdate`, the `Operation.WitnessUpdate.Properties` struct is empty (its fields are commented out). If the JSON shape differs (e.g. no `props` key at all), adjust the fixture JSON to match what the existing decoder/encoder actually does — discover via the snapshot-pin workflow on the decode side too: if `AssertDecodes` fails first, capture the message and reconcile. +- For `testRoundTrip_accountUpdate`, JSON encoding of optional `nil` authorities may serialize as `"master":null` or omit the key entirely depending on the encoder. Capture actual output and update the fixture JSON to match. +- If any round-trip test fails on the JSON-decode side instead of the binary side (e.g. snake_case key the encoder produces doesn't match what you wrote), use the failure message to correct your fixture JSON; then re-run. + +If a round-trip test reveals a more serious problem than a fixture-text mismatch (e.g. the binary encoder for some op throws), STOP and add the op to Task 5's asymmetry list instead. + +- [ ] **Step 5: Run the full operation test class** + +Run: `swift test --filter UnitTests.OperationTest` +Expected: all `testRoundTrip_*` methods pass plus the existing `testEncodable` / `testDecodable` / `testVirtual` (29 tests total in the class). + +- [ ] **Step 6: Commit** + +```bash +git add Tests/UnitTests/Operation.swift +git commit -m "test: add encode-supported operation round-trip coverage (25 ops) + +Adds testRoundTrip_ for every operation that AnyOperation.encode +dispatches on, verifying binary encode, JSON decode, and AnyOperation +wrapped JSON round-trip in one helper." +``` + +--- + +## Task 4: Virtual / decode-only operation tests + +**Files:** +- Modify: `Tests/UnitTests/Operation.swift` (add 14 new test methods) + +These ops are server-generated; clients only ever decode them. We test JSON decode via `AnyOperation` only. + +- [ ] **Step 1: Add a decode-only helper** + +Inside `Tests/UnitTests/Operation.swift`, just above `class OperationTest` (next to `roundTrip` helper), append: + +```swift +fileprivate func assertVirtualDecodes( + opIdName: String, + json body: String, + _ expected: Op, + file: StaticString = #file, + line: UInt = #line +) { + let wrappedJSON = "[\"\(opIdName)\",\(body)]" + do { + let any = try TestDecode(AnyOperation.self, json: wrappedJSON) + guard let decoded = any.operation as? Op else { + XCTFail("Decoded to \(type(of: any.operation)), expected \(Op.self)", file: file, line: line) + return + } + XCTAssertEqual(decoded, expected, file: file, line: line) + XCTAssertTrue(decoded.isVirtual, "Expected isVirtual == true", file: file, line: line) + } catch { + XCTFail("Decode failed: \(error)", file: file, line: line) + } +} +``` + +- [ ] **Step 2: Add the 14 virtual-decode tests** + +Append to `class OperationTest`: + +```swift + func testVirtualDecode_authorReward() { + assertVirtualDecodes( + opIdName: "author_reward", + json: "{\"author\":\"alice\",\"permlink\":\"post\",\"sbd_payout\":\"0.000 VIZ\",\"steem_payout\":\"1.000 VIZ\",\"vesting_payout\":\"0.500000 VESTS\"}", + Operation.AuthorReward( + author: "alice", + permlink: "post", + sbdPayout: Asset(0, .viz), + steemPayout: Asset(1, .viz), + vestingPayout: Asset(0.5, .vests) + ) + ) + } + + func testVirtualDecode_curationReward() { + assertVirtualDecodes( + opIdName: "curation_reward", + json: "{\"curator\":\"alice\",\"reward\":\"0.500000 VESTS\",\"comment_author\":\"bob\",\"comment_permlink\":\"post\"}", + Operation.CurationReward(curator: "alice", reward: Asset(0.5, .vests), commentAuthor: "bob", commentPermlink: "post") + ) + } + + func testVirtualDecode_commentReward() { + assertVirtualDecodes( + opIdName: "content_reward", + json: "{\"author\":\"alice\",\"permlink\":\"post\",\"payout\":\"1.000 VIZ\"}", + Operation.CommentReward(author: "alice", permlink: "post", payout: Asset(1, .viz)) + ) + } + + func testVirtualDecode_liquidityReward() { + // No matching OperationId enum case — AnyOperation cannot decode this on its own. + // Verify the struct decodes from a bare body JSON instead. + AssertDecodes( + json: "{\"owner\":\"alice\",\"payout\":\"1.000 VIZ\"}", + Operation.LiquidityReward(owner: "alice", payout: Asset(1, .viz)) + ) + } + + func testVirtualDecode_interest() { + // Same caveat as LiquidityReward: not reachable through AnyOperation today. + AssertDecodes( + json: "{\"owner\":\"alice\",\"interest\":\"1.000 VIZ\"}", + Operation.Interest(owner: "alice", interest: Asset(1, .viz)) + ) + } + + func testVirtualDecode_fillConvertRequest() { + AssertDecodes( + json: "{\"owner\":\"alice\",\"requestid\":1,\"amount_in\":\"1.000 VIZ\",\"amount_out\":\"1.000 VIZ\"}", + Operation.FillConvertRequest(owner: "alice", requestid: 1, amountIn: Asset(1, .viz), amountOut: Asset(1, .viz)) + ) + } + + func testVirtualDecode_fillVestingWithdraw() { + assertVirtualDecodes( + opIdName: "fill_vesting_withdraw", + json: "{\"from_account\":\"alice\",\"to_account\":\"alice\",\"withdrawn\":\"1.000000 VESTS\",\"deposited\":\"1.000 VIZ\"}", + Operation.FillVestingWithdraw(fromAccount: "alice", toAccount: "alice", withdrawn: Asset(1, .vests), deposited: Asset(1, .viz)) + ) + } + + func testVirtualDecode_shutdownWitness() { + assertVirtualDecodes( + opIdName: "shutdown_witness", + json: "{\"owner\":\"alice\"}", + Operation.ShutdownWitness(owner: "alice") + ) + } + + func testVirtualDecode_fillOrder() { + AssertDecodes( + json: "{\"current_owner\":\"alice\",\"current_orderid\":1,\"current_pays\":\"1.000 VIZ\",\"open_owner\":\"bob\",\"open_orderid\":2,\"open_pays\":\"1.000 VIZ\"}", + Operation.FillOrder(currentOwner: "alice", currentOrderid: 1, currentPays: Asset(1, .viz), openOwner: "bob", openOrderid: 2, openPays: Asset(1, .viz)) + ) + } + + func testVirtualDecode_fillTransferFromSavings() { + AssertDecodes( + json: "{\"from\":\"alice\",\"to\":\"bob\",\"amount\":\"1.000 VIZ\",\"request_id\":1,\"memo\":\"\"}", + Operation.FillTransferFromSavings(from: "alice", to: "bob", amount: Asset(1, .viz), requestId: 1, memo: "") + ) + } + + func testVirtualDecode_hardfork() { + assertVirtualDecodes( + opIdName: "hardfork", + json: "{\"hardfork_id\":4}", + Operation.Hardfork(hardforkId: 4) + ) + } + + func testVirtualDecode_commentPayoutUpdate() { + AssertDecodes( + json: "{\"author\":\"alice\",\"permlink\":\"post\"}", + Operation.CommentPayoutUpdate(author: "alice", permlink: "post") + ) + } + + func testVirtualDecode_returnVestingDelegation() { + assertVirtualDecodes( + opIdName: "return_vesting_delegation", + json: "{\"account\":\"alice\",\"vesting_shares\":\"1.000000 VESTS\"}", + Operation.ReturnVestingDelegation(account: "alice", vestingShares: Asset(1, .vests)) + ) + } + + func testVirtualDecode_witnessReward() { + assertVirtualDecodes( + opIdName: "witness_reward", + json: "{\"witness\":\"alice\",\"shares\":\"0.500000 VESTS\"}", + Operation.WitnessReward(witness: "alice", shares: Asset(0.5, .vests)) + ) + } +``` + +Note: `LiquidityReward`, `Interest`, `FillOrder`, `FillTransferFromSavings`, `CommentPayoutUpdate` have no matching `OperationId` enum entries that route through `AnyOperation`, so they're tested via `AssertDecodes` directly on the struct rather than `assertVirtualDecodes`. These tests document that the structs themselves decode correctly even though they can't currently arrive through the normal block-history path. If any of these *do* now decode through `AnyOperation` (check `Sources/VIZ/Operation.swift` `AnyOperation.init(from:)`), prefer `assertVirtualDecodes`. + +- [ ] **Step 3: Run virtual tests** + +Run: `swift test --filter UnitTests.OperationTest/testVirtualDecode` +Expected: all 14 tests pass. + +If any fail with "Decoded to Operation.Unknown, expected ...", that's because the op id isn't dispatched in `AnyOperation.init(from:)`. Move that test to `AssertDecodes`-only style (no `AnyOperation` wrapping), like the LiquidityReward example. + +- [ ] **Step 4: Commit** + +```bash +git add Tests/UnitTests/Operation.swift +git commit -m "test: add virtual/decode-only operation coverage (14 ops)" +``` + +--- + +## Task 5: Unknown-mapped operation pin tests + +**Files:** +- Modify: `Tests/UnitTests/Operation.swift` (one new test method covering all 27 Unknown-mapped op ids) + +- [ ] **Step 1: Add the pin test** + +Append to `class OperationTest`: + +```swift + func testUnknownMappedOps_decodeAsUnknown() { + // These op ids exist in OperationId but AnyOperation.init(from:) maps them to Operation.Unknown. + // This test pins that current behavior — when any of these is later modeled, the corresponding + // line will fail and force a deliberate, test-driven update. + let opIds: [String] = [ + "account_metadata", + "proposal_create", + "proposal_update", + "proposal_delete", + "chain_properties_update", + "content_payout_update", + "content_benefactor_reward", + "committee_worker_create_request", + "committee_worker_cancel_request", + "committee_vote_request", + "committee_cancel_request", + "committee_approve_request", + "committee_payout_request", + "committee_pay_request", + "use_invite_balance", + "expire_escrow_ratification", + "set_paid_subscription", + "paid_subscribe", + "paid_subscription_action", + "cancel_paid_subscription", + "set_account_price", + "set_subaccount_price", + "buy_account", + "account_sale", + "create_invite", + "claim_invite_balance", + "versioned_chain_properties_update", + ] + + for opIdName in opIds { + // Body is an empty object; any non-Unknown decode would have to read fields and fail. + // Unknown is an empty struct so an empty body decodes cleanly. + let json = "[\"\(opIdName)\",{}]" + do { + let any = try TestDecode(AnyOperation.self, json: json) + XCTAssertTrue( + any.operation is Operation.Unknown, + "Expected \(opIdName) to decode as Operation.Unknown, got \(type(of: any.operation))" + ) + } catch { + XCTFail("Decode of \(opIdName) failed: \(error)") + } + } + } +``` + +- [ ] **Step 2: Run** + +Run: `swift test --filter UnitTests.OperationTest/testUnknownMappedOps_decodeAsUnknown` +Expected: PASS. + +If a line fails with "Expected X to decode as Operation.Unknown, got Operation.Y", it means a previously-`Unknown` op has been modeled since this plan was written — remove that op id from the list and add a positive decode test for it instead. + +- [ ] **Step 3: Commit** + +```bash +git add Tests/UnitTests/Operation.swift +git commit -m "test: pin Unknown-mapped operation decode behavior (27 op ids)" +``` + +--- + +## Task 6: Encode asymmetry test + +**Files:** +- Modify: `Tests/UnitTests/Operation.swift` (one new test method) + +- [ ] **Step 1: Add the asymmetry pin test** + +Append to `class OperationTest`: + +```swift + func testEncodeAsymmetry_definedButNotEncodable() { + // These operation types are defined in Operation.swift but AnyOperation.encode does not dispatch + // on them (or dispatches incorrectly). Each wrap-and-encode attempt is expected to throw today. + // When any of these is fixed (added to the encode switch or paired correctly with an OperationId), + // the corresponding XCTAssertThrowsError line will fail and force this test to be updated. + + // 1. Operation.CustomJson — decode side produces it for the `custom` op id, but encode dispatches + // on Operation.Custom. So wrapping a CustomJson and encoding throws. + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CustomJson( + requiredAuths: [], requiredPostingAuths: ["alice"], id: "test", json: "{}" + ))), "CustomJson") + + // 2. Operation.Convert — no OperationId.convert case exists. + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.Convert( + owner: "alice", requestid: 1, amount: Asset(1, .viz) + ))), "Convert") + + // 3. Operations defined as types but missing from the encode dispatch switch. + let pubKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let auth = Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[pubKey: 1]]) + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CommentOptions( + author: "alice", permlink: "post", maxAcceptedPayout: Asset(100, .viz), + percentSteemDollars: 10000, allowVotes: true, allowCurationRewards: true, extensions: [] + ))), "CommentOptions") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ChallengeAuthority( + challenger: "alice", challenged: "bob", requireOwner: false + ))), "ChallengeAuthority") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ProveAuthority( + challenged: "alice", requireOwner: false + ))), "ProveAuthority") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.TransferToSavings( + from: "alice", to: "bob", amount: Asset(1, .viz), memo: "" + ))), "TransferToSavings") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.TransferFromSavings( + from: "alice", requestId: 1, to: "bob", amount: Asset(1, .viz), memo: "" + ))), "TransferFromSavings") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CancelTransferFromSavings( + from: "alice", requestId: 1 + ))), "CancelTransferFromSavings") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CustomBinary( + requiredOwnerAuths: [], requiredActiveAuths: [], requiredPostingAuths: [], + requiredAuths: [], id: "test", data: Data() + ))), "CustomBinary") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.DeclineVotingRights( + account: "alice", decline: true + ))), "DeclineVotingRights") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ResetAccount( + resetAccount: "alice", accountToReset: "bob", newOwnerAuthority: auth + ))), "ResetAccount") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.SetResetAccount( + account: "alice", currentResetAccount: "bob", resetAccount: "carol" + ))), "SetResetAccount") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ClaimRewardBalance( + account: "alice", rewardSteem: Asset(1, .viz), rewardSbd: Asset(0, .viz), rewardVests: Asset(0.5, .vests) + ))), "ClaimRewardBalance") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.AccountCreateWithDelegation( + fee: Asset(1, .viz), delegation: Asset(0, .vests), + creator: "alice", newAccountName: "bob", + master: auth, active: auth, regular: auth, memoKey: pubKey + ))), "AccountCreateWithDelegation") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ReportOverProduction( + reporter: "alice", + firstBlock: SignedBlockHeader( + previous: BlockId(from: try JSONDecoder().decode(BlockId.self, from: Data("\"00000000000000000000000000000000\"".utf8))), + timestamp: Date(timeIntervalSince1970: 0), + witness: "w", + transactionMerkleRoot: Data(), + extensions: [], + witnessSignature: Signature(signature: Data(count: 64), recoveryId: 0) + ), + secondBlock: SignedBlockHeader( + previous: BlockId(from: try JSONDecoder().decode(BlockId.self, from: Data("\"00000000000000000000000000000000\"".utf8))), + timestamp: Date(timeIntervalSince1970: 0), + witness: "w", + transactionMerkleRoot: Data(), + extensions: [], + witnessSignature: Signature(signature: Data(count: 64), recoveryId: 0) + ) + ))), "ReportOverProduction") + } +``` + +The `ReportOverProduction` construction is awkward because `BlockId` only has a `Decodable` initializer. If this proves too brittle, replace that one `XCTAssertThrowsError` with a comment noting "ReportOverProduction skipped — BlockId has no public initializer; covered indirectly by the encode-dispatch switch's lack of a case." + +- [ ] **Step 2: Run** + +Run: `swift test --filter UnitTests.OperationTest/testEncodeAsymmetry_definedButNotEncodable` +Expected: PASS (every `XCTAssertThrowsError` succeeds because `AnyOperation.encode` reaches its `default:` branch for these ops). + +- [ ] **Step 3: Commit** + +```bash +git add Tests/UnitTests/Operation.swift +git commit -m "test: pin encode asymmetry for ops missing from AnyOperation.encode dispatch" +``` + +--- + +## Task 7: Asset edge-case tests + +**Files:** +- Modify: `Tests/UnitTests/Asset.swift` (add new test methods to `class AssetTest`) + +- [ ] **Step 1: Add the edge-case tests** + +Append to `class AssetTest`: + +```swift + // MARK: - String parsing edge cases + + func testMalformedStringsReturnNil() { + XCTAssertNil(Asset("")) + XCTAssertNil(Asset("VIZ")) + XCTAssertNil(Asset("10.000")) + XCTAssertNil(Asset("10.000VIZ")) + XCTAssertNil(Asset("abc VIZ")) + XCTAssertNil(Asset("10..0 VIZ")) + } + + func testCustomSymbolPrecisionInference() { + let a = Asset("10.123456 FOO") + XCTAssertEqual(a?.symbol, .custom(name: "FOO", precision: 6)) + + let b = Asset("10 FOO") + XCTAssertEqual(b?.symbol, .custom(name: "FOO", precision: 0)) + + let c = Asset("10. FOO") + XCTAssertEqual(c?.symbol, .custom(name: "FOO", precision: 0)) + } + + func testNegativeAndZero() { + let neg = Asset(-1.5, .viz) + XCTAssertEqual(neg.description, "-1.500 VIZ") + XCTAssertEqual(neg.resolvedAmount, -1.5) + + let zero = Asset(0, .viz) + XCTAssertEqual(zero.description, "0.000 VIZ") + XCTAssertEqual(zero.resolvedAmount, 0.0) + } + + func testDescriptionExactPrecision() { + XCTAssertEqual(Asset(1, .viz).description, "1.000 VIZ") + XCTAssertEqual(Asset(1, .vests).description, "1.000000 VESTS") + XCTAssertEqual(Asset(1, .custom(name: "FOO", precision: 2)).description, "1.00 FOO") + } + + // MARK: - Binary encoding for non-VIZ symbols + + func testEncodeVestsBinary() { + AssertEncodes(Asset(1, .vests), Data("")) + } + + func testEncodeCustomShortSymbolBinary() { + AssertEncodes(Asset(1, .custom(name: "FOO", precision: 2)), Data("")) + } + + // MARK: - Decode failure + + func testDecodeFailsOnMalformedString() { + do { + _ = try TestDecode(Asset.self, string: "not an asset") + XCTFail("Expected DecodingError") + } catch is DecodingError { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } +``` + +- [ ] **Step 2: Capture binary fixtures** + +Run: `swift test --filter UnitTests.AssetTest` +Expected: `testEncodeVestsBinary` and `testEncodeCustomShortSymbolBinary` FAIL with the actual hex strings. + +Snapshot-pin both: copy the actual hex from each failure into `Data("...")`. + +Sanity check for vests: +- amount `1.000000 VESTS` → `40420f0000000000` (1_000_000 little-endian Int64) +- precision byte: `06` +- symbol "VESTS" + 2 NUL pad → `5645535453 0000` + +Sanity check for "FOO": +- amount `1.00 FOO` → `64000000 00000000` (100 little-endian) +- precision byte: `02` +- symbol "FOO" + 4 NUL pad → `464f4f 00000000` + +- [ ] **Step 3: Re-run** + +Run: `swift test --filter UnitTests.AssetTest` +Expected: all tests pass (existing 4 + 7 new = 11 total). + +- [ ] **Step 4: Commit** + +```bash +git add Tests/UnitTests/Asset.swift +git commit -m "test: add Asset string-parsing, precision, and decode-error coverage" +``` + +--- + +## Task 8: VIZEncoder primitive tests + +**Files:** +- Modify: `Tests/UnitTests/VIZEncoder.swift` (add new test methods to `class VIZEncoderTest`) + +- [ ] **Step 1: Add the primitive tests** + +Append to `class VIZEncoderTest`: + +```swift + func testBool() { + AssertEncodes(true, Data("01")) + AssertEncodes(false, Data("00")) + } + + func testDate() { + // UInt32 little-endian seconds since 1970 + AssertEncodes(Date(timeIntervalSince1970: 0), Data("00000000")) + // 1700000000 = 0x65525400 → little-endian: 00 54 52 65 + AssertEncodes(Date(timeIntervalSince1970: 1_700_000_000), Data("00545265")) + } + + func testOptional() { + AssertEncodes(Optional.some(0xbeef), Data("01efbe")) + AssertEncodes(Optional.none, Data("00")) + } + + func testRawData() { + // Data appends raw bytes with NO length prefix (pinned behavior). + AssertEncodes(Data([0x01, 0x02, 0x03]), Data("010203")) + } + + func testVarintBoundaries() { + // appendVarint is internal — use a String which calls it for its length prefix. + // 0-byte string → length varint of 0 → 0x00 + AssertEncodes("", Data("00")) + // 127-byte string → length varint of 127 → 0x7f + let s127 = String(repeating: "a", count: 127) + AssertEncodes(s127, Data("7f" + String(repeating: "61", count: 127))) + // 128-byte string → length varint of 128 → 0x80 0x01 + let s128 = String(repeating: "a", count: 128) + AssertEncodes(s128, Data("8001" + String(repeating: "61", count: 128))) + } + + func testLargeArrayVarintLength() { + // 200 UInt16 elements → length prefix is c8 01 (200), followed by 400 bytes of element data. + let arr = Array(repeating: UInt16(0), count: 200) + let expectedHex = "c801" + String(repeating: "0000", count: 200) + AssertEncodes(arr, Data(expectedHex)) + } +``` + +- [ ] **Step 2: Run** + +Run: `swift test --filter UnitTests.VIZEncoderTest` +Expected: all pass (existing 4 + 6 new = 10 total). + +If `testDate` fails, the issue is endianness in the comment math — snapshot-pin the actual hex output and update the literal. Same for `testVarintBoundaries` if your understanding of the encoded length disagrees with reality. + +- [ ] **Step 3: Commit** + +```bash +git add Tests/UnitTests/VIZEncoder.swift +git commit -m "test: add Bool/Date/Optional/Data/varint coverage for VIZEncoder" +``` + +--- + +## Task 9: Update XCTestManifests for Linux + +**Files:** +- Modify: `Tests/UnitTests/XCTestManifests.swift` + +- [ ] **Step 1: Fix the `SeemURLTest` typo** + +In `Tests/UnitTests/XCTestManifests.swift`, locate line 118 inside the `#if !os(macOS)` block: + +```swift + testCase(SeemURLTest.__allTests), +``` + +Replace with: + +```swift + testCase(VIZURLTest.__allTests), +``` + +- [ ] **Step 2: Update `OperationTest.__allTests`** + +Locate `extension OperationTest` (around line 33). Replace its `__allTests` array with the full list including the 25 round-trip, 14 virtual-decode, 1 unknown-pin, and 1 encode-asymmetry tests: + +```swift +extension OperationTest { + static let __allTests = [ + ("testDecodable", testDecodable), + ("testEncodable", testEncodable), + ("testEncodeAsymmetry_definedButNotEncodable", testEncodeAsymmetry_definedButNotEncodable), + ("testRoundTrip_accountCreate", testRoundTrip_accountCreate), + ("testRoundTrip_accountUpdate", testRoundTrip_accountUpdate), + ("testRoundTrip_accountWitnessProxy", testRoundTrip_accountWitnessProxy), + ("testRoundTrip_accountWitnessVote", testRoundTrip_accountWitnessVote), + ("testRoundTrip_award", testRoundTrip_award), + ("testRoundTrip_benefactorAward", testRoundTrip_benefactorAward), + ("testRoundTrip_changeRecoveryAccount", testRoundTrip_changeRecoveryAccount), + ("testRoundTrip_content", testRoundTrip_content), + ("testRoundTrip_custom", testRoundTrip_custom), + ("testRoundTrip_delegateVestingShares", testRoundTrip_delegateVestingShares), + ("testRoundTrip_deleteContent", testRoundTrip_deleteContent), + ("testRoundTrip_escrowApprove", testRoundTrip_escrowApprove), + ("testRoundTrip_escrowDispute", testRoundTrip_escrowDispute), + ("testRoundTrip_escrowRelease", testRoundTrip_escrowRelease), + ("testRoundTrip_escrowTransfer", testRoundTrip_escrowTransfer), + ("testRoundTrip_inviteRegistration", testRoundTrip_inviteRegistration), + ("testRoundTrip_receiveAward", testRoundTrip_receiveAward), + ("testRoundTrip_recoverAccount", testRoundTrip_recoverAccount), + ("testRoundTrip_requestAccountRecovery", testRoundTrip_requestAccountRecovery), + ("testRoundTrip_setWithdrawVestingRoute", testRoundTrip_setWithdrawVestingRoute), + ("testRoundTrip_transfer", testRoundTrip_transfer), + ("testRoundTrip_transferToVesting", testRoundTrip_transferToVesting), + ("testRoundTrip_vote", testRoundTrip_vote), + ("testRoundTrip_withdrawVesting", testRoundTrip_withdrawVesting), + ("testRoundTrip_witnessUpdate", testRoundTrip_witnessUpdate), + ("testUnknownMappedOps_decodeAsUnknown", testUnknownMappedOps_decodeAsUnknown), + ("testVirtual", testVirtual), + ("testVirtualDecode_authorReward", testVirtualDecode_authorReward), + ("testVirtualDecode_commentPayoutUpdate", testVirtualDecode_commentPayoutUpdate), + ("testVirtualDecode_commentReward", testVirtualDecode_commentReward), + ("testVirtualDecode_curationReward", testVirtualDecode_curationReward), + ("testVirtualDecode_fillConvertRequest", testVirtualDecode_fillConvertRequest), + ("testVirtualDecode_fillOrder", testVirtualDecode_fillOrder), + ("testVirtualDecode_fillTransferFromSavings", testVirtualDecode_fillTransferFromSavings), + ("testVirtualDecode_fillVestingWithdraw", testVirtualDecode_fillVestingWithdraw), + ("testVirtualDecode_hardfork", testVirtualDecode_hardfork), + ("testVirtualDecode_interest", testVirtualDecode_interest), + ("testVirtualDecode_liquidityReward", testVirtualDecode_liquidityReward), + ("testVirtualDecode_returnVestingDelegation", testVirtualDecode_returnVestingDelegation), + ("testVirtualDecode_shutdownWitness", testVirtualDecode_shutdownWitness), + ("testVirtualDecode_witnessReward", testVirtualDecode_witnessReward), + ] +} +``` + +- [ ] **Step 3: Update `AssetTest.__allTests`** + +Locate `extension AssetTest` (line 3) and replace its `__allTests`: + +```swift +extension AssetTest { + static let __allTests = [ + ("testCustomSymbolPrecisionInference", testCustomSymbolPrecisionInference), + ("testDecodable", testDecodable), + ("testDecodeFailsOnMalformedString", testDecodeFailsOnMalformedString), + ("testDescriptionExactPrecision", testDescriptionExactPrecision), + ("testEncodable", testEncodable), + ("testEncodeCustomShortSymbolBinary", testEncodeCustomShortSymbolBinary), + ("testEncodeVestsBinary", testEncodeVestsBinary), + ("testMalformedStringsReturnNil", testMalformedStringsReturnNil), + ("testNegativeAndZero", testNegativeAndZero), + ("testProperties", testProperties), + ("testEquateable", testEquateable), + ] +} +``` + +- [ ] **Step 4: Update `VIZEncoderTest.__allTests`** + +Locate `extension VIZEncoderTest` (around line 91) and replace its `__allTests`: + +```swift +extension VIZEncoderTest { + static let __allTests = [ + ("testArray", testArray), + ("testBool", testBool), + ("testDate", testDate), + ("testFixedWidthInteger", testFixedWidthInteger), + ("testLargeArrayVarintLength", testLargeArrayVarintLength), + ("testOptional", testOptional), + ("testRawData", testRawData), + ("testSortedDict", testSortedDict), + ("testString", testString), + ("testVarintBoundaries", testVarintBoundaries), + ] +} +``` + +- [ ] **Step 5: Build to ensure manifest compiles on the macOS toolchain too** + +Run: `swift build` +Expected: succeeds with no warnings about missing symbols. + +(The manifest body is inside `#if !os(macOS)` so it only compiles on Linux, but the per-class `extension XxxTest { static let __allTests = ... }` is compiled on every platform — any typo or missing symbol breaks the macOS build.) + +- [ ] **Step 6: Run full unit test suite locally** + +Run: `swift test --filter UnitTests` +Expected: `Executed 143 tests, with 0 failures` (or thereabouts — the exact count is OK to vary by ±2 if some ops needed to be skipped per Task 4's note). + +- [ ] **Step 7: Commit** + +```bash +git add Tests/UnitTests/XCTestManifests.swift +git commit -m "test: update XCTestManifests for new unit tests and fix SeemURLTest typo + +The typo on the affected line meant VIZURLTest was silently skipped +on Linux. Fix restores those 2 tests to Linux CI." +``` + +--- + +## Task 10: Final validation + +- [ ] **Step 1: Run the unit suite end-to-end** + +Run: `swift test --filter UnitTests 2>&1 | tail -5` +Expected: `Executed N tests, with 0 failures` where N is roughly 143. + +- [ ] **Step 2: Confirm runtime stays under 1 second** + +The last line of test output reports total runtime. Expected: total < 1 second. If runtime balloons, the most likely culprit is a fixture with absurdly large array data — check Task 8's `testLargeArrayVarintLength` (200 elements is fine; thousands would not be). + +- [ ] **Step 3: Verify all source-side TODOs are reachable** + +Run: `grep -n "TODO:" Sources/VIZ/*.swift` +Expected: exactly 5 lines, matching the spec's pinned-quirks table: +- `Sources/VIZ/API.swift` — Share decode fallback +- `Sources/VIZ/API.swift` — currentEnergy clock +- `Sources/VIZ/Operation.swift` — Convert missing OperationId +- `Sources/VIZ/Operation.swift` — custom/CustomJson encode/decode asymmetry (one TODO line, optionally two if you added the paired comment in Task 1 Step 5) + +(The `PublicKey.swift` comment is *explanatory*, not a TODO, so it shouldn't appear in this grep.) + +- [ ] **Step 4: No commit needed** + +Validation only — if any step fails, fix it in a follow-up commit. If everything passes, the implementation is done. + +--- + +## Notes on integration tests + +The existing integration tests in `Tests/IntegrationTests/` hit a live node and are out of scope for this plan. The badge in `README.md` currently depends on them being green. If a future round wants to detangle that, see the spec's "Open questions" section. From ef6c6e6c1442df905e7d6b2f6d816b6a1d7849cd Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:11:45 +0800 Subject: [PATCH 04/16] chore: remove dead print line and pin known quirks with TODOs Deletes the commented-out debug print in Client.urlRequest and adds TODO/explanatory comments at five known-quirk locations (Share decode fallback, currentEnergy clock injection, Operation.Convert missing OperationId, custom/CustomJson encode-decode asymmetry, testNet prefix collision). No behavior changes. --- Sources/VIZ/API.swift | 2 ++ Sources/VIZ/Client.swift | 1 - Sources/VIZ/Operation.swift | 3 +++ Sources/VIZ/PublicKey.swift | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/VIZ/API.swift b/Sources/VIZ/API.swift index 38a673b..0a4d134 100644 --- a/Sources/VIZ/API.swift +++ b/Sources/VIZ/API.swift @@ -81,6 +81,7 @@ public struct API { if let intValue = try? container.decode(Int64.self) { self.value = intValue } else { + // TODO: should throw DecodingError on parse failure instead of returning 0 self.value = Int64(try container.decode(String.self)) ?? 0 } } @@ -141,6 +142,7 @@ public struct API { - delegatedVestingShares.resolvedAmount } + // TODO: take a Date parameter for testability instead of reading Date() implicitly public var currentEnergy: Int { let deltaTime = Date().timeIntervalSince(lastVoteTime) var e = Float64(energy) + (deltaTime * 10000 / CHAIN_ENERGY_REGENERATION_SECONDS) diff --git a/Sources/VIZ/Client.swift b/Sources/VIZ/Client.swift index 1b77212..fd34939 100644 --- a/Sources/VIZ/Client.swift +++ b/Sources/VIZ/Client.swift @@ -230,7 +230,6 @@ public actor Client { urlRequest.setValue("swift-viz/1.0", forHTTPHeaderField: "User-Agent") urlRequest.httpMethod = "POST" urlRequest.httpBody = try encoder.encode(payload) -// print(String(data:urlRequest.httpBody!, encoding: .utf8)) return urlRequest } diff --git a/Sources/VIZ/Operation.swift b/Sources/VIZ/Operation.swift index 3345184..010a993 100644 --- a/Sources/VIZ/Operation.swift +++ b/Sources/VIZ/Operation.swift @@ -129,6 +129,7 @@ public struct Operation { } } + // TODO: no matching OperationId case — encoding falls through to default and throws /// Convert operation. public struct Convert: OperationType, Equatable { public var owner: String @@ -1123,6 +1124,7 @@ internal struct AnyOperation: VIZEncodable, Decodable, Sendable { case .witness_update: op = try container.decode(Operation.WitnessUpdate.self) case .account_witness_vote: op = try container.decode(Operation.AccountWitnessVote.self) case .account_witness_proxy: op = try container.decode(Operation.AccountWitnessProxy.self) + // TODO: encode dispatches on Operation.Custom, decode produces Operation.CustomJson — reconcile case .custom: op = try container.decode(Operation.CustomJson.self) case .request_account_recovery: op = try container.decode(Operation.RequestAccountRecovery.self) case .recover_account: op = try container.decode(Operation.RecoverAccount.self) @@ -1211,6 +1213,7 @@ internal struct AnyOperation: VIZEncodable, Decodable, Sendable { case let op as Operation.AccountWitnessProxy: try container.encode(OperationId.account_witness_proxy) try container.encode(op) + // TODO: paired with the decode-side TODO above — pick one canonical type for the `custom` op id case let op as Operation.Custom: try container.encode(OperationId.custom) try container.encode(op) diff --git a/Sources/VIZ/PublicKey.swift b/Sources/VIZ/PublicKey.swift index 5e48057..e0ff0fc 100644 --- a/Sources/VIZ/PublicKey.swift +++ b/Sources/VIZ/PublicKey.swift @@ -105,6 +105,7 @@ extension PublicKey.AddressPrefix: ExpressibleByStringLiteral, LosslessStringCon switch self { case .mainNet: return "VIZ" + // VIZ has no separate testnet prefix; .testNet currently stringifies to "VIZ" (intentional) case .testNet: return "VIZ" case let .custom(prefix): From bfbe8857839af22c8c14147efc7901a05a55ec48 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:14:10 +0800 Subject: [PATCH 05/16] test: add OperationFixture helper and roundTrip assertion for op tests --- Tests/UnitTests/Operation.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Tests/UnitTests/Operation.swift b/Tests/UnitTests/Operation.swift index 21cfc35..67c6491 100644 --- a/Tests/UnitTests/Operation.swift +++ b/Tests/UnitTests/Operation.swift @@ -1,6 +1,32 @@ @testable import VIZ import XCTest +fileprivate struct OperationFixture { + let value: Op + let json: String // canonical snake_case JSON of the operation body + let binary: Data // canonical binary hex of the operation body (no OperationId prefix) + let opIdName: String // string form of the op id, e.g. "vote", used for AnyOperation wrapping +} + +fileprivate func roundTrip(_ fixture: OperationFixture, file: StaticString = #file, line: UInt = #line) { + // 1. Binary encode + AssertEncodes(fixture.value, fixture.binary, file: file, line: line) + // 2. JSON decode + AssertDecodes(json: fixture.json, fixture.value, file: file, line: line) + // 3. AnyOperation round-trip: wrap [op_id, body] JSON, decode, expect matching op + let wrappedJSON = "[\"\(fixture.opIdName)\",\(fixture.json)]" + do { + let any = try TestDecode(AnyOperation.self, json: wrappedJSON) + guard let decoded = any.operation as? Op else { + XCTFail("AnyOperation decoded to \(type(of: any.operation)), expected \(Op.self)", file: file, line: line) + return + } + XCTAssertEqual(decoded, fixture.value, file: file, line: line) + } catch { + XCTFail("AnyOperation decode failed: \(error)", file: file, line: line) + } +} + fileprivate let vote = ( Operation.Vote(voter: "foo", author: "bar", permlink: "baz", weight: 1000), "{\"voter\":\"foo\",\"author\":\"bar\",\"permlink\":\"baz\",\"weight\":1000}" From 72e767b5703db61e900b98d873201903924e7222 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:34:08 +0800 Subject: [PATCH 06/16] test: add encode-supported operation round-trip coverage (23 ops) Adds testRoundTrip_ for every operation that AnyOperation.encode dispatches on, verifying binary encode, JSON decode, and AnyOperation wrapped JSON round-trip in one helper. Custom and DeleteContent are omitted: both have encode/decode asymmetries in AnyOperation (Custom encodes as Custom but decodes as CustomJson; delete_content is missing from the OperationId string switch). These are documented as comments and tracked for Task 6. --- Tests/UnitTests/Operation.swift | 334 ++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) diff --git a/Tests/UnitTests/Operation.swift b/Tests/UnitTests/Operation.swift index 67c6491..981e9ed 100644 --- a/Tests/UnitTests/Operation.swift +++ b/Tests/UnitTests/Operation.swift @@ -82,4 +82,338 @@ class OperationTest: XCTestCase { AssertDecodes(json: opJson, op) XCTAssert(op.isVirtual) } + + func testRoundTrip_vote() { + let fx = OperationFixture( + value: Operation.Vote(voter: "foo", author: "bar", permlink: "baz", weight: 1000), + json: "{\"voter\":\"foo\",\"author\":\"bar\",\"permlink\":\"baz\",\"weight\":1000}", + binary: Data("03666f6f036261720362617ae803"), + opIdName: "vote" + ) + roundTrip(fx) + } + + func testRoundTrip_content() { + let fx = OperationFixture( + value: Operation.Content( + title: "Hello", + body: "World", + author: "alice", + permlink: "hello-world", + parentAuthor: "", + parentPermlink: "ru--general", + jsonMetadata: "{}" + ), + json: "{\"parent_author\":\"\",\"parent_permlink\":\"ru--general\",\"author\":\"alice\",\"permlink\":\"hello-world\",\"title\":\"Hello\",\"body\":\"World\",\"json_metadata\":\"{}\"}", + binary: Data("000b72752d2d67656e6572616c05616c6963650b68656c6c6f2d776f726c640548656c6c6f05576f726c64027b7d"), + opIdName: "content" + ) + roundTrip(fx) + } + + func testRoundTrip_transfer() { + let fx = OperationFixture( + value: Operation.Transfer(from: "foo", to: "bar", amount: Asset(10, .viz), memo: "baz"), + json: "{\"from\":\"foo\",\"to\":\"bar\",\"amount\":\"10.000 VIZ\",\"memo\":\"baz\"}", + binary: Data("03666f6f0362617210270000000000000356495a000000000362617a"), + opIdName: "transfer" + ) + roundTrip(fx) + } + + func testRoundTrip_transferToVesting() { + let fx = OperationFixture( + value: Operation.TransferToVesting(from: "alice", to: "bob", amount: Asset(5, .viz)), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"amount\":\"5.000 VIZ\"}", + binary: Data("05616c69636503626f6288130000000000000356495a00000000"), + opIdName: "transfer_to_vesting" + ) + roundTrip(fx) + } + + func testRoundTrip_withdrawVesting() { + let fx = OperationFixture( + value: Operation.WithdrawVesting(account: "alice", vestingShares: Asset(100, .vests)), + json: "{\"account\":\"alice\",\"vesting_shares\":\"100.000000 VESTS\"}", + binary: Data("05616c69636500e1f505000000000656455354530000"), + opIdName: "withdraw_vesting" + ) + roundTrip(fx) + } + + func testRoundTrip_accountCreate() { + let masterKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let activeKey = PublicKey("VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6")! + let regularKey = PublicKey("VIZ5oPsxWgfCH2FWqcXBWeeMmZoyBY5baiuV1vQWMxVVpYxEsJ6Hx")! + let memoKey = PublicKey("VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH")! + let fx = OperationFixture( + value: Operation.AccountCreate( + fee: Asset(10, .viz), + creator: "viz", + newAccountName: "newbie", + master: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[masterKey: 1]]), + active: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[activeKey: 1]]), + regular: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[regularKey: 1]]), + memoKey: memoKey, + jsonMetadata: "" + ), + json: "{\"fee\":\"10.000 VIZ\",\"creator\":\"viz\",\"new_account_name\":\"newbie\",\"master\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"active\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6\",1]]},\"regular\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ5oPsxWgfCH2FWqcXBWeeMmZoyBY5baiuV1vQWMxVVpYxEsJ6Hx\",1]]},\"memo_key\":\"VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH\",\"json_metadata\":\"\"}", + binary: Data("10270000000000000356495a000000000376697a066e657762696501000000000103c5ce92a15f7120ae896f348c4ce505d9573cf0816338a478dd9845fe7b1ec59b0100010000000001021b49b04b2406912fbd4a183512b3cdf72c215eba13ceb0c9700db4fbef1dc2570100010000000001027820f0c756d3bc57ce05547fe828d20e03b7fc74e8e4968f984e38b3e26449cb0100034ff417d40dae1849b2187ebd4514b8068db851b73bee6f4c7903e7c8677059ef00"), + opIdName: "account_create" + ) + roundTrip(fx) + } + + func testRoundTrip_accountUpdate() { + let masterKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let memoKey = PublicKey("VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH")! + let fx = OperationFixture( + value: Operation.AccountUpdate( + account: "alice", + master: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[masterKey: 1]]), + active: nil, + regular: nil, + memoKey: memoKey, + jsonMetadata: "" + ), + json: "{\"account\":\"alice\",\"master_is_set\":true,\"master\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"active_is_set\":false,\"active\":null,\"regular_is_set\":false,\"regular\":null,\"memo_key\":\"VIZ7SSqMsrCqNZ3NdJLwWqC2u5PQ66JB2uCCs6ee5NFFqXxxB46AH\",\"json_metadata\":\"\"}", + binary: Data("05616c6963650101000000000103c5ce92a15f7120ae896f348c4ce505d9573cf0816338a478dd9845fe7b1ec59b01000000034ff417d40dae1849b2187ebd4514b8068db851b73bee6f4c7903e7c8677059ef00"), + opIdName: "account_update" + ) + roundTrip(fx) + } + + func testRoundTrip_witnessUpdate() { + let signingKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let fx = OperationFixture( + value: Operation.WitnessUpdate( + owner: "alice", + url: "https://example.com", + blockSigningKey: signingKey, + props: Operation.WitnessUpdate.Properties(), + fee: Asset(0, .viz) + ), + json: "{\"owner\":\"alice\",\"url\":\"https:\\/\\/example.com\",\"block_signing_key\":\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",\"props\":{},\"fee\":\"0.000 VIZ\"}", + binary: Data("05616c6963651368747470733a2f2f6578616d706c652e636f6d03c5ce92a15f7120ae896f348c4ce505d9573cf0816338a478dd9845fe7b1ec59b00000000000000000356495a00000000"), + opIdName: "witness_update" + ) + roundTrip(fx) + } + + func testRoundTrip_accountWitnessVote() { + let fx = OperationFixture( + value: Operation.AccountWitnessVote(account: "alice", witness: "witness1", approve: true), + json: "{\"account\":\"alice\",\"witness\":\"witness1\",\"approve\":true}", + binary: Data("05616c696365087769746e6573733101"), + opIdName: "account_witness_vote" + ) + roundTrip(fx) + } + + func testRoundTrip_accountWitnessProxy() { + let fx = OperationFixture( + value: Operation.AccountWitnessProxy(account: "alice", proxy: "proxy1"), + json: "{\"account\":\"alice\",\"proxy\":\"proxy1\"}", + binary: Data("05616c6963650670726f787931"), + opIdName: "account_witness_proxy" + ) + roundTrip(fx) + } + + // testRoundTrip_custom is omitted: AnyOperation.encode dispatches on Operation.Custom + // but AnyOperation.init(from:) decodes the "custom" op_id as Operation.CustomJson. + // The two types are incompatible, so the AnyOperation round-trip step always fails. + // Tracked as a Task 6 encode/decode asymmetry. + + // testRoundTrip_deleteContent is omitted: "delete_content" is absent from the + // OperationId string-switch in AnyOperation.init(from:), so the AnyOperation + // round-trip step decodes to Operation.Unknown instead of Operation.DeleteContent. + // Tracked as a Task 6 encode/decode asymmetry. + + func testRoundTrip_setWithdrawVestingRoute() { + let fx = OperationFixture( + value: Operation.SetWithdrawVestingRoute(fromAccount: "alice", toAccount: "bob", percent: 5000, autoVest: false), + json: "{\"from_account\":\"alice\",\"to_account\":\"bob\",\"percent\":5000,\"auto_vest\":false}", + binary: Data("05616c69636503626f62881300"), + opIdName: "set_withdraw_vesting_route" + ) + roundTrip(fx) + } + + func testRoundTrip_requestAccountRecovery() { + let masterKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let fx = OperationFixture( + value: Operation.RequestAccountRecovery( + recoveryAccount: "viz", + accountToRecover: "alice", + newOwnerAuthority: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[masterKey: 1]]), + extensions: [] + ), + json: "{\"recovery_account\":\"viz\",\"account_to_recover\":\"alice\",\"new_owner_authority\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"extensions\":[]}", + binary: Data("0376697a05616c69636501000000000103c5ce92a15f7120ae896f348c4ce505d9573cf0816338a478dd9845fe7b1ec59b010000"), + opIdName: "request_account_recovery" + ) + roundTrip(fx) + } + + func testRoundTrip_recoverAccount() { + let newKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let oldKey = PublicKey("VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6")! + let fx = OperationFixture( + value: Operation.RecoverAccount( + accountToRecover: "alice", + newOwnerAuthority: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[newKey: 1]]), + recentOwnerAuthority: Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[oldKey: 1]]), + extensions: [] + ), + json: "{\"account_to_recover\":\"alice\",\"new_owner_authority\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\",1]]},\"recent_owner_authority\":{\"weight_threshold\":1,\"account_auths\":[],\"key_auths\":[[\"VIZ56WPHZKvxoHpjQh69XakuoE5czuewrTDYeUBsQNKjnq3a6bbh6\",1]]},\"extensions\":[]}", + binary: Data("05616c69636501000000000103c5ce92a15f7120ae896f348c4ce505d9573cf0816338a478dd9845fe7b1ec59b0100010000000001021b49b04b2406912fbd4a183512b3cdf72c215eba13ceb0c9700db4fbef1dc257010000"), + opIdName: "recover_account" + ) + roundTrip(fx) + } + + func testRoundTrip_changeRecoveryAccount() { + let fx = OperationFixture( + value: Operation.ChangeRecoveryAccount( + accountToRecover: "alice", + newRecoveryAccount: "viz", + extensions: [] + ), + json: "{\"account_to_recover\":\"alice\",\"new_recovery_account\":\"viz\",\"extensions\":[]}", + binary: Data("05616c6963650376697a00"), + opIdName: "change_recovery_account" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowTransfer() { + let fx = OperationFixture( + value: Operation.EscrowTransfer( + from: "alice", + to: "bob", + agent: "carol", + escrowId: 1, + sbdAmount: Asset(0, .viz), + steemAmount: Asset(5, .viz), + fee: Asset(0.1, .viz), + ratificationDeadline: Date(timeIntervalSince1970: 1_700_000_000), + escrowExpiration: Date(timeIntervalSince1970: 1_700_086_400), + jsonMeta: "" + ), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"escrow_id\":1,\"sbd_amount\":\"0.000 VIZ\",\"steem_amount\":\"5.000 VIZ\",\"fee\":\"0.100 VIZ\",\"ratification_deadline\":\"2023-11-14T22:13:20\",\"escrow_expiration\":\"2023-11-15T22:13:20\",\"json_meta\":\"\"}", + binary: Data("05616c69636503626f62056361726f6c0100000000000000000000000356495a0000000088130000000000000356495a0000000064000000000000000356495a0000000000f153658042556500"), + opIdName: "escrow_transfer" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowDispute() { + let fx = OperationFixture( + value: Operation.EscrowDispute(from: "alice", to: "bob", agent: "carol", who: "alice", escrowId: 1), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"who\":\"alice\",\"escrow_id\":1}", + binary: Data("05616c69636503626f62056361726f6c05616c69636501000000"), + opIdName: "escrow_dispute" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowRelease() { + let fx = OperationFixture( + value: Operation.EscrowRelease( + from: "alice", to: "bob", agent: "carol", who: "carol", receiver: "bob", + escrowId: 1, sbdAmount: Asset(0, .viz), steemAmount: Asset(5, .viz) + ), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"who\":\"carol\",\"receiver\":\"bob\",\"escrow_id\":1,\"sbd_amount\":\"0.000 VIZ\",\"steem_amount\":\"5.000 VIZ\"}", + binary: Data("05616c69636503626f62056361726f6c056361726f6c03626f620100000000000000000000000356495a0000000088130000000000000356495a00000000"), + opIdName: "escrow_release" + ) + roundTrip(fx) + } + + func testRoundTrip_escrowApprove() { + let fx = OperationFixture( + value: Operation.EscrowApprove(from: "alice", to: "bob", agent: "carol", who: "carol", escrowId: 1, approve: true), + json: "{\"from\":\"alice\",\"to\":\"bob\",\"agent\":\"carol\",\"who\":\"carol\",\"escrow_id\":1,\"approve\":true}", + binary: Data("05616c69636503626f62056361726f6c056361726f6c0100000001"), + opIdName: "escrow_approve" + ) + roundTrip(fx) + } + + func testRoundTrip_delegateVestingShares() { + let fx = OperationFixture( + value: Operation.DelegateVestingShares(delegator: "alice", delegatee: "bob", vestingShares: Asset(1000, .vests)), + json: "{\"delegator\":\"alice\",\"delegatee\":\"bob\",\"vesting_shares\":\"1000.000000 VESTS\"}", + binary: Data("05616c69636503626f6200ca9a3b000000000656455354530000"), + opIdName: "delegate_vesting_shares" + ) + roundTrip(fx) + } + + func testRoundTrip_award() { + let fx = OperationFixture( + value: Operation.Award( + initiator: "alice", + receiver: "bob", + energy: 100, + customSequence: 0, + memo: "thanks", + beneficiaries: [Operation.Beneficiary(account: "carol", weight: 1000)] + ), + json: "{\"initiator\":\"alice\",\"receiver\":\"bob\",\"energy\":100,\"custom_sequence\":0,\"memo\":\"thanks\",\"beneficiaries\":[{\"account\":\"carol\",\"weight\":1000}]}", + binary: Data("05616c69636503626f6264000000000000000000067468616e6b7301056361726f6ce803"), + opIdName: "award" + ) + roundTrip(fx) + } + + func testRoundTrip_receiveAward() { + let fx = OperationFixture( + value: Operation.ReceiveAward( + initiator: "alice", + receiver: "bob", + customSequence: 0, + memo: "thanks", + shares: Asset(0.5, .vests) + ), + json: "{\"initiator\":\"alice\",\"receiver\":\"bob\",\"custom_sequence\":0,\"memo\":\"thanks\",\"shares\":\"0.500000 VESTS\"}", + binary: Data("05616c69636503626f620000000000000000067468616e6b7320a10700000000000656455354530000"), + opIdName: "receive_award" + ) + roundTrip(fx) + } + + func testRoundTrip_benefactorAward() { + let fx = OperationFixture( + value: Operation.BenefactorAward( + initiator: "alice", + benefactor: "bob", + receiver: "carol", + customSequence: 0, + memo: "thanks", + shares: Asset(0.5, .vests) + ), + json: "{\"initiator\":\"alice\",\"benefactor\":\"bob\",\"receiver\":\"carol\",\"custom_sequence\":0,\"memo\":\"thanks\",\"shares\":\"0.500000 VESTS\"}", + binary: Data("05616c69636503626f62056361726f6c0000000000000000067468616e6b7320a10700000000000656455354530000"), + opIdName: "benefactor_award" + ) + roundTrip(fx) + } + + func testRoundTrip_inviteRegistration() { + let newKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let fx = OperationFixture( + value: Operation.InviteRegistration( + initiator: "invite", + newAccountName: "alice", + inviteSecret: "5KVvGJo9HGXoYBFiLbNqckJR8YxrRKApFjmL3PYWQeUNuaRZhXe", + newAccountKey: newKey + ), + json: "{\"initiator\":\"invite\",\"new_account_name\":\"alice\",\"invite_secret\":\"5KVvGJo9HGXoYBFiLbNqckJR8YxrRKApFjmL3PYWQeUNuaRZhXe\",\"new_account_key\":\"VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau\"}", + binary: Data("06696e7669746505616c69636533354b5676474a6f394847586f594246694c624e71636b4a5238597872524b4170466a6d4c335059575165554e7561525a68586503c5ce92a15f7120ae896f348c4ce505d9573cf0816338a478dd9845fe7b1ec59b"), + opIdName: "invite_registration" + ) + roundTrip(fx) + } } From 63216cbeb992a06f0bf9042fde04d0ea6bdcea45 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:39:45 +0800 Subject: [PATCH 07/16] test: clarify DeleteContent deferral comment Comment said 'absent from OperationId string-switch in AnyOperation.init(from:)' when the actual missing entry is in OperationId.init(from:). No code change. --- Tests/UnitTests/Operation.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTests/Operation.swift b/Tests/UnitTests/Operation.swift index 981e9ed..04cc2a1 100644 --- a/Tests/UnitTests/Operation.swift +++ b/Tests/UnitTests/Operation.swift @@ -226,8 +226,8 @@ class OperationTest: XCTestCase { // Tracked as a Task 6 encode/decode asymmetry. // testRoundTrip_deleteContent is omitted: "delete_content" is absent from the - // OperationId string-switch in AnyOperation.init(from:), so the AnyOperation - // round-trip step decodes to Operation.Unknown instead of Operation.DeleteContent. + // string-switch in OperationId.init(from:), so the AnyOperation round-trip + // step decodes to Operation.Unknown instead of Operation.DeleteContent. // Tracked as a Task 6 encode/decode asymmetry. func testRoundTrip_setWithdrawVestingRoute() { From 3b2ef5f6cbf9d97e508bb8b2340f7f11dde833fd Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:42:21 +0800 Subject: [PATCH 08/16] test: add virtual/decode-only operation coverage (14 ops) --- Tests/UnitTests/Operation.swift | 137 ++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/Tests/UnitTests/Operation.swift b/Tests/UnitTests/Operation.swift index 04cc2a1..df0ed10 100644 --- a/Tests/UnitTests/Operation.swift +++ b/Tests/UnitTests/Operation.swift @@ -8,6 +8,27 @@ fileprivate struct OperationFixture { let opIdName: String // string form of the op id, e.g. "vote", used for AnyOperation wrapping } +fileprivate func assertVirtualDecodes( + opIdName: String, + json body: String, + _ expected: Op, + file: StaticString = #file, + line: UInt = #line +) { + let wrappedJSON = "[\"\(opIdName)\",\(body)]" + do { + let any = try TestDecode(AnyOperation.self, json: wrappedJSON) + guard let decoded = any.operation as? Op else { + XCTFail("Decoded to \(type(of: any.operation)), expected \(Op.self)", file: file, line: line) + return + } + XCTAssertEqual(decoded, expected, file: file, line: line) + XCTAssertTrue(decoded.isVirtual, "Expected isVirtual == true", file: file, line: line) + } catch { + XCTFail("Decode failed: \(error)", file: file, line: line) + } +} + fileprivate func roundTrip(_ fixture: OperationFixture, file: StaticString = #file, line: UInt = #line) { // 1. Binary encode AssertEncodes(fixture.value, fixture.binary, file: file, line: line) @@ -416,4 +437,120 @@ class OperationTest: XCTestCase { ) roundTrip(fx) } + + func testVirtualDecode_authorReward() { + assertVirtualDecodes( + opIdName: "author_reward", + json: "{\"author\":\"alice\",\"permlink\":\"post\",\"sbd_payout\":\"0.000 VIZ\",\"steem_payout\":\"1.000 VIZ\",\"vesting_payout\":\"0.500000 VESTS\"}", + Operation.AuthorReward( + author: "alice", + permlink: "post", + sbdPayout: Asset(0, .viz), + steemPayout: Asset(1, .viz), + vestingPayout: Asset(0.5, .vests) + ) + ) + } + + func testVirtualDecode_curationReward() { + assertVirtualDecodes( + opIdName: "curation_reward", + json: "{\"curator\":\"alice\",\"reward\":\"0.500000 VESTS\",\"comment_author\":\"bob\",\"comment_permlink\":\"post\"}", + Operation.CurationReward(curator: "alice", reward: Asset(0.5, .vests), commentAuthor: "bob", commentPermlink: "post") + ) + } + + func testVirtualDecode_commentReward() { + // "content_reward" is not in OperationId.init(from:)'s string-switch — AnyOperation decodes + // it as Operation.Unknown. Verify the struct decodes from a bare body JSON instead. + AssertDecodes( + json: "{\"author\":\"alice\",\"permlink\":\"post\",\"payout\":\"1.000 VIZ\"}", + Operation.CommentReward(author: "alice", permlink: "post", payout: Asset(1, .viz)) + ) + } + + func testVirtualDecode_liquidityReward() { + // No matching OperationId enum case — AnyOperation cannot decode this on its own. + // Verify the struct decodes from a bare body JSON instead. + AssertDecodes( + json: "{\"owner\":\"alice\",\"payout\":\"1.000 VIZ\"}", + Operation.LiquidityReward(owner: "alice", payout: Asset(1, .viz)) + ) + } + + func testVirtualDecode_interest() { + // Same caveat as LiquidityReward: not reachable through AnyOperation today. + AssertDecodes( + json: "{\"owner\":\"alice\",\"interest\":\"1.000 VIZ\"}", + Operation.Interest(owner: "alice", interest: Asset(1, .viz)) + ) + } + + func testVirtualDecode_fillConvertRequest() { + AssertDecodes( + json: "{\"owner\":\"alice\",\"requestid\":1,\"amount_in\":\"1.000 VIZ\",\"amount_out\":\"1.000 VIZ\"}", + Operation.FillConvertRequest(owner: "alice", requestid: 1, amountIn: Asset(1, .viz), amountOut: Asset(1, .viz)) + ) + } + + func testVirtualDecode_fillVestingWithdraw() { + assertVirtualDecodes( + opIdName: "fill_vesting_withdraw", + json: "{\"from_account\":\"alice\",\"to_account\":\"alice\",\"withdrawn\":\"1.000000 VESTS\",\"deposited\":\"1.000 VIZ\"}", + Operation.FillVestingWithdraw(fromAccount: "alice", toAccount: "alice", withdrawn: Asset(1, .vests), deposited: Asset(1, .viz)) + ) + } + + func testVirtualDecode_shutdownWitness() { + assertVirtualDecodes( + opIdName: "shutdown_witness", + json: "{\"owner\":\"alice\"}", + Operation.ShutdownWitness(owner: "alice") + ) + } + + func testVirtualDecode_fillOrder() { + AssertDecodes( + json: "{\"current_owner\":\"alice\",\"current_orderid\":1,\"current_pays\":\"1.000 VIZ\",\"open_owner\":\"bob\",\"open_orderid\":2,\"open_pays\":\"1.000 VIZ\"}", + Operation.FillOrder(currentOwner: "alice", currentOrderid: 1, currentPays: Asset(1, .viz), openOwner: "bob", openOrderid: 2, openPays: Asset(1, .viz)) + ) + } + + func testVirtualDecode_fillTransferFromSavings() { + AssertDecodes( + json: "{\"from\":\"alice\",\"to\":\"bob\",\"amount\":\"1.000 VIZ\",\"request_id\":1,\"memo\":\"\"}", + Operation.FillTransferFromSavings(from: "alice", to: "bob", amount: Asset(1, .viz), requestId: 1, memo: "") + ) + } + + func testVirtualDecode_hardfork() { + assertVirtualDecodes( + opIdName: "hardfork", + json: "{\"hardfork_id\":4}", + Operation.Hardfork(hardforkId: 4) + ) + } + + func testVirtualDecode_commentPayoutUpdate() { + AssertDecodes( + json: "{\"author\":\"alice\",\"permlink\":\"post\"}", + Operation.CommentPayoutUpdate(author: "alice", permlink: "post") + ) + } + + func testVirtualDecode_returnVestingDelegation() { + assertVirtualDecodes( + opIdName: "return_vesting_delegation", + json: "{\"account\":\"alice\",\"vesting_shares\":\"1.000000 VESTS\"}", + Operation.ReturnVestingDelegation(account: "alice", vestingShares: Asset(1, .vests)) + ) + } + + func testVirtualDecode_witnessReward() { + assertVirtualDecodes( + opIdName: "witness_reward", + json: "{\"witness\":\"alice\",\"shares\":\"0.500000 VESTS\"}", + Operation.WitnessReward(witness: "alice", shares: Asset(0.5, .vests)) + ) + } } From d246f72ba3ffe315642332bca1c7f36674d74456 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:45:37 +0800 Subject: [PATCH 09/16] test: pin Unknown-mapped operation decode behavior (27 op ids) --- Tests/UnitTests/Operation.swift | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Tests/UnitTests/Operation.swift b/Tests/UnitTests/Operation.swift index df0ed10..94770cd 100644 --- a/Tests/UnitTests/Operation.swift +++ b/Tests/UnitTests/Operation.swift @@ -553,4 +553,54 @@ class OperationTest: XCTestCase { Operation.WitnessReward(witness: "alice", shares: Asset(0.5, .vests)) ) } + + func testUnknownMappedOps_decodeAsUnknown() { + // These op ids exist in OperationId but AnyOperation.init(from:) maps them to Operation.Unknown. + // This test pins that current behavior — when any of these is later modeled, the corresponding + // line will fail and force a deliberate, test-driven update. + let opIds: [String] = [ + "account_metadata", + "proposal_create", + "proposal_update", + "proposal_delete", + "chain_properties_update", + "content_payout_update", + "content_benefactor_reward", + "committee_worker_create_request", + "committee_worker_cancel_request", + "committee_vote_request", + "committee_cancel_request", + "committee_approve_request", + "committee_payout_request", + "committee_pay_request", + "use_invite_balance", + "expire_escrow_ratification", + "set_paid_subscription", + "paid_subscribe", + "paid_subscription_action", + "cancel_paid_subscription", + "set_account_price", + "set_subaccount_price", + "buy_account", + "account_sale", + "create_invite", + "claim_invite_balance", + "versioned_chain_properties_update", + ] + + for opIdName in opIds { + // Body is an empty object; any non-Unknown decode would have to read fields and fail. + // Unknown is an empty struct so an empty body decodes cleanly. + let json = "[\"\(opIdName)\",{}]" + do { + let any = try TestDecode(AnyOperation.self, json: json) + guard any.operation as? VIZ.Operation.Unknown != nil else { + XCTFail("Expected \(opIdName) to decode as Operation.Unknown, got \(type(of: any.operation))") + continue + } + } catch { + XCTFail("Decode of \(opIdName) failed: \(error)") + } + } + } } From c305225fba418f556e18f5ae56c16c5e3c850482 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:48:52 +0800 Subject: [PATCH 10/16] test: pin encode asymmetry for ops missing from AnyOperation.encode dispatch --- Tests/UnitTests/Operation.swift | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/Tests/UnitTests/Operation.swift b/Tests/UnitTests/Operation.swift index 94770cd..f7a0747 100644 --- a/Tests/UnitTests/Operation.swift +++ b/Tests/UnitTests/Operation.swift @@ -603,4 +603,80 @@ class OperationTest: XCTestCase { } } } + + func testEncodeAsymmetry_definedButNotEncodable() { + // These operation types are defined in Operation.swift but AnyOperation.encode does not dispatch + // on them (or dispatches incorrectly). Each wrap-and-encode attempt is expected to throw today. + // When any of these is fixed (added to the encode switch or paired correctly with an OperationId), + // the corresponding XCTAssertThrowsError line will fail and force this test to be updated. + + // 1. Operation.CustomJson — decode side produces it for the `custom` op id, but encode dispatches + // on Operation.Custom. So wrapping a CustomJson and encoding throws. + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CustomJson( + requiredAuths: [], requiredPostingAuths: ["alice"], id: "test", json: "{}" + ))), "CustomJson") + + // 2. Operation.Convert — no OperationId.convert case exists. + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.Convert( + owner: "alice", requestid: 1, amount: Asset(1, .viz) + ))), "Convert") + + // 3. Operations defined as types but missing from the encode dispatch switch. + let pubKey = PublicKey("VIZ8LMF1uA5GAPfsAe1dieBRATQfhgi1ZqXYRFkaj1WaaWx9vVjau")! + let auth = Authority(weightThreshold: 1, accountAuths: [], keyAuths: [[pubKey: 1]]) + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CommentOptions( + author: "alice", permlink: "post", maxAcceptedPayout: Asset(100, .viz), + percentSteemDollars: 10000, allowVotes: true, allowCurationRewards: true, extensions: [] + ))), "CommentOptions") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ChallengeAuthority( + challenger: "alice", challenged: "bob", requireOwner: false + ))), "ChallengeAuthority") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ProveAuthority( + challenged: "alice", requireOwner: false + ))), "ProveAuthority") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.TransferToSavings( + from: "alice", to: "bob", amount: Asset(1, .viz), memo: "" + ))), "TransferToSavings") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.TransferFromSavings( + from: "alice", requestId: 1, to: "bob", amount: Asset(1, .viz), memo: "" + ))), "TransferFromSavings") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CancelTransferFromSavings( + from: "alice", requestId: 1 + ))), "CancelTransferFromSavings") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.CustomBinary( + requiredOwnerAuths: [], requiredActiveAuths: [], requiredPostingAuths: [], + requiredAuths: [], id: "test", data: Data() + ))), "CustomBinary") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.DeclineVotingRights( + account: "alice", decline: true + ))), "DeclineVotingRights") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ResetAccount( + resetAccount: "alice", accountToReset: "bob", newOwnerAuthority: auth + ))), "ResetAccount") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.SetResetAccount( + account: "alice", currentResetAccount: "bob", resetAccount: "carol" + ))), "SetResetAccount") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.ClaimRewardBalance( + account: "alice", rewardSteem: Asset(1, .viz), rewardSbd: Asset(0, .viz), rewardVests: Asset(0.5, .vests) + ))), "ClaimRewardBalance") + + XCTAssertThrowsError(try VIZEncoder.encode(AnyOperation(Operation.AccountCreateWithDelegation( + fee: Asset(1, .viz), delegation: Asset(0, .vests), + creator: "alice", newAccountName: "bob", + master: auth, active: auth, regular: auth, memoKey: pubKey + ))), "AccountCreateWithDelegation") + + // ReportOverProduction: skipped — BlockId has no public initializer; covered by encode-dispatch switch's lack of a case. + } } From 7340b26ed79d6e2237dd4358951dba62cdac95dc Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:51:31 +0800 Subject: [PATCH 11/16] test: add Asset string-parsing, precision, and decode-error coverage --- Tests/UnitTests/Asset.swift | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/Tests/UnitTests/Asset.swift b/Tests/UnitTests/Asset.swift index b7d98e4..5b535a5 100644 --- a/Tests/UnitTests/Asset.swift +++ b/Tests/UnitTests/Asset.swift @@ -32,4 +32,65 @@ class AssetTest: XCTestCase { AssertDecodes(string: "0.001 VIZ", Asset(0.001, .viz)) AssertDecodes(string: "123456789.999999 VESTS", Asset(123_456_789.999999, .vests)) } + + // MARK: - String parsing edge cases + + func testMalformedStringsReturnNil() { + XCTAssertNil(Asset("")) + XCTAssertNil(Asset("VIZ")) + XCTAssertNil(Asset("10.000")) + XCTAssertNil(Asset("10.000VIZ")) + XCTAssertNil(Asset("abc VIZ")) + XCTAssertNil(Asset("10..0 VIZ")) + } + + func testCustomSymbolPrecisionInference() { + let a = Asset("10.123456 FOO") + XCTAssertEqual(a?.symbol, .custom(name: "FOO", precision: 6)) + + let b = Asset("10 FOO") + XCTAssertEqual(b?.symbol, .custom(name: "FOO", precision: 0)) + + let c = Asset("10. FOO") + XCTAssertEqual(c?.symbol, .custom(name: "FOO", precision: 0)) + } + + func testNegativeAndZero() { + let neg = Asset(-1.5, .viz) + XCTAssertEqual(neg.description, "-1.500 VIZ") + XCTAssertEqual(neg.resolvedAmount, -1.5) + + let zero = Asset(0, .viz) + XCTAssertEqual(zero.description, "0.000 VIZ") + XCTAssertEqual(zero.resolvedAmount, 0.0) + } + + func testDescriptionExactPrecision() { + XCTAssertEqual(Asset(1, .viz).description, "1.000 VIZ") + XCTAssertEqual(Asset(1, .vests).description, "1.000000 VESTS") + XCTAssertEqual(Asset(1, .custom(name: "FOO", precision: 2)).description, "1.00 FOO") + } + + // MARK: - Binary encoding for non-VIZ symbols + + func testEncodeVestsBinary() { + AssertEncodes(Asset(1, .vests), Data("40420f00000000000656455354530000")) + } + + func testEncodeCustomShortSymbolBinary() { + AssertEncodes(Asset(1, .custom(name: "FOO", precision: 2)), Data("640000000000000002464f4f00000000")) + } + + // MARK: - Decode failure + + func testDecodeFailsOnMalformedString() { + do { + _ = try TestDecode(Asset.self, string: "not an asset") + XCTFail("Expected DecodingError") + } catch is DecodingError { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } } From 4903b4806485cd95be6d06b21fc8ba6264511388 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:54:27 +0800 Subject: [PATCH 12/16] test: add Bool/Date/Optional/Data/varint coverage for VIZEncoder --- Tests/UnitTests/VIZEncoder.swift | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Tests/UnitTests/VIZEncoder.swift b/Tests/UnitTests/VIZEncoder.swift index 2812bf6..00ff715 100644 --- a/Tests/UnitTests/VIZEncoder.swift +++ b/Tests/UnitTests/VIZEncoder.swift @@ -36,4 +36,45 @@ class VIZEncoderTest: XCTestCase { let map2: OrderedDictionary = [("2k", Date(timeIntervalSince1970: 946_684_800))] AssertEncodes(map2, Data("0102326b80436d38")) } + + func testBool() { + AssertEncodes(true, Data("01")) + AssertEncodes(false, Data("00")) + } + + func testDate() { + // UInt32 little-endian seconds since 1970 + AssertEncodes(Date(timeIntervalSince1970: 0), Data("00000000")) + // 1700000000 = 0x6553f100 → little-endian: 00 f1 53 65 + AssertEncodes(Date(timeIntervalSince1970: 1_700_000_000), Data("00f15365")) + } + + func testOptional() { + AssertEncodes(Optional.some(0xbeef), Data("01efbe")) + AssertEncodes(Optional.none, Data("00")) + } + + func testRawData() { + // Data appends raw bytes with NO length prefix (pinned behavior). + AssertEncodes(Data([0x01, 0x02, 0x03]), Data("010203")) + } + + func testVarintBoundaries() { + // appendVarint is internal — use a String which calls it for its length prefix. + // 0-byte string → length varint of 0 → 0x00 + AssertEncodes("", Data("00")) + // 127-byte string → length varint of 127 → 0x7f + let s127 = String(repeating: "a", count: 127) + AssertEncodes(s127, Data("7f" + String(repeating: "61", count: 127))) + // 128-byte string → length varint of 128 → 0x80 0x01 + let s128 = String(repeating: "a", count: 128) + AssertEncodes(s128, Data("8001" + String(repeating: "61", count: 128))) + } + + func testLargeArrayVarintLength() { + // 200 UInt16 elements → length prefix is c8 01 (200), followed by 400 bytes of element data. + let arr = Array(repeating: UInt16(0), count: 200) + let expectedHex = "c801" + String(repeating: "0000", count: 200) + AssertEncodes(arr, Data(expectedHex)) + } } From 7e0136d12c85fcdd478a4e7fab30aecf0af13a2c Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 16:57:04 +0800 Subject: [PATCH 13/16] test: update XCTestManifests for new unit tests and fix SeemURLTest typo The typo on the affected line meant VIZURLTest was silently skipped on Linux. Fix restores those 2 tests to Linux CI. --- Tests/UnitTests/XCTestManifests.swift | 57 ++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/Tests/UnitTests/XCTestManifests.swift b/Tests/UnitTests/XCTestManifests.swift index c7bf40f..453926d 100644 --- a/Tests/UnitTests/XCTestManifests.swift +++ b/Tests/UnitTests/XCTestManifests.swift @@ -2,8 +2,17 @@ import XCTest extension AssetTest { static let __allTests = [ + ("testCustomSymbolPrecisionInference", testCustomSymbolPrecisionInference), ("testDecodable", testDecodable), + ("testDecodeFailsOnMalformedString", testDecodeFailsOnMalformedString), + ("testDescriptionExactPrecision", testDescriptionExactPrecision), ("testEncodable", testEncodable), + ("testEncodeCustomShortSymbolBinary", testEncodeCustomShortSymbolBinary), + ("testEncodeVestsBinary", testEncodeVestsBinary), + ("testEquateable", testEquateable), + ("testMalformedStringsReturnNil", testMalformedStringsReturnNil), + ("testNegativeAndZero", testNegativeAndZero), + ("testProperties", testProperties), ] } @@ -34,6 +43,46 @@ extension OperationTest { static let __allTests = [ ("testDecodable", testDecodable), ("testEncodable", testEncodable), + ("testEncodeAsymmetry_definedButNotEncodable", testEncodeAsymmetry_definedButNotEncodable), + ("testRoundTrip_accountCreate", testRoundTrip_accountCreate), + ("testRoundTrip_accountUpdate", testRoundTrip_accountUpdate), + ("testRoundTrip_accountWitnessProxy", testRoundTrip_accountWitnessProxy), + ("testRoundTrip_accountWitnessVote", testRoundTrip_accountWitnessVote), + ("testRoundTrip_award", testRoundTrip_award), + ("testRoundTrip_benefactorAward", testRoundTrip_benefactorAward), + ("testRoundTrip_changeRecoveryAccount", testRoundTrip_changeRecoveryAccount), + ("testRoundTrip_content", testRoundTrip_content), + ("testRoundTrip_delegateVestingShares", testRoundTrip_delegateVestingShares), + ("testRoundTrip_escrowApprove", testRoundTrip_escrowApprove), + ("testRoundTrip_escrowDispute", testRoundTrip_escrowDispute), + ("testRoundTrip_escrowRelease", testRoundTrip_escrowRelease), + ("testRoundTrip_escrowTransfer", testRoundTrip_escrowTransfer), + ("testRoundTrip_inviteRegistration", testRoundTrip_inviteRegistration), + ("testRoundTrip_receiveAward", testRoundTrip_receiveAward), + ("testRoundTrip_recoverAccount", testRoundTrip_recoverAccount), + ("testRoundTrip_requestAccountRecovery", testRoundTrip_requestAccountRecovery), + ("testRoundTrip_setWithdrawVestingRoute", testRoundTrip_setWithdrawVestingRoute), + ("testRoundTrip_transfer", testRoundTrip_transfer), + ("testRoundTrip_transferToVesting", testRoundTrip_transferToVesting), + ("testRoundTrip_vote", testRoundTrip_vote), + ("testRoundTrip_withdrawVesting", testRoundTrip_withdrawVesting), + ("testRoundTrip_witnessUpdate", testRoundTrip_witnessUpdate), + ("testUnknownMappedOps_decodeAsUnknown", testUnknownMappedOps_decodeAsUnknown), + ("testVirtual", testVirtual), + ("testVirtualDecode_authorReward", testVirtualDecode_authorReward), + ("testVirtualDecode_commentPayoutUpdate", testVirtualDecode_commentPayoutUpdate), + ("testVirtualDecode_commentReward", testVirtualDecode_commentReward), + ("testVirtualDecode_curationReward", testVirtualDecode_curationReward), + ("testVirtualDecode_fillConvertRequest", testVirtualDecode_fillConvertRequest), + ("testVirtualDecode_fillOrder", testVirtualDecode_fillOrder), + ("testVirtualDecode_fillTransferFromSavings", testVirtualDecode_fillTransferFromSavings), + ("testVirtualDecode_fillVestingWithdraw", testVirtualDecode_fillVestingWithdraw), + ("testVirtualDecode_hardfork", testVirtualDecode_hardfork), + ("testVirtualDecode_interest", testVirtualDecode_interest), + ("testVirtualDecode_liquidityReward", testVirtualDecode_liquidityReward), + ("testVirtualDecode_returnVestingDelegation", testVirtualDecode_returnVestingDelegation), + ("testVirtualDecode_shutdownWitness", testVirtualDecode_shutdownWitness), + ("testVirtualDecode_witnessReward", testVirtualDecode_witnessReward), ] } @@ -91,9 +140,15 @@ extension SignatureTest { extension VIZEncoderTest { static let __allTests = [ ("testArray", testArray), + ("testBool", testBool), + ("testDate", testDate), ("testFixedWidthInteger", testFixedWidthInteger), + ("testLargeArrayVarintLength", testLargeArrayVarintLength), + ("testOptional", testOptional), + ("testRawData", testRawData), ("testSortedDict", testSortedDict), ("testString", testString), + ("testVarintBoundaries", testVarintBoundaries), ] } @@ -115,7 +170,7 @@ extension TransactionTest { testCase(PrivateKeyTest.__allTests), testCase(PublicKeyTest.__allTests), testCase(Secp256k1Test.__allTests), - testCase(SeemURLTest.__allTests), + testCase(VIZURLTest.__allTests), testCase(Sha2Test.__allTests), testCase(SignatureTest.__allTests), testCase(VIZEncoderTest.__allTests), From 74b46b826b02adbad91cec0bc48ea4f75fb7b3c1 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 17:22:15 +0800 Subject: [PATCH 14/16] docs: clarify and reorganize README - Add a table of contents - Promote the feature list to the top, drop the philosophy section - Normalize headers to sentence case, add consistent dividers - Fix code samples that referenced undefined identifiers (Error.invalidKey, regularKey, publicKey) - Cover .codingError in the error-handling example - Show all three swift test invocations - Note that XCTestManifests.swift is hand-edited --- README.md | 359 +++++++++++++++++++++++++----------------------------- 1 file changed, 169 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index c141ccf..8859dd2 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,43 @@ # viz-swift-lib -![Tests badge](https://github.com/VIZ-Blockchain/viz-swift-lib/actions/workflows/tests.yml/badge.svg?branch=master) +![Tests](https://github.com/VIZ-Blockchain/viz-swift-lib/actions/workflows/tests.yml/badge.svg?branch=master) [![Swift](https://img.shields.io/badge/Swift-5.5%2B-orange.svg)](https://swift.org) ![Platforms](https://img.shields.io/badge/platforms-iOS%20|%20macOS%20|%20tvOS%20|%20watchOS%20|%20Linux-blue.svg) [![License](https://img.shields.io/badge/License-MIT-lightgrey.svg)](#license) -**viz-swift-lib** is a low-level, highly flexible Swift library for interacting with the **VIZ blockchain**. +A low-level, unopinionated Swift library for the **VIZ blockchain**. -It provides building blocks for: -- creating operations -- composing transactions -- computing transaction digests -- signing transactions with `secp256k1` -- broadcasting transactions to the network - -The library does **not impose any architecture** and does not hide protocol details, making it suitable for: -- mobile wallets -- backend services -- bots -- experimental and research projects +`viz-swift-lib` gives you the primitives — operations, transactions, signing, JSON-RPC — and stays out of your way. Good for mobile wallets, backend services, bots, and research projects where you want full control over how the chain is used. --- -## Design philosophy +## Contents + +- [Features](#features) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Keys](#keys) +- [Assets](#assets) +- [Common operations](#common-operations) +- [Signing URLs](#signing-urls) +- [Authority management](#authority-management) +- [Error handling](#error-handling) +- [Requirements](#requirements) +- [Contributing](#contributing) +- [License](#license) + +--- -Unlike high-level SDKs, `viz-swift-lib` focuses on: +## Features -- **Full control** — you explicitly create operations, transactions, and signatures -- **Composability** — multiple operations can be combined in a single transaction -- **Security** — private keys and signing always remain under your control -- **Cross-platform** — works on Apple platforms and Linux +- **Full operation coverage** — transfers, awards, account create/update, witness votes, vesting, escrow, recovery, and more +- **Composable transactions** — combine any number of operations in one signed transaction +- **Pure-Swift signing** — `secp256k1` ECDSA, key derivation from seed, WIF import/export +- **Async/await JSON-RPC client** — `actor`-based, `Sendable` types throughout +- **No hidden state** — you decide how to manage keys, sessions, retries, and broadcasting +- **Cross-platform** — Apple platforms and Linux -If you understand how the VIZ blockchain works, this library does not abstract or restrict anything. +This is **not** a wallet or a high-level SDK: there is no auto-key-management, no UI, no transaction queue. If you need those, build them on top. --- @@ -39,7 +45,7 @@ If you understand how the VIZ blockchain works, this library does not abstract o ### Swift Package Manager -Add the following to your `Package.swift`: +In `Package.swift`: ```swift dependencies: [ @@ -50,43 +56,28 @@ dependencies: [ ] ``` -Or in Xcode: File → Add Packages → Enter the repository URL. +Or in Xcode: **File → Add Packages…** and enter the repository URL. -## Quick Start +--- -### 1. Initialize the Client +## Quick start + +A complete signed transfer from one account to another: ```swift import VIZ let client = VIZ.Client(address: URL(string: "https://node.viz.cx")!) -``` - -### 2. Fetch Account Information - -```swift -let request = VIZ.API.GetAccounts(names: ["alice"]) -let accounts = try await client.send(request) - -if let account = accounts.first { - print("Balance: \(account.balance)") - print("Energy: \(account.energy)") - print("Vesting Shares: \(account.vestingShares)") -} -``` - -### 3. Create and Sign a Transaction -```swift -// Get dynamic global properties for transaction reference +// 1. Fetch chain head for the transaction reference block. let props = try await client.send(VIZ.API.GetDynamicGlobalProperties()) -// Create a private key from seed -guard let privateKey = VIZ.PrivateKey(seed: "alice" + "active" + "password") else { - throw Error.invalidKey +// 2. Derive the signing key (here: from username + role + password). +guard let key = VIZ.PrivateKey(seed: "alice" + "active" + "password") else { + fatalError("invalid seed") } -// Create a transfer operation +// 3. Build the operation. let transfer = VIZ.Operation.Transfer( from: "alice", to: "bob", @@ -94,154 +85,147 @@ let transfer = VIZ.Operation.Transfer( memo: "Thanks for everything!" ) -// Build the transaction -let transaction = VIZ.Transaction( +// 4. Wrap it in a transaction and sign. +let tx = VIZ.Transaction( refBlockNum: UInt16(props.headBlockNumber & 0xFFFF), refBlockPrefix: props.headBlockId.prefix, expiration: props.time.addingTimeInterval(60), operations: [transfer] ) +let signed = try tx.sign(usingKey: key) -// Sign and broadcast -let signedTx = try transaction.sign(usingKey: privateKey) -let broadcast = VIZ.API.BroadcastTransaction(transaction: signedTx) -let confirmation = try await client.send(broadcast) - +// 5. Broadcast. +let confirmation = try await client.send( + VIZ.API.BroadcastTransaction(transaction: signed) +) print("Transaction ID: \(confirmation.id.base58EncodedString() ?? "")") ``` -## Common Use Cases +--- -### Award to Another Account +## Keys -```swift -let award = VIZ.Operation.Award( - initiator: "alice", - receiver: "bob", - energy: 1000, // 10% energy - customSequence: 0, - memo: "Great content!", - beneficiaries: [ - VIZ.Operation.Beneficiary(account: "charlie", weight: 1000) - ] -) +### From a seed -let transaction = VIZ.Transaction( - refBlockNum: UInt16(props.headBlockNumber & 0xFFFF), - refBlockPrefix: props.headBlockId.prefix, - expiration: props.time.addingTimeInterval(60), - operations: [award] -) +```swift +let privateKey = VIZ.PrivateKey(seed: "username" + "active" + "password")! +let publicKey = privateKey.createPublic() -let signedTx = try transaction.sign(usingKey: regularKey) +print(publicKey.address) // e.g. VIZ7… +print(privateKey.wif) // e.g. 5K… ``` -### Create a New Account +### From WIF or raw bytes ```swift -// Generate keys for the new account -let masterKey = VIZ.PrivateKey(seed: "newuser" + "master" + "password")! -let activeKey = VIZ.PrivateKey(seed: "newuser" + "active" + "password")! -let regularKey = VIZ.PrivateKey(seed: "newuser" + "regular" + "password")! -let memoKey = VIZ.PrivateKey(seed: "newuser" + "memo" + "password")! +// WIF +let key = VIZ.PrivateKey("5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3")! -let accountCreate = VIZ.Operation.AccountCreate( - fee: VIZ.Asset(1.0, .viz), - creator: "alice", - newAccountName: "newuser", - master: VIZ.Authority(keyAuths: [VIZ.Authority.Auth(masterKey.createPublic())]), - active: VIZ.Authority(keyAuths: [VIZ.Authority.Auth(activeKey.createPublic())]), - regular: VIZ.Authority(keyAuths: [VIZ.Authority.Auth(regularKey.createPublic())]), - memoKey: memoKey.createPublic(), - jsonMetadata: "" -) +// Raw bytes (32-byte private + 1-byte network ID) +let keyFromBytes = VIZ.PrivateKey(keyData) ``` -### Delegate Vesting Shares +### Sign and verify a message ```swift -let delegate = VIZ.Operation.DelegateVestingShares( - delegator: "alice", - delegatee: "bob", - vestingShares: VIZ.Asset(1000.0, .vests) -) +let digest = "Hello VIZ".data(using: .utf8)!.sha256Digest +let signature = try privateKey.sign(message: digest) + +let recovered = signature.recover(message: digest, prefix: .mainNet) +print(recovered?.address ?? "could not recover") ``` -### Fetch Account History +--- -```swift -let history = VIZ.API.GetAccountHistory( - account: "alice", - from: -1, - limit: 100 -) +## Assets -let operations = try await client.send(history) +```swift +let vizTokens = VIZ.Asset(100.5, .viz) // "100.500 VIZ" +let shares = VIZ.Asset(1000.0, .vests) // "1000.000000 VESTS" -for item in operations { - print("Block: \(item.value.block)") - print("Operation: \(item.value.operation)") - print("Timestamp: \(item.value.timestamp)") -} +let parsed = VIZ.Asset("50.000 VIZ")! // parse from string +print(parsed.resolvedAmount) // 50.0 +print(parsed.description) // "50.000 VIZ" ``` -## Key Management +--- -### Generate Keys from Seed +## Common operations -```swift -// Generate a private key from account name and password -let privateKey = VIZ.PrivateKey(seed: "username" + "active" + "password")! - -// Derive the public key -let publicKey = privateKey.createPublic() +### Fetch accounts -print("Public Key: \(publicKey.address)") -print("Private Key (WIF): \(privateKey.wif)") +```swift +let accounts = try await client.send(VIZ.API.GetAccounts(names: ["alice"])) +if let account = accounts.first { + print(account.balance) // VIZ + print(account.energy) // current energy bar + print(account.vestingShares) // VESTS +} ``` -### Import Keys +### Award another account ```swift -// From WIF format -let privateKey = VIZ.PrivateKey("5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3")! - -// From raw bytes -let keyData = Data(/* 33 bytes with network ID */) -let privateKey = VIZ.PrivateKey(keyData) +let award = VIZ.Operation.Award( + initiator: "alice", + receiver: "bob", + energy: 1000, // 10% energy + customSequence: 0, + memo: "Great content!", + beneficiaries: [ + VIZ.Operation.Beneficiary(account: "charlie", weight: 1000) + ] +) ``` -### Sign and Verify Messages +### Create a new account ```swift -let message = "Hello VIZ".data(using: .utf8)!.sha256Digest -let signature = try privateKey.sign(message: message) +let master = VIZ.PrivateKey(seed: "newuser" + "master" + "password")! +let active = VIZ.PrivateKey(seed: "newuser" + "active" + "password")! +let regular = VIZ.PrivateKey(seed: "newuser" + "regular" + "password")! +let memo = VIZ.PrivateKey(seed: "newuser" + "memo" + "password")! -let publicKey = signature.recover(message: message, prefix: .mainNet) -print("Recovered public key: \(publicKey?.address ?? "none")") +let create = VIZ.Operation.AccountCreate( + fee: VIZ.Asset(1.0, .viz), + creator: "alice", + newAccountName: "newuser", + master: VIZ.Authority(keyAuths: [VIZ.Authority.Auth(master.createPublic())]), + active: VIZ.Authority(keyAuths: [VIZ.Authority.Auth(active.createPublic())]), + regular: VIZ.Authority(keyAuths: [VIZ.Authority.Auth(regular.createPublic())]), + memoKey: memo.createPublic(), + jsonMetadata: "" +) ``` -## Working with Assets +### Delegate vesting shares ```swift -// Create assets -let vizTokens = VIZ.Asset(100.5, .viz) // 100.500 VIZ -let vestingShares = VIZ.Asset(1000.0, .vests) // 1000.000000 VESTS +let delegate = VIZ.Operation.DelegateVestingShares( + delegator: "alice", + delegatee: "bob", + vestingShares: VIZ.Asset(1000.0, .vests) +) +``` -// Parse from string -let asset = VIZ.Asset("50.000 VIZ")! +### Read account history -// Access amount with proper precision -print(asset.resolvedAmount) // 50.0 -print(asset.description) // "50.000 VIZ" +```swift +let history = try await client.send( + VIZ.API.GetAccountHistory(account: "alice", from: -1, limit: 100) +) +for item in history { + print(item.value.block, item.value.timestamp, item.value.operation) +} ``` +--- + ## Signing URLs -Create delegated signing requests using `viz://` URLs: +Build delegated signing requests using `viz://` URLs: ```swift -// Create a signing URL with an operation let transfer = VIZ.Operation.Transfer( from: "__signer", to: "bob", @@ -258,102 +242,97 @@ let params = VIZ.VIZURL.Params( let signingURL = VIZ.VIZURL(operation: transfer, params: params)! print(signingURL.description) -// Resolve the URL to a transaction +// Resolve a received URL back into a signable transaction: let options = VIZ.VIZURL.ResolveOptions( refBlockNum: UInt16(props.headBlockNumber & 0xFFFF), refBlockPrefix: props.headBlockId.prefix, expiration: props.time.addingTimeInterval(60), signer: "alice" ) - -let transaction = try signingURL.resolve(with: options) +let resolved = try signingURL.resolve(with: options) ``` -## Advanced Features - -### Custom Operations - -The library supports all VIZ blockchain operations including: - -- Account management (create, update, recovery) -- Witness operations (voting, updates) -- Content operations (custom json, awards) -- Economic operations (transfers, conversions, escrow) -- And many more... - -### Binary Encoding - -All types conform to `VIZEncodable` for efficient binary serialization: - -```swift -let encoder = VIZ.VIZEncoder() -try transaction.binaryEncode(to: encoder) -let binaryData = encoder.data -``` +--- -### Authority Management +## Authority management -Define complex signing authorities with weights: +Compose multi-sig authorities with weighted account- and key-based auths: ```swift let authority = VIZ.Authority( weightThreshold: 2, accountAuths: [ VIZ.Authority.Auth("alice", weight: 1), - VIZ.Authority.Auth("bob", weight: 1) + VIZ.Authority.Auth("bob", weight: 1) ], keyAuths: [ - VIZ.Authority.Auth(publicKey, weight: 1) + VIZ.Authority.Auth(somePublicKey, weight: 1) ] ) ``` -## Error Handling +--- -The library uses Swift's structured error handling: +## Error handling ```swift do { let result = try await client.send(request) -} catch VIZ.Client.Error.responseError(let code, let message) { - print("RPC Error \(code): \(message)") -} catch VIZ.Client.Error.networkError(let message, let error) { - print("Network Error: \(message)") +} catch let VIZ.Client.Error.responseError(code, message) { + print("RPC error \(code): \(message)") +} catch let VIZ.Client.Error.networkError(message, _) { + print("Network error: \(message)") +} catch let VIZ.Client.Error.codingError(message, _) { + print("Coding error: \(message)") } catch { print("Unexpected error: \(error)") } ``` +--- + ## Requirements -- Swift 5.5 or later -- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+ / Linux +- Swift **5.5** or later +- iOS **13.0+** · macOS **10.15+** · tvOS **13.0+** · watchOS **6.0+** · Linux -## Dependencies +### Dependencies -- [secp256k1](https://github.com/greymass/secp256k1) - ECDSA signatures and secret/public key operations on curve secp256k1 -- [OrderedDictionary](https://github.com/lukaskubanek/OrderedDictionary) - Ordered dictionary data structure lightweight implementation +- [secp256k1](https://github.com/greymass/secp256k1) — ECDSA signatures and curve operations +- [OrderedDictionary](https://github.com/lukaskubanek/OrderedDictionary) — lightweight ordered dictionary + +--- ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome — please open a Pull Request. + +### Tests + +```bash +swift test # unit + integration +swift test --filter UnitTests # unit only +swift test --filter IntegrationTests # integration only (requires live node) +``` -### Running tests +### Local development -To run all tests simply run `swift test`, this will run both the unit- and integration-tests. To run them separately use the `--filter` flag, e.g. `swift test --filter IntegrationTests` +Generate an Xcode project: -### Developing +```bash +swift package generate-xcodeproj +``` -Development of the library is best done with Xcode, to generate a `.xcodeproj` you need to run `swift package generate-xcodeproj`. +For test coverage in Xcode: **Scheme → Manage Schemes → viz-swift-lib → Test → Options → Gather coverage for some targets**, then add the `viz-swift-lib` target. -To enable test coverage display go "Scheme > Manage Schemes..." menu item and edit the "viz-swift-lib" scheme, select the Test configuration and under the Options tab enable "Gather coverage for some targets" and add the `viz-swift-lib` target. +After adding unit tests, register them in `Tests/UnitTests/XCTestManifests.swift` so they also run on Linux. -After adding adding more unit tests the `swift test --generate-linuxmain` command has to be run and the XCTestManifest changes committed for the tests to be run on Linux. +--- ## License -This library is available under the MIT license. See the LICENSE file for more info. +MIT — see [LICENSE](LICENSE). ## Support -For questions and support, please visit the [VIZ.cx community](https://t.me/viz_cx). +Questions and discussion: [VIZ.cx community on Telegram](https://t.me/viz_cx). From 8d062b2e37ceb6cbc20b0e2620441432deb49b01 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 17:22:21 +0800 Subject: [PATCH 15/16] docs: add AGENTS.md and CLAUDE.md for AI coding agents AGENTS.md captures: project layout, build/test commands, code conventions (async/actor/Sendable, VIZCodable contracts, JSON snake_case, raw-Data no-length-prefix), the Linux manifest hand-edit workflow, the 5 grep-discoverable known quirks with source locations, and commit style. CLAUDE.md is a one-line pointer to AGENTS.md so Claude Code picks up the same guidance. Also gitignores .claude/settings.local.json (user-local). --- .gitignore | 1 + AGENTS.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 +++ 3 files changed, 80 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 1a1b81d..7ff26a1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ Package.resolved !/docs/superpowers/ /.gh-pages .swiftpm +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b01ad46 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +Guidance for AI coding agents (Claude Code, Codex, Cursor, Aider, etc.) working in this repository. + +## Project + +**viz-swift-lib** — a low-level Swift library for the VIZ blockchain (operations, transactions, signing, JSON-RPC). Modernized to Swift 5.5+, async/await, `actor Client`, `Sendable` throughout. Public README: [README.md](README.md). + +## Layout + +- `Sources/VIZ/` — public library. One file per concept (`Operation.swift`, `Transaction.swift`, `Asset.swift`, `Client.swift`, …). +- `Sources/Crypto/` — internal crypto primitives. +- `Tests/UnitTests/` — XCTest suite, no network. Fast (sub-second). +- `Tests/IntegrationTests/` — hits a live node (`https://node.viz.cx`). Flaky by nature. +- `Tests/UnitTests/XCTestManifests.swift` — Linux test discovery. Must be hand-edited when adding tests. + +## Build & test + +```bash +swift build +swift test --filter UnitTests # always run this — fast, no network +swift test --filter IntegrationTests # only when you know the node is up +swift test # both (may flake on integration) +``` + +Unit suite runs ~108 tests in under a second. If your changes push it over a second, that's worth investigating. + +## Conventions + +- **Async API surface uses `async`/`await` and `Sendable`.** Don't introduce completion-handler APIs. +- **`actor Client` owns network state.** Don't add shared mutable globals. +- **Public types conform to `VIZCodable` (binary) and `Codable` (JSON snake_case).** When adding a new operation or model type, add both sides plus tests. +- **No length prefix on raw `Data` in `VIZEncoder`** — it appends bytes verbatim. This is a load-bearing contract; tests pin it. +- **JSON uses snake_case** via `JSONEncoder.KeyEncodingStrategy.convertToSnakeCase` and a custom date format `yyyy-MM-dd'T'HH:mm:ss`. +- **Operations are dispatched manually** in `AnyOperation.init(from:)` and `AnyOperation.encode(to:)`. Adding a new operation requires editing both switches plus `OperationId`. + +## Adding tests + +1. Add test methods to the relevant file in `Tests/UnitTests/`. +2. Register them in `Tests/UnitTests/XCTestManifests.swift` (the `__allTests` array for the class **and** the `allTests()` list at the bottom — the latter is wrapped in `#if !os(macOS)`). +3. `swift test --generate-linuxmain` is **deprecated** in current toolchains — edit the manifest by hand. +4. Use the existing helpers in `Tests/UnitTests/Common.swift`: `AssertEncodes`, `AssertDecodes`, `TestDecode`. For new operation tests, use the `OperationFixture` + `roundTrip` helpers in `Tests/UnitTests/Operation.swift`. + +## Known quirks (do not "fix" silently) + +Each of these is pinned by a test and marked with a `// TODO:` in source. Don't change behavior without also updating the pin. + +- `API.Share.init(from:)` returns `0` on parse failure instead of throwing (`Sources/VIZ/API.swift:84`). +- `ExtendedAccount.currentEnergy` reads `Date()` implicitly (`Sources/VIZ/API.swift:145`). +- `Operation.Convert` has no `OperationId` case — encode falls through (`Sources/VIZ/Operation.swift:132`). +- `custom` op id decodes to `Operation.CustomJson` but encode dispatches on `Operation.Custom` — incompatible (`Sources/VIZ/Operation.swift:1127, 1216`). +- `PublicKey.AddressPrefix.testNet` stringifies to `"VIZ"` like `.mainNet` — intentional (VIZ has no separate testnet prefix). + +Find them all with: `grep -n "TODO:" Sources/VIZ/*.swift`. + +## Things to avoid + +- **Don't restructure `Operation.swift`** (it's 1,294 LOC by design — one file is the convention here). +- **Don't add new abstractions or feature-flag shims for hypothetical needs.** This is a primitives library. +- **Don't change the public API signatures** without checking — downstream code on VIZ depends on shape stability. +- **Don't mock network in IntegrationTests** — they exist to catch real wire-format drift against a live node. + +## Commit style + +- Imperative mood, lowercase, no trailing period (`test: add asset edge-case coverage`). +- Prefixes used in this repo: `feat:`, `fix:`, `test:`, `chore:`, `refactor:`, `docs:`. +- **No `Co-Authored-By: Claude …` trailer** on commit messages. + +## Planning & specs + +Long-running design work lives under `docs/superpowers/`: + +- `docs/superpowers/specs/` — design documents (one per feature). +- `docs/superpowers/plans/` — task-by-task implementation plans derived from specs. + +If you're about to take on something non-trivial, look there first to see if it's already been planned. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e62f79a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +See [AGENTS.md](AGENTS.md) for project guidance. Everything there applies to Claude Code as well. From f4f99296ff4d03c1bb5bf634d1aaf0d0e7588a75 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 16 May 2026 17:26:32 +0800 Subject: [PATCH 16/16] ci: scope test job to UnitTests only IntegrationTests hit https://node.viz.cx and fail when chain state drifts from fixtures (block heights, balances). The unit suite is the load-bearing contract; integration tests can be run manually or in a scheduled job later. This is the open question flagged in docs/superpowers/specs/2026-05-16-test-coverage-and-safe-fixes-design.md under 'CI: filter the badge workflow to --filter UnitTests'. --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index caf152d..e1cf8fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,5 +15,5 @@ jobs: - uses: actions/checkout@v3 - name: Build run: swift build -v - - name: Run tests - run: swift test -v + - name: Run unit tests + run: swift test -v --filter UnitTests