Enter the component's serial number to see its registered owner, status, and available actions.
What the app does
The app models a bicycle component registry. A role-authorized registrar creates a registry record for a physical component serial number and mints a normal ERC-721 token to the owner. The manager contract stores the serial lookup, status, registrar, timestamps, token contract, and token id. The NFT contract remains a regular token contract, so wallets and indexers can treat component ownership normally.
“Verified” here identifies a registrar-created record; the contract does not inspect the physical component or prove its authenticity.
The visible app is defined by CAM resources. A host contract publishes the manifest URI/hash and contract address bindings; the generic viewer loads those resources, calls the read routes, renders the declared UI nodes, and prepares wallet writes from the manifest routes.
User workflow
A registrar mints the component NFT and creates the verified serial record.
The owner or a scoped delegate updates metadata, opens a missing report, or resolves it.
Only the owner can take the component out of service or reactivate it. Missing reports remain attached. An ownership handover transfers the NFT directly and permanently invalidates previous workshop grants.
Try locally with Docker
From the repository's main branch, run the commands below. The GUI target starts a disposable Anvil test chain, deploys the contracts and demo records, and prints the browser URL. This page documents the workflow; it does not host a registry app.
git clone https://github.com/numpde/dapp32.git
cd dapp32
make dapps/deps/install
make js/deps/install
make cam/conformance/check
make bike-nft/local/demo/gui/up
You can browse the seeded DEMO-FRAME-001 record without a wallet. The address in the printed URL selects whose actions you see; it cannot sign transactions. Preparing a write does not change the registry.
To send a transaction, use a separate browser wallet profile. Add chain ID 31337 with RPC URL http://127.0.0.1:5173/rpc, or the printed GUI origin followed by /rpc if you changed the port. Import the public local-only PRIVATE_KEY from compose/bike-nft/local/deploy.yml and connect it to the viewer. That funded fixture account is both the registrar and the owner of the demo components. Connecting a wallet selects its address for the view and clears prepared calls. If you switch accounts in the wallet, wait for the refreshed view, check the account and chain, then prepare again. This fixture key is unsafe on any other chain; keep real funds and valuable keys out of this disposable chain.
After connection, wallets that report account and network changes update the viewer automatically and clear unsent prepared calls. A different wallet network leaves the viewer's configured chain and RPC unchanged and disables sending until the wallet returns to that chain. Disconnecting keeps the last successful read-only view. Submitted transaction hashes and receipt tracking survive wallet changes. If your provider cannot report changes, use Switch wallet to reconnect after changing accounts or networks.
To register, enter an unused serial number, the intended owner's address, and a token metadata URI. Prepare the call, review it, and send it with the registrar wallet. Wait for successful confirmation before treating the record as created; the viewer then reads the component view. The GUI target attempts teardown when its foreground command exits. Successful teardown removes the local fixture state. After an interrupted run, or if the fixture remains running, use make bike-nft/local/demo/gui/down and check that it has stopped.
Token metadata describes one component, including its name and image reference. For your own release, the empty base URI default lets the viewer store a complete HTTP(S) metadata URL or embedded JSON data URI for each token. A shared nonempty prefix requires an advanced client that supplies relative suffixes; the viewer's complete URIs must not be combined with that prefix. Collection metadata describes the collection as a whole and is separate from token metadata and the CAM manifest. See the release setup's metadata choices, including inline collection JSON.
This fixture allows a zero manifest hash for local development. It still checks the manifest's ABI and UI resource hashes; it does not demonstrate a hash-pinned root publication.
Onchain pieces
CamRoot
Publishes the CAM URI/hash and resolves manifest contract names to deployed addresses for this chain.
BicycleComponents
A non-upgradeable ERC-721 collection. It owns token behavior, transferability, token URI storage, and collection metadata.
BicycleComponentManager
The registry and policy contract. It owns serial lookup, status, component delegations, account info, missing report state, lifecycle events, and write permissions.
BicycleComponentManagerUI
A read-only route projection contract. It never writes manager state; it returns semantic view data and action names for CAM.
CAM manifest
Declares contract namespaces, read routes, write routes, ABI resources, and the UI resource consumed by generic viewers.
Generic viewer
Renders the workflow from manifest data and prepares contract calls; business authority remains in the manager and token contracts.
CAM routes
The manifest defines named contract calls, called routes. Read routes return values for the app's screens. Write routes prepare direct wallet calls to the contract responsible for the action. In the browser viewer, the follow-up read runs after the wallet sends the transaction and the viewer receives a successful receipt.
| Route | Kind | Contract function | Continuation | User-facing purpose |
|---|---|---|---|---|
deploymentHandovers | read | viewDeploymentHandovers(camHost, account) | ui.app(view={'screen': 'deploymentHandovers', 'task': {'paused': '$outputs.0.paused', 'componentTokenPaused': '$outputs.0.componentTokenPaused', 'cam': '$outputs.0.cam', 'components': '$outputs.0.components', 'manager': '$outputs.0.manager', 'actions': '$outputs.0.actions', 'navigationSerial': ''}, 'navigationSerial': '', 'navigation': 'navigation'}) | Inspect independent CAM ownership and Components/Manager administrator handovers. |
acceptCamOwnership | write | acceptOwnership() | routes.deploymentHandoversWithContext(serialNumber=$inputs.serialNumber) | The nominated owner accepts ownership directly on the selected CAM host. |
acceptComponentsAdmin | write | acceptDefaultAdminTransfer() | routes.deploymentHandoversWithContext(serialNumber=$inputs.serialNumber) | The nominated Components administrator accepts after its chain-time delay. |
acceptManagerAdmin | write | acceptDefaultAdminTransfer() | routes.deploymentHandoversWithContext(serialNumber=$inputs.serialNumber) | The nominated Manager administrator accepts after its chain-time delay. |
findComponentForm | read | viewEntry(account) | ui.app(view={'screen': 'entry', 'task': {'serialNumber': '$inputs.serialNumber', 'actions': '$outputs.0.actions', 'paused': '$outputs.0.paused', 'componentTokenPaused': '$outputs.0.componentTokenPaused', 'account': '$outputs.0.account', 'canRegister': '$outputs.0.canRegister', 'accountInfo': '$outputs.0.accountInfo'}, 'navigationSerial': '$inputs.serialNumber', 'navigation': 'navigation.find'}) | Return to component search with the selected serial. |
deploymentHandoversWithContext | read | viewDeploymentHandovers(camHost, account) | ui.app(view={'screen': 'deploymentHandovers', 'task': {'paused': '$outputs.0.paused', 'componentTokenPaused': '$outputs.0.componentTokenPaused', 'cam': '$outputs.0.cam', 'components': '$outputs.0.components', 'manager': '$outputs.0.manager', 'actions': '$outputs.0.actions', 'navigationSerial': '$inputs.serialNumber'}, 'navigationSerial': '$inputs.serialNumber', 'navigation': 'navigation'}) | Inspect deployment handovers while retaining the selected serial. |
entry | read | viewEntry(account) | ui.app(view={'screen': '$outputs.0.viewId', 'task': '$outputs.0', 'navigationSerial': '', 'navigation': 'navigation.find'}) | Look up a component or edit the viewed account’s profile. |
component | read | viewComponent(serialNumber, account) | ui.app(view={'screen': '$outputs.0.viewId', 'task': '$outputs.0', 'navigationSerial': '$inputs.serialNumber', 'navigation': 'navigation'}) | Read one component and its available actions. |
register | read | viewRegister(serialNumber, account) | ui.app(view={'screen': '$outputs.0.viewId', 'task': '$outputs.0', 'navigationSerial': '$inputs.serialNumber', 'navigation': 'navigation'}) | Check whether a serial can be registered. |
registerComponent | write | registerComponent(owner, serialNumber, tokenURI_) | routes.component(serialNumber=$inputs.serialNumber) | Mint the component NFT and register its serial. |
setAccountInfo | write | setAccountInfo(infoURI) | routes.findComponentForm(serialNumber=$inputs.serialNumber) | Set the sending account’s public information URI. |
updateComponentMetadata | write | setComponentMetadata(serialNumber, tokenURI_) | routes.component(serialNumber=$inputs.serialNumber) | Update the component NFT’s metadata URI. |
markComponentMissing | write | markComponentMissing(serialNumber, reportURI) | routes.component(serialNumber=$inputs.serialNumber) | Open a missing report with its published URI. |
clearComponentMissing | write | clearComponentMissing(serialNumber, resolutionURI) | routes.component(serialNumber=$inputs.serialNumber) | Resolve the missing report with a resolution URI. |
decommissionComponent | write | decommissionComponent(serialNumber) | routes.component(serialNumber=$inputs.serialNumber) | Owner takes the component out of service without erasing missing reports. |
reactivateComponent | write | reactivateComponent(serialNumber) | routes.component(serialNumber=$inputs.serialNumber) | Owner returns an out-of-service component to service. |
delegation | read | viewDelegation(serialNumber, delegate, account, duration) | ui.app(view={'screen': '$outputs.0.viewId', 'task': '$outputs.0', 'navigationSerial': '$inputs.serialNumber', 'navigation': 'navigation'}) | Inspect a workshop grant and preview its expiry. |
setComponentDelegate | write | setComponentDelegate(serialNumber, delegate, capabilities, validUntil) | routes.component(serialNumber=$inputs.serialNumber) | Owner grants expiring, scoped workshop permissions. |
revokeComponentDelegate | write | revokeComponentDelegate(serialNumber, delegate) | routes.component(serialNumber=$inputs.serialNumber) | Owner revokes workshop permissions. |
transferComponent | write | safeTransferFrom(address,address,uint256)(from, to, tokenId) | routes.component(serialNumber=$inputs.serialNumber) | Owner transfers the NFT directly to its recipient. |
Important boundaries
The UI contract is read-only
BicycleComponentManagerUI has no roles, no owner, and no
write functions. It projects registry and deployment-authority state.
Unavailable declared actions remain visible but disabled, including
paused registry writes and deployment acceptances that are not yet
available to the nominated account.
The token remains standard
Transfers and approvals are ordinary ERC-721 operations on
BicycleComponents. Registry status is stored by the manager; token transfers do not
update it in V1.
The manifest is not authority
CAM describes reads, UI nodes, and prepared writes. The manager and token contracts still enforce all business rules onchain.
Deploy your own instance
This is an example app guide. Use the app deployment guide for its contract settings, authority handoffs and verification, and the shared deployment guide for the release workflow. Deployment selects your chain and publication; this page supplies no running public instance.