Repository navigation
Conversation
…that completes Squashes the reliability half of the 0.7.1 follow-up. Custody membership and ciphertext replication become separate roles, so a node runs with --role custody and is never counted toward the replica requirement, and a kubo peering mesh is proven by real block transfer rather than assumed. Minting is repaired where it could not finish. External wallets are accepted wherever signability rather than proof type is the precondition; the creator tail raises exactly one wallet effect, the mint itself; a mint keeps the account it started with across retries, and Runtime refuses before raising an effect when the wallet's default has moved on, naming both accounts and the two ways out. Settlement waits on confirmations instead of refusing, a completed mint is owned as a Library item, and an approval that lapses is asked again rather than stranding what it stood for. Publish progress is read from the mint journal, so a stage cannot report a phase the server has not reached. Model-content object import stays out of protected-content replica placement: a Carrier object import proves its local copy and never asks the availability plane to place replicas. The storage role therefore runs custody, chain and ipfs only, needs no availability ensure URL, and the custody-host image no longer ships availability-provider. Carrier refuses the retired rights, key, decrypt and drm targets. A wallet connector opens as a window whether it is launched from the Wallet rail or the Wallet window, so both paths run the same ceremony; the separate connector sheet is removed. Failures and waits answer as typed data rather than one shared sentence. Original commits: 58686dd, b2ad4c0, f217bf0, 9989aa3, 8657a95, 9d62397, 7a6e955, 093b67d.
… replicas that settle Squashes three parallel workstreams that complete the buy-and-open journey. MARKETPLACE AND BUY. The protected shelf reads what Runtime actually publishes: the listing parser had refused every row carrying an availability receipt_digest since 2026-08-28, so protected items were invisible for twenty-four days, and one bad row no longer takes the whole answer down. A listing states its own kind, derived from the same content identity the open path enforces, so an item opens in the viewer its kind needs instead of the player for everything. A listing published on another Home can be added to this one, a purchase shows its terms before it spends anything, and a purchase says which wait it is in while Marketplace waits with it. THE OPEN CEREMONY. Every first rights-signature request answers requires_approval, for managed and external accounts alike, so the first open of every protected item returned a pending answer that both viewers rendered as "unavailable" -- the journey was a dead end at its first step. Open progress is now typed data carried beside the stable message, naming the stage, whether asking again can change the answer, whether a person must act, and where. Both viewers show the ceremony and re-issue the identical open until the person answers or the window closes, disposing timers, object URLs, MediaSource and renderer resources on every path. The approval window moves from sixty seconds to the largest that stays inside the recipient-authorization and rights-request contracts, so a human approval is no longer lost by default. AVAILABILITY. A real local pin counts as the replica it already is, replication sends the bytes rather than asking a peer to find them, and a delivered block graph settles on the peer's own pin. Metadata publishes had been finishing at one replica because every remote proof failed on a manifest-identity defect. Also: the locked-renderer boundary design, with the decisions that a decoder sits inside the decrypt provider under a pure-Rust rule and that AVIF keeps its current path; and a node_modules symlink carrying an operator's own absolute path is untracked, with the ignore rule widened from a directory pattern that never matched a symlink. Original commits: 80ca4467, d31c43a6, cafb624a, 7f29f1a2, 723711d9, 996eae09, e7ef1109, 55cd077c, ddde6665, d861fcd7, f2e1f9e4, 988211f3, 788a3b37, and the two integration merges 5dbb4199 and 7ab64780.
…rchase stops hiding after a reload
Two things a person could not do, both about a copy that is theirs and not in
front of them.
DOWNLOAD. A `.ddrm` file is made of public material: the metadata document the
token URI resolves to, and the content the listing names. What makes the copy
theirs is the access token on the chain, not the file. So the file can always
be built again — and until now it was only ever built as a side effect of
buying or minting, with no way to ask. Someone whose write failed, who deleted
the file, or who holds the token on a Home that never had it, owned something
they could not see and had nothing to press.
`download_owned_copy` runs the rebuild that already existed. The door is a live
`hasAccess` read at the head block for the account this principal transacts
with: a purchase record on this Home is neither necessary nor sufficient, which
is the point — someone who bought on one Home owns the same token on another,
and a local record is not a grant. Asking twice is safe, which is the whole
point of a control a person presses when something is missing.
A COPY IN FLIGHT. A purchase lives in Runtime, not in the page that started it,
so closing Marketplace or reloading left the row looking untouched. Pressing
Buy resumed the same attempt against the same effect — safe, and invisible.
The row now says a purchase is already under way and offers Continue, which
resumes rather than starting again, and does not re-ask for terms the record
has already fixed.
That is a new field, `purchase_in_flight`, rather than a new `access_state`
value. A value is something consumers switch on, and a new one falls silently
into whatever their default does; an unknown field is refused loudly by the
parser on the other side of this contract. Marketplace's own `accessStateLabel`
and `mediaActionButton` are two such switches, and both would have offered a
person a purchase that was already running.
Verified in this worktree:
cargo test -p elastos-server --lib -- runtime_custody
node --test capsules/marketplace/browser/src/listing.test.mjs # 21 pass
node scripts/marketplace-product-behavior-smoke.mjs # OK
node scripts/marketplace-product-layout-smoke.mjs # OK
node scripts/home-entropy-check.mjs # PASS
This also untracks `elastos/tools/browser-playwright-engine/node_modules`,
which my own buy-progress commit swept into the tree: a symlink I made locally
to avoid a second Playwright install while the disk was full. The ignore rule
reads `node_modules/` with a trailing slash, which is a directory pattern and
never matched a symlink of that name, so a broad `git add` took it. It resolved
in the worktree that made it and became a loop anywhere else, and its content
was a local path and an operator username, which this repository asks never to
commit. No committed value is right here; the only correct content is whatever
a local `npm ci` produces.
The browser gate walks both: Download appears on a copy someone holds and not
on an item still on offer, and it asks Runtime to rebuild exactly the copy its
row names; a row whose purchase is still under way says so and offers Continue.
…pletes, and a market anyone can see
A protected file opened and read on an installed Home for the first time, and
then everything the creator had been unable to choose became the next thing in
the way. Both halves are here.
An external wallet approval passes through three states, not two: `pending`
while it waits for the person, `approved` once they press Confirm, and
`completed` once the connector posts the signature back. The release read
`requires_approval`, which is `status == Pending` alone, so the middle state
looked like a finished approval carrying no signature and the open failed with
"wallet result carries no rights signature". That gap is a second or two wide
and every external open crosses it. Live evidence: the approval was raised and
confirmed, and eleven seconds later the release reported no signature. The
release now waits on `approved` too, replaying the identical request, and
answers a rejected or expired approval by its own type.
Approving an open meant confirming two kilobytes of replacement characters.
The signature was sound, so this was a consent defect: an approval screen
exists so a person can decide, and nobody can decide about a screen of binary.
The handoff sent canonical bytes 0x-prefixed, and a wallet decodes 0x input
before displaying it. The wallet is now shown four readable lines whose hash
covers them, `RightsRequestV1::signing_message` is the single definition, and
every site reaches the signature through it.
Every chunk read fetched the whole framed object and re-hashed all of it, kept
a megabyte and dropped the rest -- a thirteen-chunk read spent 95% of its
27.45 s between chunks rather than producing them. The open already fetches and
verifies that object, so it stages the verified bytes and a read seeks to its
own range. What is staged is published ciphertext, verified before writing and
length-checked on every read; per-chunk integrity rests on AES-256-GCM, as it
always did.
The wallet also opened two connector windows per click, because a click
bubbled through two listeners that both recognised it. Two windows offering
Review for one request let the same approval be answered twice.
And the listing metadata did not match the schema it claims, so items appeared
on the shelf without a title, type or price. Checked against the schema itself
rather than the generator, which mattered twice: `media.protectionType` is an
array, and `category` is a declared field.
A mint carries the metadata CID twice and they are not the same string. The
operative's base URI stays the folder, because `OperativePrimitive.uri(id)`
appends `/{id}.json` to whatever it is given. The media token's URI has to name
the document, because a marketplace dereferences it rather than appending -- a
folder there is why published listings arrived empty.
`AssetCreated._uri` echoes the media token URI, so the receipt corroboration
compares against the derived form. Adding the suffix at the encode site alone
would have left every mint settling on chain and then failing to verify; the
receipt tests caught exactly that.
**The access method.** All three publish now. `AssetFactory.registerNewAsset`
gates operative creation, listing and royalty split on the same `opType > 0`
branch, so free creates none of them: its `opRawData` is the content id alone,
which the factory still reads to bind the id, and its receipt may not be
expected to prove a listing that was never made. Buy and resell carries the
trailing `uint16 resellerCut` that only it may carry, defaulting to 90%.
**The channel.** A mint settles on the channel its creator chose, and a mint
that names none is refused. The configured `ledger` was being read as *the*
channel; it no longer is. The choice is recorded in the mint's own terms so a
retry publishes into the same place, and the source digest drops the channel
and the pay token, which now legitimately vary -- its domain moves to /v2 and
mints recorded under /v1 will be refused.
Channels come from a GraphQL index of on-chain state, asked
`access: "mint:0x…"` -- the set an account may publish into. Not `creator`,
which returns nothing for an account publishing into a public channel it did
not create, and not `user:`, which is the wider set including channels that
would refuse the mint. The directory is discovery, never authority: it may be
absent, stale or unapproved, and a typed address always works.
External HTTP follows the path the Wallet's price source already sets: off
until approved through the Inbox, the approval recorded with who gave it, every
fetch audited, and a failure degrading to a note rather than a refusal. Its own
policy and actions, because approving prices must not approve this.
**The currency.** Selecting USDC scaled a price by six decimals and then listed
it against the native token, because the currency was deliberately never sent:
0.11 USDC settled as 110000 wei, about a ten-trillionth of the intended price.
Not a mislabelled listing -- one priced in a currency nobody agreed to.
The pay token is now a term with an allow-list stating each token's decimals,
and the price's scale travels with it. A scale that does not match the chosen
token is refused before anything is encoded. An allow-list rather than any
address a page sends, because a token of unknown decimals is a price of unknown
meaning.
A read failing with "release approval is unavailable" told a person nothing,
while all three custody nodes had said "content access id is not bound on
chain" -- a mint too recent for the finalized block the release evidence is
required to read. The reason was dropped twice: the carrier replaced the peer's
message with a generic string, and the coordinator discarded each node's error
entirely.
The coordinator's classification stays text-free on purpose -- it decides
whether a node can have acted, which must never widen on a remote peer's
say-so. The node's own sentence is collected separately, bounded and
diagnostic, and reported with the refusal.
ONE WIRE, DEFINED ONCE
Four field mismatches between the Runtime and its chain provider reached a
person while every test passed: `op_type_code`, the channel and pay-token
reshape, `pay_token_decimals`, and a free mint's null listing. Each was found
by building and watching it fail.
One cause. The gateway declared each answer as a `deny_unknown_fields` struct,
the capsule built the same answer as a JSON literal, and the only thing
reconciling them was a hand-written mock that the tests answered with INSTEAD
of the capsule. A field added on one side left the other refusing at runtime
while the suite stayed green, so green was never evidence that the boundary
worked.
The shapes now live in the contracts crate both sides already depend on, and
the mock is built from those same structs rather than written out again. A
field that exists on one side only stops compiling.
The fourth was never reached. A free mint's receipt reports no quantity, price
or pay token, because it lists nothing, and the gateway declared all three
non-optional: the first free mint anyone tried would have failed at its
receipt. Shared types turned that into a type error, and the absence is
recorded as what it is -- a sale of nothing, at no price, in the chain's own
coin.
A SALE PRICED IN A TOKEN NO LONGER WAITS FOR FINALITY
A mint priced in anything but the chain's own coin re-read its entire listing
at a FINALIZED block, re-proving terms the receipt had already proved: the
`ItemListed` is emitted by the very transaction that settled. The one fact a
receipt cannot carry is where an ERC-20 sale pays through, which lives on the
operative rather than in the event.
So a USDC mint settled on chain and then failed. Sixteen minutes before Base
finalizes, the two evidence sources disagreed about a block neither had
finalized, and the listing went unassembled while the asset sat minted. Only
the payment processor is read now, at the head and corroborated across
sources, and a buyer's own purchase re-reads and re-checks it before any money
moves.
NOT ADDRESSED
The finality bar itself, which is correct: a key release may only rest on state
no reorg can take back, and that costs roughly sixteen minutes after a mint.
The viewer should render it as a wait rather than a dead end, and does not yet.
The custody ceremony's 16.9-36.6 s, which is three nodes each requiring two
agreeing finalized results from Base -- distributed work, not waste.
A CHANNEL DIRECTORY THE MARKETPLACE MAY READ, AND CONTENT A CAPSULE MAY SHOW
Reading an on-chain index had one surface and now has two, so the terms that
let it happen are written once instead of copied: source decision, policy
file, approval request, audit streams, the GraphQL post, and the
errors-before-data read that stops a refused query from looking like an empty
answer. The Creator's picker is re-pointed at it and its four tests pass
unchanged.
Each surface keeps its OWN request id, approve action, policy file and audit
stream. One endpoint is not one consent, and approving a picker in one app
must not quietly enable another.
The Marketplace asks for the channels that exist, with no `access` filter:
Shops is a window onto what others publish, not a list of this account's own
permissions. What it shows next to each channel -- whether this Home
administers it, already subscribes, or neither -- is read from the chain, not
from the index, because a directory naming a channel's creator is a label and
a label must not decide what a person is offered.
An unreadable channel answers `unknown` rather than `none`. `none` is what
draws a Subscribe button, so it has to rest on an answer rather than on a
silence -- otherwise an RPC hiccup invites someone to pay for a subscription
they already hold.
TWO REFUSALS THAT LOOK LIKE ANSWERS
Forty calls sent one at a time took 46 seconds with every card showing
"checking", so they were batched. Both configured sources refuse a batch that
size, and they refuse it differently:
mainnet.base.org a single object "maximum 10 calls in 1 batch"
base.drpc.org an ARRAY of 40 errors, every element the same refusal
The second is the dangerous one. A well-formed array whose every answer is an
error is indistinguishable from a source that answered and knew nothing, and
read that way it made a source contribute silence while looking healthy --
which is why most cards said "access unknown". A whole batch failing is now
treated as a refusal, so the caller asks again one call at a time; batches are
three, the strictest source's limit, and the chunks run together rather than
one after another.
CONTENT A CAPSULE CAN ACTUALLY SHOW
Published CID content was served correct and then thrown away by the browser.
A capsule runs under `require-corp` in an opaque origin, so a subresource
without `Cross-Origin-Resource-Policy` is fetched and discarded: a 200 that
renders nothing. Reading one with `fetch` needs CORS as well, and neither
header was set -- so an image was blocked and a document read failed before
any status could be seen, which is not a 404 a caller can act on.
A bare CID also answered `application/octet-stream` whatever its bytes were,
which a browser treats as a file to save rather than a picture to show. It is
typed from its own magic number now, and deliberately never as `text/html` or
`image/svg+xml` from an unnamed blob: both are documents that can carry
script, and this origin serves the Home's own pages.
EXPLORE IS THE MARKET, NOT THIS HOME'S SHELF
Explore showed what this Home held. A Home that has minted nothing therefore
opened a market with nothing in it, and there was nothing anywhere to buy --
which is not a thin shelf, it is the wrong shelf. It now shows what anyone has
minted, with this Home's own items among them rather than instead of them.
The index answers in its own dialect and every value is converted once, in
Runtime, rather than in each surface that shows one:
price 0.1 a float in TOKEN units -> base units, as a uint256 string
createdAt 1790138251000 milliseconds -> seconds
kid "27df70c5…" no 0x -> 0x-prefixed
tokenURI ipfs://Qm…/metadata.json -> the directory CID
media.uri ipfs://Qm… -> the content CID
contentType "video" a category, not a MIME -> carried as a category
Each item is then joined against this Home's own listings on the names the
chain uses -- channel and token -- because the channel an item lives on is
deliberately not published to a surface, so a surface could not make that join.
What comes back is the mint this Home holds for that asset, if any, and what
this person may do with it. An item with no mint here is one to buy; the access
state is per principal, so two people on one Home get their own answer about
the same asset.
A row missing the ledger, the token or the content id is dropped rather than
drawn as a card whose buttons cannot do what they say, and one the index has
marked unpublished is on nobody's shelf. None of it is authority: a price shown
here is the index's claim, and the terms of a sale are still read from chain
before anything is signed.
A PRICE ARRIVES IN TWO SCALES AND ONLY ONE OF THEM IS OBVIOUS
A listing's price is already in base units -- 200000 for 0.2 USDC -- while the
item's headline price is in token units -- 0.2. Scaling both multiplied every
listed item by a million and put $200000 on a twenty-cent card.
The conversion moves digits through text, never through a float: 0.1 at six
decimals is exactly 100000, and arriving there by multiplication is how a price
becomes 99999.99999999999. Exponent form is written out rather than evaluated,
because 0.000001 serialises as 1e-6. A token whose decimals this Home does not
know is refused rather than guessed -- a price of unknown scale is not a price
-- and so is more precision than the token has, because rounding a price is
changing it.
ROWS AND COMPLAINTS ARRIVE TOGETHER, AND THE ROWS ARE STILL ROWS
GraphQL reports a field it could not resolve for one row beside the rows it did
resolve. The live index does exactly this: sixty assets came back with eighteen
complaints about a non-nullable `image` that was null. Reading `errors` first
threw every good row away with the bad ones, so Explore was empty while the
index was answering perfectly well -- which cost an afternoon to find, because
an empty shelf and an unreachable market look the same.
Rows now win when there are rows, and the complaints are kept: shown to nobody,
because they are about the index's schema rather than about anything a person
did, and said in the log, bounded, because a shelf quietly missing rows is what
took the afternoon. An answer with no rows at all is still a refusal and still
carries its reason. This lives in the one place every surface reads a GraphQL
answer through, and a test pins it there, so a surface cannot get it wrong
again on its own.
WHAT A BUYER NEEDS IN ORDER TO OPEN WHAT THEY BOUGHT
The document the token URI names carried the rights policy identity and
nothing else. Someone who finds an asset in an index can buy it from what the
chain says -- and could then hold something they cannot open, because the three
facts needed to open it were reachable only from a link the creator hands out.
It now carries them: which envelope holds the key, that the key belongs to this
content, and what the content is. All three were already public -- the listing
package carries them and sits on IPFS, fetched by CID with no authentication --
so what changed is their reachability, not the judgement about key material.
None of them is any: each is an identity, naming the custody pool, epoch and
committee authorization. The shares stay with the custody nodes, and the chain
still decides whether this account may open it. Four fields became seven, and
the test that forbids material travelling here grew `key` to its list rather
than losing a name from it.
THE SHELF ITSELF
An item this Home holds answers to its mint; one known only to the index has no
mint here at all and is named by its asset instead. Keying both on the mint
alone gave every catalogue item the same empty key, so one item's title and
cover appeared on all of them.
The line under a title must not repeat it. For an item this Home holds it names
the file, which says WHICH copy this is -- two mints of one title are one title
and two files. An item from the index has no file name, so that line would echo
the title and say nothing; it says how many are left and who is selling instead.
Asking again is now a control rather than a reason to reopen the app, and it
says what it found: a market that cannot be read must not look like an empty
one. The same distinction is drawn on the shelf itself, as a note above the
items rather than an error in place of them, because the items are real and
what is missing is beside them.
While the market is on its way the shelf keeps its shape with the design's own
skeletons, and a cover holds a shimmer until it lands -- the fetch goes to this
Home's node and then, for anything it does not already hold, out to the
network. A cover that never arrives settles to the placeholder rather than
shimmering for ever.
A person searches for what the card shows them, so a price is matched in the
form they read as well as in full, through the same formatter the card uses.
Pressing the tab you are already on takes you back to the whole of it, which is
how anyone would expect to leave a "See all". And Explore asks for its items
when Media is opened rather than only when a tab is switched -- it is where a
person lands, so a load that needed a switch never happened. Asking is what
raises an approval, so a surface nobody opens still asks for nothing.
One CSS rule said `margin-top: 24` with no unit, which is not a length, so the
spacing it describes had never once been applied.
A HOME BUILT FOR ITERATING ON
`scripts/setup-dev-home.sh` installs the same Home with the dev profile. It is
a wrapper rather than a second installer -- what "install a Home" means stays
in one script, and a copy of nineteen hundred lines would drift from it within
a week -- and the profile is now a parameter of the installer, including the
`debug/` directory cargo actually writes it to.
It passes the VS Code launch's own debug-info overrides, because those change
the fingerprint and without them cargo would keep a third copy of the gateway
rather than reuse the one the editor already built. The free-space gate is
lowered rather than removed: this build reuses those artifacts and needs a
fraction of sixteen GiB, and a gate that stops an iteration loop over headroom
it never uses is a gate that gets bypassed instead of heeded -- while running
out of disk mid-install still leaves a half-written Home. It says on the way in
that it is slow where it hurts: custody and the crypto paths are the longest
work this Home does, and unoptimised they are painful rather than merely
slower.
Verification:
cargo test --workspace (just test-elastos) 0 failed
cargo test -p elastos-server runtime_custody 120 passed
cargo test -p elastos-server market_directory 17 passed
chain-provider cargo test 103 passed
creator page node --test 71 passed
marketplace listing node --test 21 passed
marketplace behavior smoke, layout smoke pass
cargo clippy --workspace --all-targets -- -D warnings clean
cargo fmt --all -- --check clean
home entropy, public-copy entropy, browser entropy,
vendor-ui, diff --check pass
Driven on an installed Home as far as a settled mint: the asset reached chain
with a token id and an operative, and the listing step after it was what
failed -- which is what the finality change above addresses. That change has
not itself been watched complete on an installed Home, and is not claimed
here. Nor has a free mint been tried.
The market shelf rests on its own tests and the product smoke, and on one live
observation: the rows-and-complaints answer above is what the configured index
actually sends, and the conversion test's row is one of its answers unedited.
Buying another person's item has not been driven end to end, so what a buyer
can do with the three identities now published is proven in the source and not
yet on an installed Home.
Removed the unused `ethereum_signed_message_hash` import from the custody provider tests. Updated the run creation logic in the model provider tests to utilize a more robust request handling mechanism, ensuring timely responses and improved error handling during stalled operations.
This was referenced Oct 2, 2026
irzhywau
added this pull request to stack #207
October 2, 2026 10:34
Contributor
Author
|
The local run on this branch alone was stopped to save disk space, so GitHub CI is the check for this branch on its own. The full stack (top of #206) passes every check locally. |
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
A mint started from the Creator now completes, and an owned item opens reliably.
Custody
--role <storage|custody>, so key-share custody no longer makes a node a ciphertext replica.custody,chainandipfsonly. Model-content's Carrier object import proves its own local pin instead of calling an external placement service, which used to stall mints at "Publish to storage".Minting
Opening
approvedstate no longer fails the open.Wallet and market
Commits
custody nodes, external wallets, and a mint that completesa shelf that loads, a viewer that waits, and replicas that settleone button rebuilds a copy you own, and a purchase stops hiding after a reloada mint the creator decides, an open that completes, and a market anyone can seefix(tests): remove unused import and enhance run creation logicStack
Third of four, based on #204:
Supersedes the second half of #62.
Verification
The whole stack (top of PR 4) passes locally:
cargo fmt --checkand workspace clippy with-D warningsjust test-elastosandjust test-capsules(21 capsules)source-gatescript and the capsule browser testsjust product-ui-sourceThe same checks are running on this branch alone; I'll post the result as a comment. GitHub CI is the arbiter.
Not yet re-proven on an installed Home after the rebase onto
develop;state.mdrecords what is and is not proven.