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);
}
Wallet.GetSeqnoreturns wrong value (e.g. 2 instead of 16) on Toncenter v3 — silent corruption of any hex number containing only0and1digitsSummary
When using
TonClientType.HTTP_TONCENTERAPIV3,Wallet.GetSeqno()(and any other call that goes throughrunGetMethod) silently returns the wrong number whenever the real value's hex representation happens to consist only of the characters0and1.For wallets, the bug stays invisible for the first 15 transactions and breaks every transaction after that, because seqno 16 =
0x10is 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 withLITE_SERVER_UNKNOWN: cannot apply external message to current state : Too old seqno: msg_seqno=2, wallet_seqno=16.Reproduction
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 v3returns numbers in stack items as hex strings with a0xprefix, e.g. forseqno=16the response contains:{ "type": "num", "value": "0x10" }2. How the SDK parses it
In
TonSdk.Client/src/Models/Transformers.cs,ParseStackItem(JObject item)fortype == "num"does this (current code):After stripping
0xwe hand the hex digits (here"10") tonew Bits(...).3. What
new Bits("10")doesIn
TonSdk.Core/src/boc/bits/Bits.cs,fromString(s)tries to auto-detect the input format:4. What
isBinaryStringisIn
TonSdk.Core/src/boc/bits/Utils.cs:The pattern
^[01]+$matches any string consisting solely of0and1characters.5. Putting it together
For
slice = "10":^[01]+$matches"10"→isBinaryStringreturns true.fromBinaryString("10")reads it as a 2-bit bitstring1, 0→ numeric value 2.isHexStringbranch 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
GetSeqnoreturns a validuint, the message is signed normally, the rejection only comes from the network when the BOC is broadcast.Suggested fix
The v3 endpoint always returns numbers as hex with a
0xprefix — there's no need to auto-detect. Parse the hex slice directly withBigInteger.Parse(..., NumberStyles.HexNumber):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: