Skip to content

Conversation

@gfanton
Copy link
Member

@gfanton gfanton commented Dec 14, 2025

STILL WIP - need testing

Following: #2949

This implementation is the most advanced JSON implementation I have made so far. It tries to follow @jaekwon's specs #2949 (comment) while taking some liberties with the structure format to make it more usable.

What Changed from #2949

The previous PR (#2949) was a simpler approach that:

  • Returned primitives as raw JSON values
  • Represented objects as strings: <obj:TYPE:OID>
  • Called Error()/String() methods inline

This new implementation takes a different approach:

  • Returns full typed values with {T: <type>, V: <value>} format
  • Uses Amino serialization for complex types (with @type tags)
  • Persisted objects return as RefValue with queryable ObjectID
  • Adds vm/qobject endpoint to fetch any object by its ID
  • Includes field names in struct serialization (JSONStructValue)

Key Features

  • Structured JSON for vm/qeval: Every result includes type information
  • New vm/qobject endpoint: Traverse the object graph by fetching objects via ObjectID
  • Error extraction: @error field when functions return errors (per spec)
  • Cycle handling: Ephemeral objects with cycles use incremental IDs (:1, :2)
  • Field names: Struct values include field names for readability

Usage

Query function results (vm/qeval)

# Simple string return
gnokey --json query vm/qeval -data "gno.land/r/demo/foo.Hello(\"world\")"
{"results":[{"T":"string","V":"Hello world"}]}
# Function returning error
gnokey --json query vm/qeval -data "gno.land/r/demo/foo.MightFail()"
{"results":[{"T":"string","V":""},{"T":"*RefType{errors.errorString}","V":{...}}],"@error":"something went wrong"}
# Returning a persisted object (pointer to realm state)
gnokey --json query vm/qeval -data "gno.land/r/demo/foo.GetTree()"
{"results":[{"T":"*RefType{avl.Tree}","V":{"@type":"/gno.PointerValue","Base":{"@type":"/gno.RefValue","ObjectID":"abc123:42"},"Index":"0"}}]}

Fetch object by ID (vm/qobject)

# Use ObjectID from qeval response to fetch full object with field names
gnokey query vm/qobject -data "abc123:42"
{
  "objectid": "abc123:42",
  "value": {
    "@type": "/gno.JSONStructValue",
    "ObjectInfo": {"ID": "...", "RefCount": "1"},
    "Fields": [{"N": "node", "T": {...}, "V": {...}}]
  }
}

Spec Implementation

Original Specification from @jaekwon
Let's do the following:

`{"@error":"<result of err.Error() string>"}`.

Amino already uses @type, might as well use @error too. But this won't be using amino to encode, it will be a custom function for encoding.

`{"@error":"xyz"}` means any object that implements Error() and returns "xyz".

This encoding is only for MsgCall results, it never goes into the gno vm store.

------

Implementation spec:

# Step 1: break unreal cycles

`refOrCopyValue()` assumes that *all* objects are real (persisted) because it's only used for persisting after a transaction ends, and return values of functions (such as errors, typically) are not necessarily part of the realm state. So we can't just use refOrCopyValue. We need to copy this into a new function.

In gnovm/values_export.go (new file) we implement the following:

func ExportValues(tvs []TypedValue) TypedValue {
   exportValues(tvs, new(map[Object]int))
   ...
}

func exportValues(tvs []TypedValue, m map[Object]int) TypedValue {
   ...
      m[tv.V] = len(m)
   ...
}

This function will scan over tv similarly to refOrCopyValue() but it will replace recursive references to Value with RefValue{ObjectID:{NewTime:n}} where n is m[tv.V] above. The ObjectID has PkgID of zero which never happens in gnovm storage so it indicates that it is unreal, so we can take the NewTime to refer to the export cycle reference number just like ProtectedString does. It must not modify any values but it needs to return all fresh copies of values as a defensive measure against accidental modifications by the caller of ExportValue().

Real persisted values should already be RefValue{} with non-zero ObjectID.PkgID, so nothing special to do there.

# Step 2: Compute error string.

 * If the **func** return type's **last** element exactly a named or unnamed **interface** type **which implements** go's `error`, or `Error() string` method,
 * And the result is not undefined,
 * Then, .Error() is called.

The .Error() is called in a new context (but with a cache-wrapped store since .Error() might make mutations, but we should allow mutations and then MUST discard changes. The gas allowance will be set to the remaining gas of the transaction ONLY. If gas runs out instead of returning the string we return the full export value (see below) as if it was any other object type. We assume no performance issue because the cost of exporting is assumed to be amortized into the cost of calling the function. (it isn't, but we can deal with that later).

We don't read results[-1].T to see if implements error, but we check the func type itself, because if the function called by MsgCall returns say interface{} rather than error we don't do the error-transform, even though results[-1].T would still implement error() if the returned value is an error (but it's also *not* an error because interface{} doesn't implement error).

This error string is remembered along with the de-cycled results from step 1.

# Step 3: custom TypedValue JSON exporter

Implement a special `JSONExportTypedValue(tv TypedValue) string` and `JSONExportTypedValues(tv []TypedValue, errstr string) string` functions. It assumes step 1 transform already happened. It always returns values of the form `"{T:<typestr>,V:<valstr>}"` according to the following rules:

 * <typestr> is RefType.TypeID if RefType.
 * <typestr> is just tv.T.String() otherwise. For primitive it is e.g. 'int'. For declared types it should be like 'RefType{"gno.land/r/myrealm/mypackage.Foo"}'.
 * <valstr> is 123, -123, true, false, for non-string PrimitiveTypes.
 * <valstr> is "abc" for string types.
 * <valstr> is amino.MustMarshalJSONAny() otherwise, like "{@type:"/gno.StructValue",...}"
 * except if the last <valstr> and errstr != "", @error:"<errstr>" is injected after @type above. If valstr was primitive bool/number/string, then {@error:"<errstr>",value:123}, otherwise will look like {@type:"/gno.StructValue",@error:"<errstr>",ObjectInfo:...,Fields:...}.

# Step 4: Optimize ObjectID

Change implementation of `func (oid ObjectID) MarshalAmino() (string, error) {` and UnmarshalAmino such that if objectid.PkgID is zero it can be omited: so like `{@type:"/gno.RefValue","ObjectID":":123"}` rather than `{"@type":"/gno.RefValue","ObjectID":"0000000000000000000000000000000000000000:123"}`

What Was Implemented

Spec Step Implementation
Step 1: Break unreal cycles ExportValues() in values_export.go - cycles become RefValue{ObjectID:":N"}
Step 2: Error extraction @error field extracted when function signature returns error
Step 3: JSON export {T: <type>, V: <value>} format, Amino for complex types
Step 4: Optimize ObjectID Zero PkgID renders as :N instead of 000...000:N

Additions Beyond Spec

  1. vm/qobject endpoint: Fetch any persisted object by ObjectID - enables traversing the full object graph

  2. JSONStructValue with field names: Struct fields include their names for better readability:

    {"@type":"/gno.JSONStructValue","Fields":[{"N":"Data","T":{...},"V":{...}}]}
  3. JSONExporterOptions: Configurable export behavior:

    • ExportUnexported: Include unexported (lowercase) fields (used by qobject)
    • MaxDepth: Limit nested object expansion
  4. Signature-based error detection: Per spec, @error is only extracted when the function signature declares an error return type

Differences from Spec

  1. @error as top-level field: Instead of injecting into the value object, @error is at the response root:

    {"results":[...],"@error":"error message"}

    This avoids modifying value serialization and is cleaner to parse.

  2. Persisted objects as RefValue: Real objects return RefValue with ObjectID rather than being expanded inline. Use vm/qobject to fetch the full object. This prevents unbounded response sizes.

  3. .Error() call without cache-wrapped store: The spec wanted .Error() called with a cache-wrapped store to discard any mutations, plus graceful fallback if gas runs out (return raw value instead of error string). Current implementation uses the machine directly, which naturally respects gas limits but does not discard mutations or handle gas exhaustion gracefully.

Files Changed

File Change
gnovm/pkg/gnolang/values_export.go New: JSON export logic, cycle handling, JSONExporterOptions
gnovm/pkg/gnolang/package.go Register JSONField, JSONObjectInfo, JSONStructValue
gno.land/pkg/sdk/vm/convert.go stringifyJSONResults() for qeval JSON format
gno.land/pkg/sdk/vm/keeper.go QueryObject() for vm/qobject endpoint
gno.land/pkg/sdk/vm/handler.go Route vm/qobject queries

Example Outputs

String result
{"results":[{"T":"string","V":"Hello juliette"},{"T":null,"V":null}]}
Error result
{
  "results": [
    {"T": "string", "V": ""},
    {
      "T": "*RefType{errors.errorString}",
      "V": {
        "@type": "/gno.PointerValue",
        "Base": {
          "@type": "/gno.StructValue",
          "ObjectInfo": {"ID": ":0", "ModTime": "0", "RefCount": "0"},
          "Fields": [{"T": {"@type": "/gno.PrimitiveType", "value": "16"}, "V": {"@type": "/gno.StringValue", "value": "not for you bernard!"}}]
        },
        "Index": "0"
      }
    }
  ],
  "@error": "not for you bernard!"
}
Persisted object (RefValue with queryable ObjectID)
{
  "results": [
    {
      "T": "*RefType{gno.land/r/dev/silo.Silo}",
      "V": {
        "@type": "/gno.PointerValue",
        "Base": {
          "@type": "/gno.RefValue",
          "ObjectID": "6aed2eda72c79e411eec1ea7f661c5e30383fb59:3",
          "Hash": "efbf31b561d0858742bb7c5004509cbade105909"
        },
        "Index": "0"
      }
    }
  ]
}
qobject response (with field names)
{
  "objectid": "6aed2eda72c79e411eec1ea7f661c5e30383fb59:3",
  "value": {
    "@type": "/gno.JSONStructValue",
    "ObjectInfo": {
      "ID": "6aed2eda72c79e411eec1ea7f661c5e30383fb59:4",
      "Hash": "877a0487f47efa309c26e43d8781773d9d27145f",
      "OwnerID": "6aed2eda72c79e411eec1ea7f661c5e30383fb59:3",
      "RefCount": "1"
    },
    "Fields": [
      {
        "N": "Data",
        "T": {"@type": "/gno.PrimitiveType", "value": "16"},
        "V": {"@type": "/gno.StringValue", "value": "secret message"}
      }
    ]
  }
}

@gfanton gfanton self-assigned this Dec 14, 2025
@github-actions github-actions bot added 📦 🤖 gnovm Issues or PRs gnovm related 📦 🌐 tendermint v2 Issues or PRs tm2 related 📦 ⛰️ gno.land Issues or PRs gno.land package related 🌍 gnoweb Issues & PRs related to gnoweb and render labels Dec 14, 2025
@Gno2D2
Copy link
Collaborator

Gno2D2 commented Dec 14, 2025

🛠 PR Checks Summary

🔴 Changes related to gnoweb must be reviewed by its codeowners

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

🟢 Maintainers must be able to edit this pull request (more info)
🔴 Changes related to gnoweb must be reviewed by its codeowners

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Automated Checks
Maintainers must be able to edit this pull request (more info)

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 The pull request was created from a fork (head branch repo: gfanton/gno)

Then

🟢 Requirement satisfied
└── 🟢 Maintainer can modify this pull request

Changes related to gnoweb must be reviewed by its codeowners

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 A changed file matches this pattern: ^gno.land/pkg/gnoweb/ (filename: gno.land/pkg/gnoweb/app.go)

Then

🔴 Requirement not satisfied
└── 🔴 Or
    ├── 🔴 Or
    │   ├── 🔴 And
    │   │   ├── 🔴 Pull request author is user: alexiscolin
    │   │   └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")
    │   └── 🔴 And
    │       ├── 🟢 Pull request author is user: gfanton
    │       └── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
    └── 🔴 And
        ├── 🟢 Not (🔴 Pull request author is user: alexiscolin)
        ├── 🔴 Not (🟢 Pull request author is user: gfanton)
        └── 🔴 Or
            ├── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
            └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")

Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🛠️ gnodev 🌍 gnoweb Issues & PRs related to gnoweb and render 📦 🌐 tendermint v2 Issues or PRs tm2 related 📦 ⛰️ gno.land Issues or PRs gno.land package related 📦 🤖 gnovm Issues or PRs gnovm related

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants