Skip to content

Extract TS SDK, hoist pnpm workspace to repo root#160

Open
ilchu wants to merge 15 commits into
devfrom
ic/papi-sdk
Open

Extract TS SDK, hoist pnpm workspace to repo root#160
ilchu wants to merge 15 commits into
devfrom
ic/papi-sdk

Conversation

@ilchu

@ilchu ilchu commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Part of #135 / #136 (PR A of two: examples + E2E migrate now, UIs + test-helpers follow in PR B).

One workspace, one descriptor source

pnpm-workspace.yaml, the lockfile, and a private root package.json move to the repo root; members now cover user-interfaces/*, shared/*, the shared descriptors package, packages/*, and examples/papi. The examples npm island is deleted — its own lockfile (which had drifted: manifest said polkadot-api ^1.23.3, lock resolved 2.1.5), its own generated .papi/ copy, and its npm install path in papi-setup. The whole repo now resolves exactly one polkadot-api instance and one @polkadot-api/descriptors package.

packages/sdk — seed of the dedicated TS SDK (#123)

Flat typed functions ported from examples/papi/{common,api,sc-api}.js: chain connect + readiness guards, signers (seedToKeypair, makeSigner, Alice..Ferdie), a unified submitTx, provider-HTTP helpers, and per-pallet extrinsic wrappers. pallet_revive helpers live on the ./revive subpath so viem stays out of non-contract dependency graphs. Deliberately flat rather than a class facade: 1:1 with existing call sites, tree-shakeable from Vite bundles, and re-homeable into #123's core/layer0/layer1 split without rewrites — Web3Storage.connect() later becomes a thin class delegating here. Test-only powers (sudo cleanup, expect-failure submission, Playwright fixtures) stay in test-helpers/examples by design; see packages/sdk/README.md.

The suite's finalization semantics (d3de2d9, 391f8bf, 2bd19cf, 18e1416) are encoded as API defaults, not conventions to remember: submitTx resolves at in-block inclusion, reads target the best block via READ_OPTS, only the challenge creators finalize (challenge ids embed their creation block), and events come from the tx result — never from finalized-only event watches. The wait helpers are watchValue-based (replays the current value on subscribe → no missed-event window, no poll interval to tune): the 1s-poll waitForAgreementAcceptance is gone, and waitForPrimaryProvider is ready to replace the three identical ~150s polling copies in the UIs in PR B.

Stale-metadata drift: found, fixed, and now gated

The tracked metadata snapshot in shared/papi had silently drifted from the runtime — it lacked pallet_revive entirely, and claim_expired_agreement had dropped its provider arg on-chain while the old wrapper kept passing it (silently discarded by the encoder). Regenerated from a live chain (187KB → 225KB), and the e2e CI job now re-fetches metadata from its running chain and fails on diff — the papi:check that the descriptor unification designed but never wired.

PAPI v2 conventions

The old examples only worked by pinning substrate-bindings 0.16. On v2: Binary/Enum come from polkadot-api itself, Vec<u8> fields take plain Uint8Array, fixed-size binary travels as 0x-hex (SizedHex) via a new asHex helper, event filter returns {payload} records, and getWsProvider moved to polkadot-api/ws. CLAUDE.md's guidance table said the opposite and is updated.

CI

A setup-pnpm composite action (Node + pnpm 11 + store cache + frozen root install) replaces the per-job setup dances; the smoke/e2e/sc jobs gain it because papi-setup is now a root pnpm install. c8 runs from the repo root so the coverage gate spans packages/sdk alongside the suite (92–100% per sdk module on the validation run). ui-checks/deploy-ui path filters extend to the hoisted workspace files. The justfile invokes example scripts via node --import tsx (raw-TS sdk imported from plain node; the e2e runner forwards the loader to its child processes). pnpm 10 → 11 in CI to match the lockfile producer and the new allowBuilds workspace key.

Validation

Against a local omni-node dev chain + provider: e2e suite 10/10 workflows (95/95 tests), just demo (2/2 ChallengeDefended through the finalized challenge path), sc-demo, sc-coverage (15/15 selectors), sc-team-drive, sc-token-gated; sdk typechecks under tsc --strict. The same suites gate this PR in CI, including the new metadata drift check.

Part of #135 / #136 (PR A of two: examples + E2E migrate now, UIs +
test-helpers follow in PR B).

Workspace: pnpm-workspace.yaml, lockfile, and a private root package.json
move to the repo root; members now include user-interfaces/*, shared/*,
the shared descriptors package, packages/*, and examples/papi. This
deletes the examples npm island (own lockfile, own generated .papi copy,
polkadot-api manifest/lock drift) — the whole workspace resolves one
polkadot-api and one descriptor instance.

packages/sdk (@web3-storage/sdk): flat typed functions ported from
examples/papi/{common,api,sc-api}.js — connect/readiness guards, signers
(seedToKeypair, makeSigner, Alice..Ferdie), unified submitTx, provider
HTTP helpers, and per-pallet extrinsic wrappers; pallet_revive helpers
live on the ./revive subpath so viem stays out of non-contract graphs.
Canonical tx semantics are encoded as API defaults: in-block ("best")
submission, READ_OPTS best-block reads, finalization only for the
challenge creators (ids embed the creation block), events extracted from
the tx result. The wait helpers are watchValue-based (replays current
value on subscribe -> no missed-event race, no poll interval), replacing
the 1s-poll waitForAgreementAcceptance; waitForPrimaryProvider is ready
to replace the three UI polling copies in PR B.

PAPI v2 conventions adopted throughout (the old examples leaned on
substrate-bindings 0.16): Binary/Enum come from "polkadot-api" itself,
Vec<u8> fields take plain Uint8Array, fixed-size binary travels as
0x-hex (SizedHex) via a new asHex helper, and getWsProvider moved to
"polkadot-api/ws". CLAUDE.md guidance updated accordingly.

The tracked metadata snapshot in shared/papi had drifted from the
runtime (187KB -> 225KB; it lacked pallet_revive entirely, and
claim_expired_agreement had dropped its provider arg). Regenerated from
a live chain, and the e2e CI job now re-fetches metadata and fails
loudly on drift — the papi:check that PAPI_OVERHAUL designed but never
shipped.

CI: a setup-pnpm composite action (Node + pnpm 11 + store cache + frozen
install at root) replaces the per-job setup-node/pnpm dances; the smoke,
e2e, and sc jobs gain it because papi-setup is now a root pnpm install.
c8 runs from the repo root so coverage spans packages/sdk alongside the
suite. ui-checks/deploy-ui path filters extended to the hoisted
workspace files. justfile invokes example scripts via node --import tsx
(raw-TS sdk imported from plain node; the e2e runner forwards the loader
to its child processes).

Validated against a local dev chain + provider: e2e suite 10/10
workflows (95/95 tests), full-flow demo (2/2 ChallengeDefended),
sc-demo, sc-coverage (15/15 selectors), sc-team-drive, sc-token-gated.
@ilchu ilchu self-assigned this Jun 11, 2026
ilchu added 5 commits June 11, 2026 17:44
Inside the CI container, git refuses to discover the host-owned checkout
(dubious-ownership guard surfaces as "Not a git repository"), so the
drift check's git diff always failed regardless of metadata state.
Compare a sha256 of the tracked snapshot before/after regeneration
instead — no git involved, and the one-file content hash is the precise
signal anyway.

Also guard the always()-running coverage report against the case where
an earlier step failed before the E2E suite ran (no tmp dir -> c8
ENOENT noise on top of the real failure).
…eb3Storage facade

Delivers the #123 monorepo layout while keeping every existing import
stable: @web3-storage/core (backend-free, browser-safe: byte/hex utils,
retrying httpFetch, provider request signing per auth.rs, CID
verification with CidMismatchError), @web3-storage/layer0 (the former
packages/sdk content, now depending on core), @web3-storage/layer1
(FileSystemClient + S3Client over layer 0), and @web3-storage/sdk as the
umbrella that re-exports all three and hosts the Web3Storage.connect
facade. Dependency direction is strictly layer1 -> layer0 -> core;
consumers keep importing @web3-storage/sdk (./revive, plus new ./fs and
./s3 subpaths).

The layer-1 clients unify the three hand-rolled implementations: chain
ops delegate to the pallet wrappers (which now accept a pass-through
SubmitOpts param — closing PR B's noted gap — so clients run silent,
in-block, no auto-retry), provider resolution shares one cached
watchValue-based resolver, HTTP goes through core's retrying fetch and
is signed (raw sr25519, ChainSigner now carries its keypair when locally
derived) whenever possible. getObject verifies downloads against the
on-chain CID: single-chunk mismatches throw CidMismatchError (data_root
== chunk hash), multi-chunk payloads surface verified: false (DAG-walk
parity with the Rust client is tracked separately), and path-based fs
downloads are documented as unverified.
…age/sdk

user-interfaces/sdk/typescript/{file-system,s3} (2,212 LOC) had zero
consumers — not workspace members, never built, zero tests, never in CI
or the justfile — and both UIs had long since reimplemented everything
they covered. They were also browser-broken (Buffer in base64 helpers),
did no CID verification (audit finding B1; blake2AsU8a imported and
never called), and depended on the forbidden @polkadot/keyring +
@polkadot/util-crypto. The API shape worth keeping (type names,
bucket-name/object-key validation, x-amz-meta round-tripping) lives on
in @web3-storage/layer1.

docs/README.md, docs/filesystems/README.md (which falsely claimed
drive-ui used the stub), and console-ui's README now point at
@web3-storage/sdk; its README documents the #123 layout, tx semantics,
and the download-verification matrix.

for (const providerAccount of providers) {
const provider = await api.query.StorageProvider.Providers.getValue(providerAccount);
const provider = await api.query.StorageProvider.Providers.getValue(providerAccount, { at: "best" });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ilchu I introduced for examples constant export const READ_OPTS = { at: "best" };, but for real usage, not sure if BestBlock is ok, maybe we should stick to the finalized (explicitly -> refactor to some constant, so we can change easily)

const buckets: BucketInfo[] = [];
for (const bucketId of bucketIds) {
const bucket = await this.api!.query.S3Registry.S3Buckets.getValue(bucketId);
const bucket = await this.api!.query.S3Registry.S3Buckets.getValue(bucketId, READ_OPTS);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ilchu for real UI, probably we should go with finalize, as I said before better refactor this and set the separate const as finalized for now

@bkontur bkontur left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm modulo I would keep the real UIs using "finalized":

  • either remove READ_OPTS from UIs
  • or introduce separate const for UIs READ_OPTS = "finalized-or-what-is-the-format"

we can keep READ_OPTS = "at: best" for examples and tests, that is ok

#160 (comment)

@bkontur bkontur left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ilchu added 7 commits June 11, 2026 22:14
…09 through the layer-1 clients

All 17 example/E2E scripts move from .js to .ts under a strict tsconfig
(typecheck script + typescript devDep added; the stale .c8rc.json that
still listed the deleted api.js/common.js goes away, as does e2e/format.js
— its formatDispatchError has lived in the sdk since PR A). Typing is
pragmatic: typed sdk imports drive inference, cross-closure ids are
annotated, storage reads in tests use non-null assertions where an assert
follows anyway, and provider HTTP JSON stays loose where the sdk itself
returns any. The runner discovers *.ts workflows and forwards the tsx
loader to its children; justfile file references updated.

e2e/03 now drives the S3 surface through S3Client (chain ops + a new 3.12
object HTTP round-trip exercising put/get/list/delete WITH on-chain CID
verification — the verified=true path), keeping one raw putChunk path for
layer-0 coverage. e2e/09 drives drives through FileSystemClient and gains
9.8, the first e2e coverage of the provider's /fs HTTP routes from TS
(mkdir/upload/ls/download/delete round-trip).
…ges + examples in CI

76 unit tests across the three packages: byte/hex/base64 roundtrips
(incl. >64KiB chunked base64), CID verification incl. CidMismatchError
payloads, httpFetch retry/backoff semantics (5xx retried, 4xx and aborts
not), the provider auth-header format against the canonical message,
signer derivation grammar parity, the submitTx engine against rxjs
Subject fakes (best/finalized modes, TxDispatchError, stale-nonce retry
on/off with fake timers), requireOneEvent, NonceManager (best-block
read), firstMatch (skip/timeout/onTick), and the fs/s3 clients against
injected fetch mocks (URL shapes, auth + x-amz-meta headers, wire-shape
mapping, and the getObject verification matrix: verified single-chunk,
hard-fail on corruption, unverified without on-chain metadata).

ui-checks gains a typecheck step for the packages + examples and its
change filter now covers examples/papi/** (example-only changes would
previously skip CI entirely).
… + tampering spec

The final unification step (#135's "UIs consume the shared code" end to
end): drive-ui's DriveClient shrinks to a thin adapter over
FileSystemClient — fs HTTP ops, retry/backoff, provider-URL cache, and
acceptance waits all come from the sdk; what remains app-side is Blob
conversion, status strings, and stateless signer swapping (public key
recovered from the SS58 address; wallet flows carry no raw keypair, so
provider requests stay unsigned exactly as before). Its stale-descriptor
DriveCreated fallback is gone — CI fails loudly on metadata drift now.

console-ui's StorageClient delegates its object-HTTP half (put/get/
list/delete + request signing) to S3Client; setSigner now derives one
sdk ChainSigner (keypair + PolkadotSigner from a single makeSigner
call), encryption stays app-side wrapping opaque bytes so the on-chain
CID covers exactly what the provider stores, and on dev chains the
client pins the local provider URL (the provider stores data regardless
of agreements; the registered multiaddr points at the same endpoint).

Downloads are now verified: getObject checks bytes against the on-chain
S3Registry.Objects CID — the download toast says verified/unverified,
and a single-chunk mismatch hard-fails with CidMismatchError surfaced in
the failure toast. BucketRef.s3BucketId became optional for this
(HTTP routes only need the layer-0 id; without the s3 id the result is
flagged unverified). A new console-ui Playwright spec covers both
outcomes, simulating a lying provider via route interception against
metadata anchored from the test side.

Also folds in the stub-era doc repointing for docs/README.md,
docs/filesystems/README.md, and console-ui's README that a previous
commit message claimed but did not actually stage.

Validated locally: e2e suite 10/10 (TS scripts incl. the new 3.12 S3
round-trip and 9.8 fs round-trip), demo + sc-demo + sc-coverage, unit
suites 76/76, Playwright drive-ui 19/19, console-ui 16/16 (incl. both
verification tests), provider 11/11.
Per bkontur's review: READ_OPTS ({at: "best"}) and in-block submission
are the right trade for tests and examples, not for real UIs — those
keep the reorg-safe finalized view.

layer0 gains FINALIZED_READ_OPTS and READ_OPTS is documented as the
test/example default. The layer-1 clients now take readOpts + submitMode
options DEFAULTING to finalized reads and finalized submission
(UI-grade); the e2e workflows construct them with readOpts: READ_OPTS,
submitMode: "best" to keep test speed. resolveProviderEndpoint reads
finalized by default with the read view as a parameter — the resolver's
waitForProvider still resolves at best, because the acceptance it just
observed fired at the best head and a finalized read could lag the very
event that unblocked it.

The UI read sites that PR B had moved to best-block revert to the
finalized default (console-ui's ~15 reads, drive-ui's adapter reads),
and drive-ui's submission returns to finalized via the client default.
console-ui keeps its long-standing best-block submission (pre-dates this
series; Bulletin Chain pattern) with finalized reads, as it always had.
cleanProviderRegistry skipped dev providers with committed_bytes > 0
(can't wipe active agreements) — but a skipped provider that is still
accepting_primary keeps getting picked by the runtime's auto-match, and
with no node behind it every drive/bucket creation stalls until timeout.
Seen live: the e2e suite leaves Charlie with a 2 MiB agreement and
accepting_primary on, and drive-ui's whole Playwright suite then dies in
createDriveViaApi. Now the cleanup flips accepting_primary off for
anything it cannot remove, which is the same determinism trick
ensureSoleAcceptingProvider uses. Validated against the exact chain
state that reproduced the stall: drive-ui 19/19 after the fix.
Comment thread examples/papi/e2e/03-s3-bucket-and-objects.ts Fixed
ilchu and others added 2 commits June 12, 2026 16:22
…oss-platform reproducible

The e2e job's drift check failed honestly: the committed parachain.scale
(regenerated on a macOS toolchain) differs byte-wise — same 225,860 size
— from the metadata the ci-unified Linux container produces when
building the IDENTICAL runtime source. Verified by downloading the
failing run's build artifact, booting a probe chain from that exact
wasm, and extracting its metadata: the hash matches the "live chain"
side of the check (9018bbf0…) precisely.

The check compares bytes, so the committed snapshot must originate from
the build environment CI uses. This commit adopts the CI-built bytes as
canonical. All typechecks (packages, examples, three UIs) and unit
suites pass against descriptors generated from it.

DX note: refreshing this file from a macOS-built chain will fail the
check again — regenerate from the CI build artifact (or a container
build) instead. If that proves too annoying, the follow-up is a
semantic (decoded) comparison instead of a byte hash.
…tion or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants