Skip to content

fix(api): return [] not null for empty leaderboard and account points - #412

Closed
Ryanmello07 wants to merge 1 commit into
urnetwork:mainfrom
Ryanmello07:fix/nil-slice-json-null-upstream
Closed

fix(api): return [] not null for empty leaderboard and account points#412
Ryanmello07 wants to merge 1 commit into
urnetwork:mainfrom
Ryanmello07:fix/nil-slice-json-null-upstream

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

A nil slice marshals as JSON null. The gomobile sdk binds these fields as pointers, so null becomes a nil object that the Android client dereferences inside a JNI callback — the NPE cannot cross JNI and ART aborts the whole process:

JNI DETECTED ERROR IN APPLICATION: CallStaticVoidMethod called with pending exception
java.lang.NullPointerException: ...LeaderboardEarnersList.len() on a null object reference

Confirmed on device: opening the Leaderboard tab hard-crashes the app. This hits any deployment whose leaderboard query returns zero rows — a new or custom server, or one before its first payout cycle.

Reproduced live before the fix, and verified after:

before:  /stats/leaderboard -> {"earners":null}
         /account/points    -> {"account_points":null,"network_points":null}
after:   /stats/leaderboard -> {"earners":[]}
         /account/points    -> {"account_points":[],"network_points":[]}

Three instances

  1. model.GetLeaderboardearners is a named return that is only ever appended to, so zero rows leaves it nil.
  2. controller.GetLeaderboard error path — returns a result with only Error set, leaving Earners nil, so a transient query error crashes the app exactly as an empty leaderboard does.
  3. model.FetchAccountPoints — identical shape, feeding both network_points and its dual-emitted account_points alias, so /account/points returned two null fields.

Not affected

GetNetworkLeaderboardRanking returns a NetworkRanking value, not a pointer, so a network with no payouts already serialises as an object. A nil *NetworkRanking on the client is the binding reflecting the JSON and resolves with the above.

Audit of the whole class

Every function in model/ and controller/ with a named slice return that is only appended to: six matches, of which only the two above are API-facing. listClientReliabilityPartitions and MaintainClientReliabilityPartitions are internal maintenance, GetProxyIdsSince has no controller caller, and GetCircleUCUsers feeds internal processing.

Tests

DB-backed, calling the real functions against an empty database and asserting on the marshalled bytes rather than on non-nilness — [] versus null is the property that actually reaches the client. Teeth-checked: removing either initialisation fails them with "returned a nil slice; it marshals as null and crashes the client".

Note: TestAccountPoints in the controller package fails, but fails identically on the parent commit (same panic at account_point_controller_test.go:16) — pre-existing and unrelated to this change.

A nil slice marshals as JSON `null`. The gomobile sdk binds these fields
as pointers, so `null` becomes a nil object that the android client
dereferences inside a jni callback -- the NPE cannot cross jni and ART
aborts the whole process. Opening the Leaderboard tab hard-crashes the
app on any deployment whose leaderboard query returns zero rows: a new
or custom server, or one before its first payout cycle.

GetLeaderboard's `earners` was a named return that was only ever appended
to, so zero rows left it nil. Two further instances of the same class,
both reproduced live against beta:

  /stats/leaderboard -> {"earners":null}
  /account/points    -> {"account_points":null,"network_points":null}

FetchAccountPoints has the identical shape and feeds `network_points`
plus its `account_points` alias. And GetLeaderboard's controller left
Earners nil on the *error* path, so a transient query error crashed the
app just as an empty leaderboard did.

Audited every model and controller function with a named slice return: of
six with this shape, only these two are API-facing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
@Ryanmello07

Copy link
Copy Markdown
Contributor Author

Superseded by #425, which consolidates this stack into a single branch rebased onto current main. The work is unchanged and included there — this PR's commits are in #425's history (or, where the chain rewrote them, their final form is). Closing to keep review in one place.

@Ryanmello07 Ryanmello07 closed this Aug 4, 2026
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.

1 participant