Skip to content

Commit 9708ac6

Browse files
foxcoolclaude
andcommitted
fix(subscan): a lock nobody could read is not a lock of zero
The token parser landed an hour ago reading a lock with the helper that returns zero on failure, and asking whether the lock was zero to decide the position was liquid. So an unreadable lock arrived as "nothing frozen" — a claim about spendable money made out of a string that was never parsed, in the one direction the function's own comment forbids. Absent and unreadable are now separate, because absent is genuinely zero and that is measured, not assumed: in one live Polkadot Asset Hub response DED carries "lock": "0" while MYTH carries no lock field at all, which is Subscan omitting a component the account does not use. A present lock that will not parse leaves the liquidity unstated and is named in the sync errors, so the withholding leaves a trace — the half of personal-feb.13 that reaches this path. Refs: personal-feb.10, personal-feb.13 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018E1gTMsMxVGcjkvFp4wwgr
1 parent 1ca2558 commit 9708ac6

3 files changed

Lines changed: 89 additions & 15 deletions

File tree

internal/adapter/subscan/client.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ type Token struct {
108108
// Lock is the frozen part of Balance where the entry reports one. It is a
109109
// subset of Balance, never an addition — same model as the native coin.
110110
Lock decimal.Decimal
111+
// LockKnown is false when the entry carried a lock this parser could not
112+
// read. Absent is not unknown: Subscan omits a component an account does
113+
// not use, so a missing lock is a zero one. An UNREADABLE lock is the
114+
// third state, and it must not collapse into the zero — a zero lock is
115+
// read as "nothing frozen", which is a claim about spendable money made
116+
// out of a string nobody could parse.
117+
LockKnown bool
111118
// UniqueID is Subscan's identity for this asset ON THIS CHAIN
112119
// ("standard_assets/30", "standard_foreign_assets/6212dc…"). It is what an
113120
// asset_external_ref is keyed by, so a second asset claiming the same
@@ -212,9 +219,9 @@ func (d tokensData) tokens(chain string) ([]Token, []string) {
212219
skipped = append(skipped, fmt.Sprintf("%s: token %s (%s) reports no decimals", chain, e.Symbol, id))
213220
continue
214221
}
215-
balance, err := parseAmount(chain, "token balance", e.Balance)
216-
if err != nil {
217-
skipped = append(skipped, err.Error())
222+
balance, berr := parseAmount(chain, "token balance", e.Balance)
223+
if berr != nil {
224+
skipped = append(skipped, berr.Error())
218225
continue
219226
}
220227
seen[id] = true
@@ -223,12 +230,23 @@ func (d tokensData) tokens(chain string) ([]Token, []string) {
223230
// response at zero. It is not a position.
224231
continue
225232
}
233+
lock, lerr := parseAmount(chain, "token lock", e.Lock)
234+
lockKnown := lerr == nil
235+
if !lockKnown {
236+
// The position is still real and still reported; only its
237+
// partition is withheld. Named here so the withholding leaves
238+
// a trace, which is the half of personal-feb.13 that applies.
239+
skipped = append(skipped, fmt.Sprintf(
240+
"%s: token %s (%s) has an unreadable lock %q; its liquidity is left unstated",
241+
chain, e.Symbol, id, e.Lock))
242+
}
226243
out = append(out, Token{
227-
Symbol: e.Symbol,
228-
Decimals: *e.Decimals,
229-
Balance: balance,
230-
Lock: optionalAmount(e.Lock),
231-
UniqueID: id,
244+
Symbol: e.Symbol,
245+
Decimals: *e.Decimals,
246+
Balance: balance,
247+
Lock: lock,
248+
LockKnown: lockKnown,
249+
UniqueID: id,
232250
})
233251
}
234252
}

internal/adapter/subscan/syncer.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,20 @@ func splitLiquidity(a Account, row func(decimal.Decimal, entity.Liquidity) entit
158158
// it becomes an asset_external_ref under "onchain:<chain>", so an unconfirmed
159159
// asset lands in its own market instead of on top of the ticker it claims.
160160
//
161-
// LIQUIDITY IS STATED ONLY WHERE IT IS KNOWN. A zero lock is a position with
162-
// nothing frozen, which is exactly "liquid". A non-zero lock is left
163-
// unpartitioned: no captured response has ever carried one, so whether it is a
164-
// subset of balance (as the native coin's is) or something beside it has not
165-
// been measured — and the two answers differ in the direction that overstates
166-
// spendable money. An unknown liquidity is a gap; a wrong one is a false claim.
161+
// LIQUIDITY IS STATED ONLY WHERE IT IS KNOWN, and "known" is three states, not
162+
// two. A lock the entry reports as zero — or omits, which is how Subscan
163+
// reports a component an account does not use — is a position with nothing
164+
// frozen, exactly "liquid". A non-zero lock is left unpartitioned: no captured
165+
// response has ever carried one, so whether it is a subset of balance (as the
166+
// native coin's is) or something beside it has not been measured. A lock that
167+
// is present and UNREADABLE is left unpartitioned too, and that third state is
168+
// the reason this is not a bare IsZero: a zero standing in for an unparsed
169+
// string would state "nothing frozen" on the strength of a number nobody read,
170+
// and it would state it in the direction that overstates spendable money. An
171+
// unknown liquidity is a gap; a wrong one is a false claim.
167172
func tokenBalance(chain string, net network, t Token) entity.WalletBalance {
168173
liquidity := entity.LiquidityUnknown
169-
if t.Lock.IsZero() {
174+
if t.LockKnown && t.Lock.IsZero() {
170175
liquidity = entity.LiquidityLiquid
171176
}
172177
return entity.WalletBalance{

internal/adapter/subscan/syncer_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,3 +702,54 @@ func TestSyncWallet_ALockedTokenIsNotClaimedSpendable(t *testing.T) {
702702
assert.Equal(t, "895736192688", balances[0].Amount)
703703
assert.Equal(t, entity.LiquidityUnknown, balances[0].Liquidity)
704704
}
705+
706+
// TestSyncWallet_AnUnreadableLockIsNotAZeroLock closes the hole the adjacent
707+
// personal-feb.13 pointed at, one layer over from where that ticket looks.
708+
//
709+
// A lock is read with the parser that returns zero on failure, and the liquidity
710+
// rule asked whether the lock was zero. So a lock nobody could parse arrived as
711+
// "nothing frozen" — a claim about spendable money made out of a string that was
712+
// never read, and made in the one direction that overstates it. The entry is
713+
// still a position; only its partition is withheld, and the withholding is named.
714+
func TestSyncWallet_AnUnreadableLockIsNotAZeroLock(t *testing.T) {
715+
syncer := newTestSyncer(t, respondJSON(`{
716+
"code": 0, "message": "Success",
717+
"data": {
718+
"native": [],
719+
"assets": [
720+
{"symbol": "DED", "unique_id": "standard_assets/30", "decimals": 10,
721+
"balance": "895736192688", "lock": "not-a-number"}
722+
]
723+
}
724+
}`))
725+
726+
balances, err := syncer.SyncWallet(context.Background(), "5Dsvsa", []string{"assethub-polkadot"})
727+
require.ErrorContains(t, err, "unreadable lock")
728+
require.Len(t, balances, 1, "the position is reported; only its partition is withheld")
729+
assert.Equal(t, "895736192688", balances[0].Amount)
730+
assert.Equal(t, entity.LiquidityUnknown, balances[0].Liquidity)
731+
}
732+
733+
// TestSyncWallet_AnAbsentLockIsAZeroLock is the other side of that line, and it
734+
// is measured rather than assumed: in one live Polkadot Asset Hub response DED
735+
// carries "lock": "0" and MYTH carries no lock field at all. Subscan omits a
736+
// component an account does not use, so absent is zero — and reading it as
737+
// unknown would drop every token into an unstated liquidity.
738+
func TestSyncWallet_AnAbsentLockIsAZeroLock(t *testing.T) {
739+
syncer := newTestSyncer(t, respondJSON(`{
740+
"code": 0, "message": "Success",
741+
"data": {
742+
"native": [],
743+
"assets": [
744+
{"symbol": "MYTH",
745+
"unique_id": "standard_foreign_assets/6212dc295daf309533f0f5873ec3f3e62d9dba33",
746+
"decimals": 18, "balance": "57000000000000000000"}
747+
]
748+
}
749+
}`))
750+
751+
balances, err := syncer.SyncWallet(context.Background(), "5Dsvsa", []string{"assethub-polkadot"})
752+
require.NoError(t, err)
753+
require.Len(t, balances, 1)
754+
assert.Equal(t, entity.LiquidityLiquid, balances[0].Liquidity)
755+
}

0 commit comments

Comments
 (0)