Repository navigation
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
Description
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"→isBinaryStringreturns true.fromBinaryString("10")reads it as a 2-bit bitstring1, 0→ numeric value 2.- The
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
- Critical for any production wallet: as soon as seqno crosses 16, all outgoing transfers fail.
- No exception is raised by the SDK —
GetSeqnoreturns a validuint, 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);
}
Activity
[-]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[/-][+]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[/+]