Skip to content

BUG: Wallet.GetSeqno returns wrong value (e.g. 2 instead of 16) on Toncenter v3 — silent corruption of any hex number containing only 0 and 1 digits #152

@pavelrubanov

Description

@pavelrubanov

Wallet.GetSeqno returns wrong value (e.g. 2 instead of 16) on Toncenter v3 — silent corruption of any hex number containing only 0 and 1 digits

Summary

When using TonClientType.HTTP_TONCENTERAPIV3, Wallet.GetSeqno() (and any other call that goes through runGetMethod) silently returns the wrong number whenever the real value's hex representation happens to consist only of the characters 0 and 1.

For wallets, the bug stays invisible for the first 15 transactions and breaks every transaction after that, because seqno 16 = 0x10 is the first value that triggers it. The SDK throws no error — it just returns the wrong number, you sign with the wrong seqno, and the blockchain rejects every outgoing transaction with LITE_SERVER_UNKNOWN: cannot apply external message to current state : Too old seqno: msg_seqno=2, wallet_seqno=16.

Reproduction

var client = new TonClient(TonClientType.HTTP_TONCENTERAPIV3,
    new HttpParameters { Endpoint = "https://toncenter.com/api/v3/" });

// pick any V4 wallet that has done at least 16 outgoing transactions
var addr = new Address("UQBhbyRe2k8jTFPJ9Rz6brCeCDE00Bc6xNn-vhG7kEnaTPyb");

var seqno = await client.Wallet.GetSeqno(addr);
// returns 2
// expected: 16 (confirmed by GET /api/v3/walletStates?address=...)

For the same address, GET https://toncenter.com/api/v3/walletStates?address=... returns the correct seqno (16), so the API itself is fine — the bug is purely in the SDK's parser.

Root cause — step by step

1. Where the value comes from

Toncenter v3 returns numbers in stack items as hex strings with a 0x prefix, e.g. for seqno=16 the response contains:

{ "type": "num", "value": "0x10" }

2. How the SDK parses it

In TonSdk.Client/src/Models/Transformers.cs, ParseStackItem(JObject item) for type == "num" does this (current code):

case "num":
{
    string valueStr = item["value"].ToString();              // "0x10"
    bool isNegative = valueStr[0] == '-';                     // false
    string slice = isNegative
        ? valueStr.Substring(3)
        : valueStr.Substring(2);                              // strips "0x"  →  "10"

    BitsSlice bitsSlice = new Bits(slice).Parse();            // ← problem starts here
    BigInteger x = bitsSlice.LoadUInt(bitsSlice.RemainderBits);
    return isNegative ? 0 - x : x;
}

After stripping 0x we hand the hex digits (here "10") to new Bits(...).

3. What new Bits("10") does

In TonSdk.Core/src/boc/bits/Bits.cs, fromString(s) tries to auto-detect the input format:

private static BitArray fromString(string s) {
    BitArray bits;
    if (s.isBinaryString()) {           // ← checked FIRST
        bits = fromBinaryString(s);
    }
    else if (s.isHexString()) {
        bits = fromHexString(s);
    }
    else if (s.isBase64()) { ... }
    ...
}

4. What isBinaryString is

In TonSdk.Core/src/boc/bits/Utils.cs:

private const string BinaryString = @"^[01]+$";
private static readonly Regex BinaryStringRegex = new Regex(BinaryString);

public static bool isBinaryString(this string s) {
    return BinaryStringRegex.IsMatch(s);
}

The pattern ^[01]+$ matches any string consisting solely of 0 and 1 characters.

5. Putting it together

For slice = "10":

  • ^[01]+$ matches "10"isBinaryString returns true.
  • fromBinaryString("10") reads it as a 2-bit bitstring 1, 0 → numeric value 2.
  • The isHexString branch is never reached.

But after stripping 0x, "10" was meant to be hex, i.e. 16. The parser had no way to remember that the original string was "0x10" — it just sees "10" and the auto-detection guesses wrong.

Impact

  • Critical for any production wallet: as soon as seqno crosses 16, all outgoing transfers fail.
  • No exception is raised by the SDK — GetSeqno returns a valid uint, the message is signed normally, the rejection only comes from the network when the BOC is broadcast.
  • Easy to miss in dev/staging: most test runs don't get past 15 outgoing transactions, so the bug shows up only after deployment to a long-lived wallet.

Suggested fix

The v3 endpoint always returns numbers as hex with a 0x prefix — there's no need to auto-detect. Parse the hex slice directly with BigInteger.Parse(..., NumberStyles.HexNumber):

case "num":
{
    string valueStr = item["value"].ToString();
    if (valueStr == null)
        throw new Exception("Expected a string value for 'num' type.");

    bool isNegative = valueStr[0] == '-';
    string slice = isNegative ? valueStr.Substring(3) : valueStr.Substring(2);
    // Toncenter v3 always returns numbers as hex; parse directly to avoid the
    // Bits-auto-detect ambiguity that mis-classifies hex strings like "10" or "11"
    // as binary bitstrings.
    // Leading "0" forces unsigned interpretation: BigInteger.Parse otherwise reads
    // a leading hex digit ≥ 8 as the sign bit (e.g. "80" → -128, but "080" → 128).
    BigInteger x = BigInteger.Parse("0" + slice, NumberStyles.HexNumber, CultureInfo.InvariantCulture);

    return isNegative ? -x : x;
}

The v2 overload (ParseStackItem(object[] item) a few lines below) already does byte-level hex parsing and is not affected.

Test case

A unit test that would have caught it:

[Theory]
[InlineData("0x10", 16)]       // currently returns 2
[InlineData("0x11", 17)]       // currently returns 3
[InlineData("0x100", 256)]     // currently returns 4
[InlineData("0x1000", 4096)]   // currently returns 8
[InlineData("0x12", 18)]       // works
[InlineData("0xff", 255)]      // works
public void ParseStackItem_HexNumber_ReturnsCorrectValue(string hex, long expected)
{
    var json = JObject.Parse($"{{ \"type\": \"num\", \"value\": \"{hex}\" }}");
    var result = (BigInteger)RunGetMethodResult.ParseStackItem(json);
    Assert.Equal(expected, (long)result);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions