DEV Community

Aaradhy Chinche
Aaradhy Chinche

Posted on

Migrating Hyperledger Fabric Chaincode to Fabric-X: A Working Proof-of-Concept

A working PoC, what changes in the programming model, and what I learned getting public review from an FSC core maintainer along the way.


If you maintain a Hyperledger Fabric chaincode in production today, there's a question quietly hovering over your roadmap: what happens to my application on Fabric-X?

Fabric-X — Hyperledger's next-generation re-architecture — keeps the governance model, the X.509 PKI, and the execute-order-validate transaction lifecycle. But it removes three things you've probably built habits around: the monolithic peer, the SmartBFT/Raft orderer, and chaincode itself. Business logic that used to live inside a Go chaincode binary now lives inside views running on Fabric-Smart-Client (FSC) nodes.

So: what does your chaincode look like in this new world?

I built a working proof-of-concept that answers that question for the canonical asset-transfer-basic Go chaincode — the one every Fabric tutorial starts with. All eight chaincode methods are ported to FSC views. The example boots a real Fabric-X network via FSC's NWO test harness. Along the way I designed receiver-acceptance into asset transfers, surfaced the Query Service's range-query gap honestly, and produced a tutorial-style README for chaincode developers who have never touched FSC.

The numbers, for the curious: 5 FSC nodes, 3 organisations, 1 namespace, 11 views, 18 files, 1,929 lines. Compiles cleanly. ginkgo --dry-run passes.

This post is the story of how I got there, the design decisions that mattered, and what I learned from doing all of it in public.


Why this PoC exists

I'd been deep-reading the Hyperledger and LFDT codebases preparing for the 2026 LFDT mentorship cycle. Walking through hyperledger-labs/fabric-smart-client, I noticed integration/fabricx/ already had simple, iou, multiendorsement, and deployment examples. There was a clear template. What was missing was the migration tutorial — the example that takes a chaincode every Fabric developer recognises and walks them through the rewrite.

The mentorship issue — LFDT mentorship #59: Exploring Chaincode Support for Hyperledger Fabric-X — was asking for exactly that. Better, an earlier applicant (@aaradhychinche-alt) had already started a stub PoC and gotten public review from one of the mentors: Marcus Brandenburger (IBM Research Zurich, Fabric-Smart-Client core maintainer).

Marcus's feedback on that earlier attempt is short and surgical. It shaped most of the design choices below:

"I really like the idea of having a 'tutorial style' example here that illustrates new users how to implement an existing 'chaincode' to a FSC-based view application."

"While I believe the initial version can be very simple — I think we should include a real FSC + FabricX network in the initial version … it's just more fun :)"

"Let's start with defining a topology. Who are the FSC nodes/apps? You need at least one FSC node to execute some business logic and being responsible to endorse the transactions… We can be creative here."

"You should move forward with this and start porting the Asset Transfer logic into views; setup fsc nodes; think about how to spinnup a fabric-x network (for example, you could use our integration test suite (nwo))."

That feedback is gold for an applicant. It told me three things specifically:

  1. The PoC needs a real Fabric-X network, not a single-process simulation.
  2. The PoC needs a deliberate topology, not implicit roles.
  3. The right harness is NWO, FSC's integration framework.

I built the PoC against those three constraints.


The topology — and why these five nodes

The earlier PoC simulated everything in a single Go process with an in-memory map. Mine doesn't. Five FSC nodes, three Fabric organisations, one Fabric-X namespace approved by Org1 under unanimity:

node org role description
issuer Org1 initiator InitLedger, CreateAsset
endorser Org1 approver validates every state-changing tx — the chaincode replacement
auditor Org1 observer responder on every state-changing tx
alice Org2 owner initiates Update / Delete / Transfer; receives transfers
bob Org3 owner same shape as alice

Why these five and not, say, two?

Three reasons.

1. The endorser is the chaincode replacement. In classical Fabric, the chaincode binary on the endorsing peer is what said "yes, this CreateAsset is valid; here is my signature." In FSC-on-Fabric-X that role becomes a responder view on a dedicated FSC node. By giving it its own node, and only its own node, the scv2.WithApproverRole() option, the parallel is one-to-one and obvious to a reader.

2. Splitting issuer from owners is a security upgrade. Classical chaincode lets anyone with rights to invoke the chaincode write any Owner string on an asset. In my topology, only the issuer can create assets and only the asset's current owner can update or transfer it. The auditor witnesses every state change. None of those guarantees were available in the chaincode model without writing them into the chaincode body — and even then, they relied on the endorsing peer running the binary you expected.

3. The auditor pattern is interesting in its own right. Auditors on classical Fabric usually retrofitted themselves by emitting chaincode events and scraping them off-chain. In FSC the auditor is just an FSC node registered as a responder on every state-changing initiator view. Every transfer, every create, every delete pulls the auditor into the endorsement collection automatically. That's a topology-level concern, not application code.

           Client tests / CLI
                  │
   ┌──────────────┼──────────────┐
   ▼              ▼              ▼
issuer          alice           bob
   │              │              │
   └──────┬───────┴──────┬───────┘  ← CollectEndorsements
          │              │
          ▼              ▼
      endorser        auditor       (responders)
          │
          ▼
   Fabric-X Arma orderer  →  Sidecar → Coordinator → Validator-Committer
          │                                  │
          └──────────────────────────────────┴──→ Query Service (reads)
Enter fullscreen mode Exit fullscreen mode

The eight chaincode methods, mapped

The original chaincode is 194 lines and exposes eight methods. Each one becomes one or more FSC views.

Chaincode method FSC view(s) Notes
InitLedger InitLedgerView One transaction, six AddOutput calls
CreateAsset CreateAssetView No inputs, one output; existence check at endorser
ReadAsset ReadAssetView Query-only, no transaction
UpdateAsset UpdateAssetView AddInputByLinearID + AddOutput
DeleteAsset DeleteAssetView Input with no matching output → state deletion
AssetExists AssetExistsView Query-only, returns bool
TransferAsset TransferAssetView + TransferAssetReceiverView Receiver-acceptance pattern
GetAllAssets GetAllAssetsView Range queries unsupported on Fabric-X — explicit-ID-list

Two of those rows deserve their own section.


The architectural highlight: receiver acceptance on transfer

The chaincode TransferAsset mutates the asset's Owner field and PutStates. The new owner has no say in whether they receive the asset:

// classical chaincode
func (s *SmartContract) TransferAsset(ctx, id, newOwner string) (string, error) {
    asset, _ := s.ReadAsset(ctx, id)
    oldOwner := asset.Owner
    asset.Owner = newOwner   // <-- new owner did not consent to this
    json, _ := json.Marshal(asset)
    return oldOwner, ctx.GetStub().PutState(id, json)
}
Enter fullscreen mode Exit fullscreen mode

That works because the chaincode is the only thing signing the transaction body. Anyone who can produce a valid endorsement can transfer assets to anyone — there is no per-key endorser identity at chaincode level.

The FSC version makes the receiver a participant. Here is the heart of TransferAssetView:

// Collect endorsements: receiver first, so the receiver's refusal
// short-circuits before we bother the endorser; then the endorser
// (chaincode logic); then the auditor.
_, err = viewCtx.RunView(state.NewCollectEndorsementsView(
    tx,
    t.NewOwner,    // ← receiver-acceptance: new owner must sign
    t.Endorser,
    t.Auditor,
))
Enter fullscreen mode Exit fullscreen mode

The new owner's FSC node runs TransferAssetReceiverView (registered as a responder in the topology). It inspects the proposed transaction and signs only if it accepts:

func (r *TransferAssetReceiverView) Call(viewCtx view.Context) (interface{}, error) {
    tx, err := state.ReceiveTransaction(viewCtx)
    // Real production code: hook business rules here — credit checks,
    // AML, anti-self-dealing, etc.
    out := &states.Asset{}
    tx.GetOutputAt(0, out)
    assert.NotEmpty(out.Owner, "Receiver: refusing transfer with empty Owner")
    return viewCtx.RunView(state.NewEndorseView(tx))
}
Enter fullscreen mode Exit fullscreen mode

This is a strict tightening of the chaincode security model. A buggy or malicious initiator can no longer force an asset onto an unwilling new owner. Receiver acceptance is part of the protocol. The chaincode could not express this; FSC views give it to us almost for free.

The migration is, in places, an opportunity to do better than the original.


The architectural sharp edge: GetStateByRange is gone

Classical chaincode walks every key in its namespace with ctx.GetStub().GetStateByRange("", ""). The Fabric-X Query Service does not support range queries. This is not a temporary limitation — it is documented in the FSC platform code itself:

// platform/fabricx/core/vault/vault.go
// GetStateRange returns an error as range queries are not
// supported by the QueryService.
func (qe *queryExecutor) GetStateRange(...) (..., error) {
    return nil, errors.New("GetStateRange not supported by VaultX QueryService")
}
Enter fullscreen mode Exit fullscreen mode

That's the migration's most important sharp edge. A tutorial that doesn't surface it is doing the reader a disservice. Three workarounds, in increasing order of complexity:

1. Explicit-ID-list (what my PoC does)

The caller passes the IDs to resolve. qs.GetStates(...) does a single-round-trip multi-key fetch. Suits registry-style apps where the caller already tracks issued IDs. Zero on-chain bookkeeping.

2. Index-key pattern

A dedicated key holds the JSON-encoded list of live asset IDs. Every Create / Delete also updates the index. The endorser must check the index update is internally consistent. Cost: one extra output per state-changing transaction.

3. Off-chain index

An FSC node subscribes to namespace commit events via finality listeners and maintains a local view. Best for analytics-style queries; weakest consistency on cold start.

My PoC implements pattern 1, names the trade-off explicitly in the view's godoc, and documents all three in the README. Honesty is part of tutorial quality.


The endorser, where the conceptual centre lives

This is the file I'm proudest of.

In classical Fabric, every chaincode function had its own existence check, its own ownership check, its own no-op-transfer guard. Those checks ran on the endorsing peer. The chaincode binary was the only thing that could produce a valid endorsement, so the checks were trusted.

In my FSC version, the same checks live in a single EndorserView on the endorser FSC node, dispatched on the transaction's command name:

switch cmd := tx.Commands().At(0); cmd.Name {
case "init":      // 0 inputs, 6 outputs, ascending IDs, none pre-existing
case "create":    // 0 inputs, 1 output, output's ID does not exist
case "update":    // 1 input,  1 output, IDs match
case "delete":    // 1 input,  0 outputs
case "transfer":  // 1 input,  1 output, only Owner changes
}
Enter fullscreen mode Exit fullscreen mode

The mapping from chaincode method to command is one-to-one. Adding a new chaincode method means adding one new case. The validation logic stays in one place, gets one place to evolve, and is testable in isolation.

A subtle bonus: the endorser additionally requires that on transfer, Color, Size, and AppraisedValue do not change. The chaincode TransferAsset only updated Owner, but nothing prevented a buggy client from also mutating Size. FSC + namespace endorsement lets us tighten that.

Migration as upgrade, again.


The Asset struct: byte-equivalent on purpose

One small but useful design decision — the Asset struct preserves the chaincode's exact JSON shape:

type Asset struct {
    AppraisedValue int    `json:"AppraisedValue"`
    Color          string `json:"Color"`
    ID             string `json:"ID"`
    Owner          string `json:"Owner"`
    Size           int    `json:"Size"`
}
Enter fullscreen mode Exit fullscreen mode

Same field names, same field tags, same field order. This means:

  • An off-chain indexer that watched the chaincode-era state can read post-migration state without re-tooling.
  • An app that hashes on-chain payloads gets the same hash before and after.
  • Tests that assert on payload bytes don't need to be rewritten.

The only addition is a GetLinearID() method on the struct (FSC's state package needs it to derive the world-state key from an object). It returns a.ID. Migration friction: zero.

The tutorial's first pleasant surprise: the on-chain payload doesn't have to change. The migration is about who runs what code, not what data lives on the ledger.


What it actually feels like to run

End-to-end, the test reads top-to-bottom as a tutorial. Each By(...) block names a chaincode method and the equivalent FSC view:

By("InitLedger — chaincode wrote 6 assets via PutState; FSC writes 6 outputs in one tx")
initLedger(s.II)

By("ReadAsset — chaincode used GetState; FSC uses Query Service")
a1 := readAsset(s.II, IssuerNode, "asset1")

By("CreateAsset — happy path: brand-new ID")
createAsset(s.II, IssuerNode, &states.Asset{...})

By("CreateAsset — negative path: duplicate ID is rejected by the endorser")
createAssetExpectFail(s.II, IssuerNode, &states.Asset{...})

By("TransferAsset — chaincode mutated Owner unilaterally; FSC requires receiver acceptance")
old := transferAsset(s.II, AliceNode, "asset7", "bob", BobNode)
Expect(old).To(Equal("alice"))
Enter fullscreen mode Exit fullscreen mode

A reviewer reading this test learns the migration story by reading down the page. Negative paths sit next to happy paths so a future contributor sees what each view rejects, not just what it accepts. Tutorial-as-test is one of the patterns I'll keep using.


What I learned (technical)

A non-exhaustive list of things I now actually understand that I only sort-of understood three weeks ago:

  1. Why Fabric-X dropped chaincode. It wasn't a fashion choice. The chaincode-as-a-process model bottlenecks at endorsement; serialised transaction simulation is exactly the wrong shape if you're trying to push 200k+ TPS. Replacing the binary boundary with a P2P negotiation between FSC nodes lets the orderer batch and shard far more aggressively.

  2. What "view" actually means in FSC. A view is a Go object that implements Call(ctx) (interface{}, error). That's it. The framework gives you helpers for the common shapes — state.NewTransaction, state.NewCollectEndorsementsView, state.NewOrderingAndFinalityWithTimeoutView, queryservice.GetQueryService, finality listeners. The view's job is to compose those helpers into a sensible per-method workflow.

  3. Initiator vs responder is a topology decision, not a code decision. The same view code can be played as an initiator on one node and a responder on another. The topology decides who runs what.

  4. The Fabric-X namespace is the new endorsement-policy boundary. What used to be endorsement_policy in chaincode lifecycle is now AddNamespaceWithUnanimity(...) plus the RegisterResponder topology. Same idea, different surface.

  5. Range queries are not coming back in their classical form. The Query Service is fundamentally a batched key-fetch. If your application depended on rich CouchDB queries, you have real architectural work ahead.

  6. NWO is unreasonably good. It's basically a one-line "start me a real Fabric-X network with these orgs and these FSC nodes" harness. Five of the trickiest pages of Fabric setup get reduced to a topology declaration. I want this on every distributed-systems project I work on.


What I learned (the human side)

Three things stand out.

1. Mentor feedback is a gift you can read like an instruction manual

Marcus Brandenburger's review on the earlier applicant's PoC was two paragraphs. Each sentence in those paragraphs maps to a concrete code-level decision in mine.

  • "Tutorial style" → 407-line README with a side-by-side migration mapping table.
  • "Real FSC + FabricX network" → NWO-driven chaincode_to_fsc_test.go.
  • "Define a topology" → an explicit five-node, three-org topology.go with a Mermaid diagram.
  • "Use NWO" → the test boots Arma + the Fabric-X-Committer microservices for real.

When a maintainer takes the time to write public feedback, the right response is to internalise it line by line and answer it with code.

2. Reading other people's working code is the fastest way to learn a framework

I didn't invent the structure of any of the views in this PoC. The shape of CreateAssetView came from integration/fabricx/simple/views/create.go. The receiver-acceptance pattern came from integration/fabric/iou/views/lender.go. The endorser dispatch came from integration/fabric/iou/views/approver.go.

Mimicry-then-deviation is more efficient than first-principles design when you're new to a framework — and it produces code maintainers can read at a glance.

3. Tutorial writing is its own engineering discipline

Writing the README took almost as long as writing the views. Every diagram had to be diffable Mermaid. Every code snippet had to render cleanly in GitHub Flavored Markdown. Every section had to answer "what does the reader want next?" rather than "what do I want to say next?"

The audience profile — a Hyperledger Fabric chaincode developer with three years of experience who has never used FSC — sits at my elbow on every paragraph I write. Without that profile the writing drifts into self-indulgence; with it the writing stays useful.


What working in public taught me

Everything I've done so far has been on the record — issue threads, repo branches, README PRs. That's how the LFDT community works, and it changes the shape of the work in three useful ways.

1. You write for the next person, not just the mentor

The mentor reads this once. The next applicant, the next maintainer, the next adopter will read it many times. So the README is structured for them. The negative-path tests are commented for them. The EndorserView godoc names the chaincode method each case corresponds to so that someone porting from the chaincode side sees the bridge instantly.

2. You build on top of other people's work, on the record

The earlier applicant's PoC is acknowledged in my proposal. Marcus's review is quoted verbatim. The NWO patterns are cited to the file that taught me each one. Open-source is an iterated game; you don't get to pretend you started from scratch.

3. Speed of iteration is set by the mentor's bandwidth, not yours

I learned to package my asks as small, decision-shaped questions: "Q1 — On the docs IA: do you want goal-oriented nav, or is the legacy split preserved?" rather than open-ended "thoughts?".

Decision-shaped questions get answered in minutes; open-ended ones can hang for weeks. That's a discipline I'll carry into every collaboration with a busy maintainer.


What I'd do next

The PoC as it stands answers the migration question for asset-transfer-basic. Two natural follow-ons:

  1. Port asset-transfer-private-data. Fabric-X has no private data collections; the choice between "separate namespace per privacy boundary" and "hash-on-ledger / body-via-P2P" is the most architecturally interesting question that asset-transfer-basic doesn't surface. The mentorship's full plan includes this as the second demo.

  2. Build a reusable index-key range-scan helper. If every team migrating off GetStateByRange has to invent the same index-key pattern, that's exactly the right shape for a small reusable view in the FSC platform repo.

A bonus I'd love to do: a meetup talk and a recorded walkthrough. If you'd like to host that, get in touch.


Acknowledgments

  • Marcus Brandenburger (IBM Research Zurich) for the kind of public review that teaches the next applicant exactly what good looks like.
  • Maria Munaro and Samuel Venzi (GoLedger) for serving as mentors on this issue.
  • The FSC and Fabric-X maintainers at IBM Research and across the LFDT community, whose integration/fabricx/{simple,iou,multiendorsement,deployment} examples were my primary teachers.
  • The earlier applicant (@aaradhychinche-alt) whose initial PoC opened the issue and gave the community something concrete to react to.

Links


If you're a chaincode developer who has been quietly wondering whether your applications have a future on Fabric-X, the answer is yes — and the migration is, in places, an opportunity to do better than the original. I'd love to hear what you'd port next.

Comments and corrections welcome on the GitHub issue or via the LFDT Discord.

Top comments (0)